auto: sync OpenClaw config 2026-09-09 16:13

This commit is contained in:
2026-09-09 16:13:31 +08:00
parent c79d73c0fe
commit 0aa13d3cf0
440 changed files with 51636 additions and 13 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Abort trigger detection for the Lark/Feishu channel plugin.
*
* Provides a fast-path check to determine whether an inbound message is
* an abort/stop command *before* it enters the per-chat serial queue.
*
* The trigger word list and normalisation logic are copied from the
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
* make a lightweight decision without importing the full reply pipeline.
* The message still flows through `tryFastAbortFromMessage()` for
* authoritative handling.
*/
import type { FeishuMessageEvent } from '../messaging/types';
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
export declare function isAbortTrigger(text: string): boolean;
/**
* Extended abort detection: matches both bare trigger words and the
* `/stop` command form. Used by the monitor fast-path.
*/
export declare function isLikelyAbortText(text: string): boolean;
/**
* Whether an inbound message expresses intent to stop / interrupt the ongoing
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
* conversational phrases above.
*
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
* the in-flight ping-pong drains instead of being re-armed. Substring match —
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
* the worst case is a missed forced-@ or a self-healing mute (any normal
* message lifts it).
*/
export declare function isConversationStopIntent(text: string): boolean;
/**
* Extract the raw text payload from a Feishu message event.
*
* Only handles `text` type messages. The `message.content` field is a
* JSON string like `{"text":"hello"}`. Returns `undefined` for
* non-text messages or parse failures.
*
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
* a message like `@Bot stop` is detected as `stop`.
*/
export declare function extractRawTextFromEvent(event: FeishuMessageEvent): string | undefined;
@@ -0,0 +1,216 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Abort trigger detection for the Lark/Feishu channel plugin.
*
* Provides a fast-path check to determine whether an inbound message is
* an abort/stop command *before* it enters the per-chat serial queue.
*
* The trigger word list and normalisation logic are copied from the
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
* make a lightweight decision without importing the full reply pipeline.
* The message still flows through `tryFastAbortFromMessage()` for
* authoritative handling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isAbortTrigger = isAbortTrigger;
exports.isLikelyAbortText = isLikelyAbortText;
exports.isConversationStopIntent = isConversationStopIntent;
exports.extractRawTextFromEvent = extractRawTextFromEvent;
// ---------------------------------------------------------------------------
// Trigger word list (synced with OpenClaw core abort.ts)
// ---------------------------------------------------------------------------
const ABORT_TRIGGERS = new Set([
'stop',
'esc',
'abort',
'wait',
'exit',
'interrupt',
'detente',
'deten',
'detén',
'arrete',
'arrête',
'停止',
'やめて',
'止めて',
'रुको',
'توقف',
'стоп',
'остановись',
'останови',
'остановить',
'прекрати',
'halt',
'anhalten',
'aufhören',
'hoer auf',
'stopp',
'pare',
'stop openclaw',
'openclaw stop',
'stop action',
'stop current action',
'stop run',
'stop current run',
'stop agent',
'stop the agent',
"stop don't do anything",
'stop dont do anything',
'stop do not do anything',
'stop doing anything',
'do not do that',
'please stop',
'stop please',
]);
// ---------------------------------------------------------------------------
// Normalisation helpers
// ---------------------------------------------------------------------------
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;:'"'")\]}]+$/u;
function normalizeAbortTriggerText(text) {
return text
.trim()
.toLowerCase()
.replace(/['`]/g, "'")
.replace(/\s+/g, ' ')
.replace(TRAILING_ABORT_PUNCTUATION_RE, '')
.trim();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
function isAbortTrigger(text) {
if (!text)
return false;
const normalized = normalizeAbortTriggerText(text);
return ABORT_TRIGGERS.has(normalized);
}
/**
* Extended abort detection: matches both bare trigger words and the
* `/stop` command form. Used by the monitor fast-path.
*/
function isLikelyAbortText(text) {
if (!text)
return false;
const trimmed = text.trim().toLowerCase();
if (trimmed === '/stop')
return true;
return isAbortTrigger(trimmed);
}
// ---------------------------------------------------------------------------
// Conversation stop-intent (broader than the exact abort triggers)
// ---------------------------------------------------------------------------
/**
* Conversational "please stop / interrupt this exchange" phrases.
*
* Deliberately SEPARATE from {@link ABORT_TRIGGERS} (which is synced word-for-
* word with OpenClaw core and matched by exact equality, e.g. `/stop`). These
* are matched by substring so natural phrasings like "中断对话" or "stop
* talking" are caught. The list is intentionally distinctive to avoid false
* positives — a false positive only means we skip the deterministic peer-@
* backstop for that turn (the model can still @ on its own), which is mild.
*/
const STOP_INTENT_PHRASES = [
// zh — stop / terminate / pause
'中断',
'中止',
'终止',
'停止',
'停下',
'停一下',
'暂停',
'打住',
'停手',
'收手',
// zh — "don't keep going / replying"
'别聊',
'别说了',
'别回复',
'别继续',
'别再聊',
'别再说',
'别吵',
'别争',
'不要回复',
'不要继续',
'不用回复',
'不用继续',
// zh — "wrap up / be quiet"
'结束对话',
'结束讨论',
'结束辩论',
'到此为止',
'闭嘴',
// en
'stop talking',
'stop chatting',
'stop debating',
'stop the debate',
'stop the conversation',
'stop this conversation',
'stop responding',
'stop replying',
'end the conversation',
'end conversation',
'end the debate',
'shut up',
'be quiet',
'cut it out',
'knock it off',
'wrap it up',
'stand down',
];
/**
* Whether an inbound message expresses intent to stop / interrupt the ongoing
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
* conversational phrases above.
*
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
* the in-flight ping-pong drains instead of being re-armed. Substring match —
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
* the worst case is a missed forced-@ or a self-healing mute (any normal
* message lifts it).
*/
function isConversationStopIntent(text) {
if (!text)
return false;
// Drop bot mention placeholders so "@Bot 中断对话" → "中断对话".
const normalized = text.replace(/@_user_\d+/g, '').trim().toLowerCase();
if (!normalized)
return false;
if (isLikelyAbortText(normalized))
return true;
return STOP_INTENT_PHRASES.some((p) => normalized.includes(p));
}
/**
* Extract the raw text payload from a Feishu message event.
*
* Only handles `text` type messages. The `message.content` field is a
* JSON string like `{"text":"hello"}`. Returns `undefined` for
* non-text messages or parse failures.
*
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
* a message like `@Bot stop` is detected as `stop`.
*/
function extractRawTextFromEvent(event) {
if (!event.message || event.message.message_type !== 'text') {
return undefined;
}
try {
const parsed = JSON.parse(event.message.content);
let text = parsed?.text;
if (typeof text !== 'string')
return undefined;
// Strip bot mention placeholders (@_user_1, @_user_2, etc.)
text = text.replace(/@_user_\d+/g, '').trim();
return text || undefined;
}
catch {
return undefined;
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Process-level chat task queue.
*
* Although located in channel/, this module is intentionally shared
* across channel, messaging, tools, and card layers as a process-level
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
*
* Ensures tasks targeting the same account+chat are executed serially.
* Used by both websocket inbound messages and synthetic message paths.
*/
type QueueStatus = 'queued' | 'immediate';
export interface ActiveDispatcherEntry {
abortCard: () => Promise<void>;
abortController?: AbortController;
}
/**
* Append `:thread:{threadId}` suffix when threadId is present.
* Consistent with the SDK's `:thread:` separator convention.
*/
export declare function threadScopedKey(base: string, threadId?: string): string;
export declare function buildQueueKey(accountId: string, chatId: string, threadId?: string): string;
export declare function registerActiveDispatcher(key: string, entry: ActiveDispatcherEntry): void;
export declare function unregisterActiveDispatcher(key: string): void;
export declare function getActiveDispatcher(key: string): ActiveDispatcherEntry | undefined;
/** Check whether the queue has an active task for the given key. */
export declare function hasActiveTask(key: string): boolean;
export declare function enqueueFeishuChatTask(params: {
accountId: string;
chatId: string;
threadId?: string;
task: () => Promise<void>;
}): {
status: QueueStatus;
promise: Promise<void>;
};
/** @internal Test-only: reset all queue and dispatcher state. */
export declare function _resetChatQueueState(): void;
export {};
@@ -0,0 +1,68 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Process-level chat task queue.
*
* Although located in channel/, this module is intentionally shared
* across channel, messaging, tools, and card layers as a process-level
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
*
* Ensures tasks targeting the same account+chat are executed serially.
* Used by both websocket inbound messages and synthetic message paths.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.threadScopedKey = threadScopedKey;
exports.buildQueueKey = buildQueueKey;
exports.registerActiveDispatcher = registerActiveDispatcher;
exports.unregisterActiveDispatcher = unregisterActiveDispatcher;
exports.getActiveDispatcher = getActiveDispatcher;
exports.hasActiveTask = hasActiveTask;
exports.enqueueFeishuChatTask = enqueueFeishuChatTask;
exports._resetChatQueueState = _resetChatQueueState;
const chatQueues = new Map();
const activeDispatchers = new Map();
/**
* Append `:thread:{threadId}` suffix when threadId is present.
* Consistent with the SDK's `:thread:` separator convention.
*/
function threadScopedKey(base, threadId) {
return threadId ? `${base}:thread:${threadId}` : base;
}
function buildQueueKey(accountId, chatId, threadId) {
return threadScopedKey(`${accountId}:${chatId}`, threadId);
}
function registerActiveDispatcher(key, entry) {
activeDispatchers.set(key, entry);
}
function unregisterActiveDispatcher(key) {
activeDispatchers.delete(key);
}
function getActiveDispatcher(key) {
return activeDispatchers.get(key);
}
/** Check whether the queue has an active task for the given key. */
function hasActiveTask(key) {
return chatQueues.has(key);
}
function enqueueFeishuChatTask(params) {
const { accountId, chatId, threadId, task } = params;
const key = buildQueueKey(accountId, chatId, threadId);
const prev = chatQueues.get(key) ?? Promise.resolve();
const status = chatQueues.has(key) ? 'queued' : 'immediate';
const taskPromise = prev.then(task, task);
chatQueues.set(key, taskPromise);
const cleanup = () => {
if (chatQueues.get(key) === taskPromise) {
chatQueues.delete(key);
}
};
taskPromise.then(cleanup, cleanup);
return { status, promise: taskPromise };
}
/** @internal Test-only: reset all queue and dispatcher state. */
function _resetChatQueueState() {
chatQueues.clear();
activeDispatchers.clear();
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Configuration merge helpers for Feishu account management.
*
* Centralises the pattern of merging a partial configuration patch
* into the Feishu section of the top-level ClawdbotConfig, handling
* both the default account (top-level fields) and named accounts
* (nested under `accounts`).
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
/** Set the `enabled` flag on a Feishu account. */
export declare function setAccountEnabled(cfg: ClawdbotConfig, accountId: string, enabled: boolean): ClawdbotConfig;
/** Apply an arbitrary config patch to a Feishu account. */
export declare function applyAccountConfig(cfg: ClawdbotConfig, accountId: string, patch: Record<string, unknown>): ClawdbotConfig;
/** Delete a Feishu account entry from the config. */
export declare function deleteAccount(cfg: ClawdbotConfig, accountId: string): ClawdbotConfig;
/** Collect security warnings for a Feishu account. */
export declare function collectFeishuSecurityWarnings(params: {
cfg: ClawdbotConfig;
accountId: string;
}): string[];
@@ -0,0 +1,107 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Configuration merge helpers for Feishu account management.
*
* Centralises the pattern of merging a partial configuration patch
* into the Feishu section of the top-level ClawdbotConfig, handling
* both the default account (top-level fields) and named accounts
* (nested under `accounts`).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.setAccountEnabled = setAccountEnabled;
exports.applyAccountConfig = applyAccountConfig;
exports.deleteAccount = deleteAccount;
exports.collectFeishuSecurityWarnings = collectFeishuSecurityWarnings;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const accounts_1 = require("../core/accounts.js");
const security_check_1 = require("../core/security-check.js");
/** Generic Feishu account config merge. */
function mergeFeishuAccountConfig(cfg, accountId, patch) {
const isDefault = !accountId || accountId === account_id_1.DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, ...patch },
},
};
}
const feishuCfg = cfg.channels?.feishu;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: { ...feishuCfg?.accounts?.[accountId], ...patch },
},
},
},
};
}
/** Set the `enabled` flag on a Feishu account. */
function setAccountEnabled(cfg, accountId, enabled) {
return mergeFeishuAccountConfig(cfg, accountId, { enabled });
}
/** Apply an arbitrary config patch to a Feishu account. */
function applyAccountConfig(cfg, accountId, patch) {
return mergeFeishuAccountConfig(cfg, accountId, patch);
}
/** Delete a Feishu account entry from the config. */
function deleteAccount(cfg, accountId) {
const isDefault = !accountId || accountId === account_id_1.DEFAULT_ACCOUNT_ID;
if (isDefault) {
// Delete entire feishu config
const next = { ...cfg };
const nextChannels = { ...cfg.channels };
delete nextChannels.feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
}
else {
delete next.channels;
}
return next;
}
// Delete specific account from accounts
const feishuCfg = cfg.channels?.feishu;
const accounts = { ...feishuCfg?.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
}
/** Collect security warnings for a Feishu account. */
function collectFeishuSecurityWarnings(params) {
const { cfg, accountId } = params;
const warnings = [];
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const feishuCfg = account.config;
// cfg.channels.defaults is a cross-channel defaults object (not formally typed)
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
if (groupPolicy === 'open') {
warnings.push(`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any group to interact (mention-gated). To restrict which groups are allowed, set groupPolicy="allowlist" and list group IDs in channels.feishu.groups. To restrict which senders can trigger the bot, set channels.feishu.groupAllowFrom with user open_ids (ou_xxx).`);
}
// Multi-account cross-tenant isolation check (only on first account to avoid duplicates)
const allIds = (0, accounts_1.getLarkAccountIds)(cfg);
if (allIds.length === 0 || accountId === allIds[0]) {
for (const w of (0, security_check_1.collectIsolationWarnings)(cfg)) {
warnings.push(w);
}
}
return warnings;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Directory listing for Feishu peers (users) and groups.
*
* Provides both config-based (offline) and live API directory
* lookups so the outbound subsystem and UI can resolve targets.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuDirectoryGroup, FeishuDirectoryPeer } from './types';
export type { FeishuDirectoryPeer, FeishuDirectoryGroup } from './types';
/**
* List users known from the channel config (allowFrom + dms fields).
*
* Does not make any API calls -- useful when the bot is not yet
* connected or when credentials are unavailable.
*/
export declare function listFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]>;
/**
* List groups known from the channel config (groups + groupAllowFrom).
*/
export declare function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]>;
/**
* List users via the Feishu contact/v3/users API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
export declare function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]>;
/**
* List groups via the Feishu im/v1/chats API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
export declare function listFeishuDirectoryGroupsLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]>;
@@ -0,0 +1,197 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Directory listing for Feishu peers (users) and groups.
*
* Provides both config-based (offline) and live API directory
* lookups so the outbound subsystem and UI can resolve targets.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.listFeishuDirectoryPeers = listFeishuDirectoryPeers;
exports.listFeishuDirectoryGroups = listFeishuDirectoryGroups;
exports.listFeishuDirectoryPeersLive = listFeishuDirectoryPeersLive;
exports.listFeishuDirectoryGroupsLive = listFeishuDirectoryGroupsLive;
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
const targets_1 = require("../core/targets.js");
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Case-insensitive substring match on id and optional name. */
function matchesQuery(id, name, query) {
if (!query)
return true;
return id.toLowerCase().includes(query) || (name?.toLowerCase().includes(query) ?? false);
}
/** Filter items and apply optional limit. */
function applyLimitSlice(items, limit) {
return limit && limit > 0 ? items.slice(0, limit) : items;
}
// ---------------------------------------------------------------------------
// Config-based (offline) directory
// ---------------------------------------------------------------------------
/**
* List users known from the channel config (allowFrom + dms fields).
*
* Does not make any API calls -- useful when the bot is not yet
* connected or when credentials are unavailable.
*/
async function listFeishuDirectoryPeers(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || '';
const ids = new Set();
// Collect from allowFrom entries.
for (const entry of feishuCfg?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
// Collect from per-user DM config keys.
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) {
ids.add(trimmed);
}
}
const peers = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => (0, targets_1.normalizeFeishuTarget)(raw) ?? raw)
.filter((id) => matchesQuery(id, undefined, q))
.map((id) => ({ kind: 'user', id }));
return applyLimitSlice(peers, params.limit);
}
/**
* List groups known from the channel config (groups + groupAllowFrom).
*/
async function listFeishuDirectoryGroups(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || '';
const ids = new Set();
// Collect from per-group config keys.
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
const trimmed = groupId.trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
// Collect from groupAllowFrom entries.
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
const groups = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.filter((id) => matchesQuery(id, undefined, q))
.map((id) => ({ kind: 'group', id }));
return applyLimitSlice(groups, params.limit);
}
// ---------------------------------------------------------------------------
// Live API directory
// ---------------------------------------------------------------------------
/**
* List users via the Feishu contact/v3/users API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
async function listFeishuDirectoryPeersLive(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
if (!account.configured) {
return listFeishuDirectoryPeers(params);
}
try {
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
const peers = [];
const limit = params.limit ?? 50;
if (limit <= 0)
return [];
const q = params.query?.trim().toLowerCase() || '';
let pageToken;
do {
const remaining = limit - peers.length;
const response = await client.contact.user.list({
params: {
page_size: Math.min(remaining, 50),
page_token: pageToken,
},
});
if (response.code !== 0 || !response.data?.items)
break;
for (const user of response.data.items) {
if (user.open_id && matchesQuery(user.open_id, user.name, q)) {
peers.push({
kind: 'user',
id: user.open_id,
name: user.name || undefined,
});
}
if (peers.length >= limit)
break;
}
pageToken = response.data?.page_token;
} while (pageToken && peers.length < limit);
return peers;
}
catch {
// Fallback to config-based listing on API failure.
return listFeishuDirectoryPeers(params);
}
}
/**
* List groups via the Feishu im/v1/chats API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
async function listFeishuDirectoryGroupsLive(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
if (!account.configured) {
return listFeishuDirectoryGroups(params);
}
try {
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
const groups = [];
const limit = params.limit ?? 50;
if (limit <= 0)
return [];
const q = params.query?.trim().toLowerCase() || '';
let pageToken;
do {
const remaining = limit - groups.length;
const response = await client.im.chat.list({
params: {
page_size: Math.min(remaining, 100),
page_token: pageToken,
},
});
if (response.code !== 0 || !response.data?.items)
break;
for (const chat of response.data.items) {
if (chat.chat_id && matchesQuery(chat.chat_id, chat.name, q)) {
groups.push({
kind: 'group',
id: chat.chat_id,
name: chat.name || undefined,
});
}
if (groups.length >= limit)
break;
}
pageToken = response.data?.page_token;
} while (pageToken && groups.length < limit);
return groups;
}
catch {
// Fallback to config-based listing on API failure.
return listFeishuDirectoryGroups(params);
}
}
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event handlers for the Feishu WebSocket monitor.
*
* Extracted from monitor.ts to improve testability and reduce
* function size. Each handler receives a MonitorContext with all
* dependencies needed to process the event.
*/
import type { MonitorContext } from './types';
export declare function handleMessageEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleReactionEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleBotMembershipEvent(ctx: MonitorContext, data: unknown, action: 'added' | 'removed'): Promise<void>;
export declare function handleVcMeetingInvitedEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleCommentEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleCardActionEvent(ctx: MonitorContext, data: unknown): Promise<unknown>;
@@ -0,0 +1,380 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event handlers for the Feishu WebSocket monitor.
*
* Extracted from monitor.ts to improve testability and reduce
* function size. Each handler receives a MonitorContext with all
* dependencies needed to process the event.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleMessageEvent = handleMessageEvent;
exports.handleReactionEvent = handleReactionEvent;
exports.handleBotMembershipEvent = handleBotMembershipEvent;
exports.handleVcMeetingInvitedEvent = handleVcMeetingInvitedEvent;
exports.handleCommentEvent = handleCommentEvent;
exports.handleCardActionEvent = handleCardActionEvent;
const handler_1 = require("../messaging/inbound/handler.js");
const reaction_handler_1 = require("../messaging/inbound/reaction-handler.js");
const comment_handler_1 = require("../messaging/inbound/comment-handler.js");
const vc_meeting_invited_handler_1 = require("../messaging/inbound/vc-meeting-invited-handler.js");
const vc_sender_1 = require("../messaging/inbound/vc-sender.js");
const comment_context_1 = require("../messaging/inbound/comment-context.js");
const dedup_1 = require("../messaging/inbound/dedup.js");
const lark_ticket_1 = require("../core/lark-ticket.js");
const lark_logger_1 = require("../core/lark-logger.js");
const auto_auth_1 = require("../tools/auto-auth.js");
const ask_user_question_1 = require("../tools/ask-user-question.js");
const chat_queue_1 = require("./chat-queue.js");
const abort_detect_1 = require("./abort-detect.js");
const interactive_dispatch_1 = require("./interactive-dispatch.js");
const elog = (0, lark_logger_1.larkLogger)('channel/event-handlers');
// ---------------------------------------------------------------------------
// Event ownership validation
// ---------------------------------------------------------------------------
/**
* Verify that the event's app_id matches the current account.
*
* Lark SDK EventDispatcher flattens the v2 envelope header (which
* contains `app_id`) into the handler `data` object, so `app_id` is
* available directly on `data`.
*
* Returns `false` (discard event) when the app_id does not match.
*/
function isEventOwnershipValid(ctx, data) {
const expectedAppId = ctx.lark.account.appId;
if (!expectedAppId)
return true; // appId not configured — skip check
const eventAppId = data.app_id;
if (eventAppId == null)
return true; // SDK did not provide app_id — defensive skip
if (eventAppId !== expectedAppId) {
elog.warn('event app_id mismatch, discarding', {
accountId: ctx.accountId,
expected: expectedAppId,
received: String(eventAppId),
});
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Message handler
// ---------------------------------------------------------------------------
async function handleMessageEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
// Self-echo hard filter — drop messages authored by this very bot before
// dedup and enqueue. Prevents self-reply loops; the primary guardrail
// against bot-to-bot ping-pong.
//
// NOTE: if botOpenId is not yet populated (startup race before probe
// resolves), this filter is skipped. The downstream bot-sender gate
// (checkBotSenderGate) acts as fallback — bot messages default to
// `allowBots='mentions'`, so in groups they require an explicit @-mention
// of this bot to pass; DMs are pass-through under the default.
const senderOpenId = event.sender?.sender_id?.open_id;
const botOpenId = ctx.lark.botOpenId;
if (botOpenId && senderOpenId && senderOpenId === botOpenId) {
log(`feishu[${accountId}]: drop self-echo message ${event.message?.message_id ?? 'unknown'}`);
return;
}
const msgId = event.message?.message_id ?? 'unknown';
const chatId = event.message?.chat_id ?? '';
// In topic groups, reply events carry root_id but not thread_id.
// Use root_id as fallback so different topics get separate queue keys
// and can be processed in parallel.
const threadId = event.message?.thread_id || event.message?.root_id || undefined;
// Dedup — skip duplicate messages (e.g. from WebSocket reconnects).
if (!ctx.messageDedup.tryRecord(msgId, accountId)) {
log(`feishu[${accountId}]: duplicate message ${msgId}, skipping`);
return;
}
// Expiry — discard stale messages from reconnect replay.
if ((0, dedup_1.isMessageExpired)(event.message?.create_time)) {
log(`feishu[${accountId}]: message ${msgId} expired, discarding`);
return;
}
// ---- Abort fast-path ----
// If the message looks like an abort trigger and there is an active
// reply dispatcher for this chat, fire abortCard() immediately
// (before the message enters the serial queue) so the streaming
// card is terminated without waiting for the current task.
const abortText = (0, abort_detect_1.extractRawTextFromEvent)(event);
if (abortText && (0, abort_detect_1.isLikelyAbortText)(abortText)) {
const queueKey = (0, chat_queue_1.buildQueueKey)(accountId, chatId, threadId);
if ((0, chat_queue_1.hasActiveTask)(queueKey)) {
const active = (0, chat_queue_1.getActiveDispatcher)(queueKey);
if (active) {
log(`feishu[${accountId}]: abort fast-path triggered for chat ${chatId} (text="${abortText}")`);
active.abortController?.abort();
active.abortCard().catch((err) => {
error(`feishu[${accountId}]: abort fast-path abortCard failed: ${String(err)}`);
});
}
}
}
const { status } = (0, chat_queue_1.enqueueFeishuChatTask)({
accountId,
chatId,
threadId,
task: async () => {
try {
await (0, lark_ticket_1.withTicket)({
messageId: msgId,
chatId,
accountId,
startTime: Date.now(),
senderOpenId: event.sender?.sender_id?.open_id || '',
chatType: event.message?.chat_type || undefined,
threadId,
}, () => (0, handler_1.handleFeishuMessage)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
}));
}
catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
},
});
log(`feishu[${accountId}]: message ${msgId} in chat ${chatId}${threadId ? ` thread ${threadId}` : ''}${status}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Reaction handler
// ---------------------------------------------------------------------------
async function handleReactionEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
const msgId = event.message_id ?? 'unknown';
log(`feishu[${accountId}]: reaction event on message ${msgId}`);
// ---- Dedup: deterministic key based on message + emoji + operator ----
const emojiType = event.reaction_type?.emoji_type ?? '';
const operatorOpenId = event.user_id?.open_id ?? '';
const dedupKey = `${msgId}:reaction:${emojiType}:${operatorOpenId}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate reaction ${dedupKey}, skipping`);
return;
}
// ---- Expiry: discard stale reaction events ----
if ((0, dedup_1.isMessageExpired)(event.action_time)) {
log(`feishu[${accountId}]: reaction on ${msgId} expired, discarding`);
return;
}
// ---- Pre-resolve real chatId before enqueuing ----
// The API call (3s timeout) runs outside the queue so it doesn't
// block the serial chain, and is read-only so ordering is irrelevant.
const preResolved = await (0, reaction_handler_1.resolveReactionContext)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
accountId,
});
if (!preResolved)
return;
// ---- Enqueue with the real chatId (matches normal message queue key) ----
const { status } = (0, chat_queue_1.enqueueFeishuChatTask)({
accountId,
chatId: preResolved.chatId,
threadId: preResolved.threadId,
task: async () => {
try {
await (0, lark_ticket_1.withTicket)({
messageId: msgId,
chatId: preResolved.chatId,
accountId,
startTime: Date.now(),
senderOpenId: operatorOpenId,
chatType: preResolved.chatType,
threadId: preResolved.threadId,
}, () => (0, reaction_handler_1.handleFeishuReaction)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
preResolved,
}));
}
catch (err) {
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
}
},
});
log(`feishu[${accountId}]: reaction on ${msgId} (chatId=${preResolved.chatId}) — ${status}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Bot membership handler
// ---------------------------------------------------------------------------
async function handleBotMembershipEvent(ctx, data, action) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
log(`feishu[${accountId}]: bot ${action} ${action === 'removed' ? 'from' : 'to'} chat ${event.chat_id}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling bot ${action} event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// VC meeting invited handler
// ---------------------------------------------------------------------------
async function handleVcMeetingInvitedEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
const meetingNo = event.meeting?.meeting_no?.trim() ?? '';
const eventId = event.event_id?.trim() ?? '';
// Resolve the inviter identity through the shared helper so the
// diagnostics log and the dispatch handler always agree on the
// same sender semantics.
const sender = (0, vc_sender_1.resolveVcSender)(event);
const senderId = sender.senderId;
const invitedBotOpenId = event.bot?.id?.open_id?.trim() ?? '';
// VC invited origin/ownership diagnostics:
// - This handler is only reachable from the WebSocket monitor path.
// - We still log app_id/bot_open_id so operators can confirm the event
// is delivered to the expected bot/account, and see which required
// fields are missing when we skip.
const expectedAppId = ctx.lark.account.appId ?? '';
const eventAppId = event.app_id?.trim() ?? '';
log(`feishu[${accountId}]: vc invited event received (ingress=websocket)` +
`${eventId ? ` event_id=${eventId}` : ''}` +
`${eventAppId ? ` app_id=${eventAppId}` : ' app_id=<missing>'}` +
`${expectedAppId ? ` expected_app_id=${expectedAppId}` : ''}` +
`${invitedBotOpenId ? ` bot_open_id=${invitedBotOpenId}` : ' bot_open_id=<missing>'}` +
`${ctx.lark.botOpenId ? ` expected_bot_open_id=${ctx.lark.botOpenId}` : ''}` +
`${event.invite_time ? ` invite_time=${event.invite_time}` : ''}` +
` meeting_no_present=${meetingNo ? 'true' : 'false'}` +
` sender_present=${senderId ? 'true' : 'false'}` +
` sender_from=${sender.fromFallback}`);
if (!meetingNo) {
log(`feishu[${accountId}]: vc invited event missing meeting_no, skipping`);
return;
}
if (!senderId) {
log(`feishu[${accountId}]: vc invited event missing inviter identity, skipping`);
return;
}
if (ctx.lark.botOpenId && invitedBotOpenId && invitedBotOpenId !== ctx.lark.botOpenId) {
log(`feishu[${accountId}]: vc invited event for another bot, expected=${ctx.lark.botOpenId}, got=${invitedBotOpenId}, skipping`);
return;
}
// Prefer event_id when the SDK exposes it: historical raw payload logs
// show WebSocket reconnect replays reuse the same event_id, while a real
// second invitation yields a new event_id even for the same meeting/bot.
// Fallback to (meeting_no, bot) only when event_id is absent so older
// payload shapes still remain deduplicated.
const dedupBotKey = ctx.lark.botOpenId ?? invitedBotOpenId ?? 'no-bot';
const dedupKey = eventId ? `vc-invited:by-event:${eventId}` : `vc-invited:by-meeting:${meetingNo}:${dedupBotKey}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate vc invited event detected, skipping`);
return;
}
log(`feishu[${accountId}]: vc invited event accepted for synthetic dispatch`);
await (0, vc_meeting_invited_handler_1.handleFeishuVcMeetingInvited)({
cfg: ctx.cfg,
event,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
});
}
catch (err) {
error(`feishu[${accountId}]: error handling vc invited event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Drive comment handler
// ---------------------------------------------------------------------------
async function handleCommentEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const parsed = (0, comment_context_1.parseFeishuDriveCommentNoticeEventPayload)(data);
if (!parsed) {
log(`feishu[${accountId}]: invalid comment event payload, skipping`);
return;
}
const commentId = parsed.comment_id ?? '';
const replyId = parsed.reply_id ?? '';
// Parser has normalized notice_meta fields into canonical top-level fields
const _senderOpenId = parsed.user_id?.open_id ?? '';
const isMentioned = parsed.is_mention ?? false;
const eventTimestamp = parsed.action_time;
log(`feishu[${accountId}]: drive comment event: ` +
`type=${parsed.file_type}, comment=${commentId}` +
`${replyId ? `, reply=${replyId}` : ''}` +
`${isMentioned ? ', @bot' : ''}`);
// Dedup: build a deterministic key from the comment/reply IDs
const dedupKey = replyId ? `comment:${commentId}:reply:${replyId}` : `comment:${commentId}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate comment event ${dedupKey}, skipping`);
return;
}
// Expiry check
if ((0, dedup_1.isMessageExpired)(eventTimestamp)) {
log(`feishu[${accountId}]: comment event expired, discarding`);
return;
}
// Dispatch the comment event (no queue serialization needed for comment threads)
await (0, comment_handler_1.handleFeishuCommentEvent)({
cfg: ctx.cfg,
event: parsed,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
});
}
catch (err) {
error(`feishu[${accountId}]: error handling comment event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Card action handler
// ---------------------------------------------------------------------------
async function handleCardActionEvent(ctx, data) {
try {
// AskUserQuestion:表单卡片交互(宿主内建能力优先)
const askResult = (0, ask_user_question_1.handleAskUserAction)(data, ctx.cfg, ctx.accountId);
if (askResult !== undefined)
return askResult;
// auto-auth:授权/权限引导相关卡片交互(宿主内建能力优先)
const authResult = await (0, auto_auth_1.handleCardAction)(data, ctx.cfg, ctx.accountId);
if (authResult !== undefined)
return authResult;
// 业务自定义卡片交互:使用 SDK 标准 interactive dispatch 管道转发给业务插件。
return await (0, interactive_dispatch_1.dispatchFeishuPluginInteractiveHandler)({ cfg: ctx.cfg, accountId: ctx.accountId, data });
}
catch (err) {
elog.warn(`card.action.trigger handler error: ${err}`);
}
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu interactive dispatch wrapper.
*
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
* standard interactive dispatch pipeline:
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
* - Channel forwards via `dispatchPluginInteractiveHandler()`
*
* We intentionally do NOT maintain any channel-local global registry here.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
export type FeishuInteractiveHandlerResponse = unknown;
export interface FeishuInteractiveHandlerContext {
channel: 'feishu';
accountId: string;
senderId?: string;
conversationId?: string;
messageId?: string;
namespace: string;
payload: string;
action: string;
rawEvent: unknown;
respond: {
reply: (args: {
text: string;
}) => Promise<void>;
followUp: (args: {
text: string;
}) => Promise<void>;
/**
* Best-effort "edit current message" mapping.
* In Feishu, we prefer updating the original interactive card when possible.
*/
editMessage: (args: {
text?: string;
blocks?: unknown[];
}) => Promise<void>;
};
}
/**
* Dispatch a Feishu interactive card action to business plugins through
* the OpenClaw SDK's standard interactive dispatch pipeline.
*
* Returns `undefined` when:
* - the event does not look like an interactive action we can route, or
* - no plugin handler is registered for the derived namespace.
*
* @param params.cfg - OpenClaw config snapshot.
* @param params.accountId - Current Feishu account id.
* @param params.data - Raw `card.action.trigger` event payload.
*/
export declare function dispatchFeishuPluginInteractiveHandler(params: {
cfg: ClawdbotConfig;
accountId: string;
data: unknown;
}): Promise<unknown | undefined>;
@@ -0,0 +1,188 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu interactive dispatch wrapper.
*
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
* standard interactive dispatch pipeline:
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
* - Channel forwards via `dispatchPluginInteractiveHandler()`
*
* We intentionally do NOT maintain any channel-local global registry here.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.dispatchFeishuPluginInteractiveHandler = dispatchFeishuPluginInteractiveHandler;
// NOTE: This is the SDK-standard interactive pipeline.
const plugin_runtime_1 = require("openclaw/plugin-sdk/plugin-runtime");
const card_action_operator_1 = require("../core/card-action-operator.js");
const lark_logger_1 = require("../core/lark-logger.js");
const send_1 = require("../messaging/outbound/send.js");
const log = (0, lark_logger_1.larkLogger)('channel/interactive-dispatch');
function extractBasics(data) {
try {
const ev = data;
const action = ev.action?.value?.action;
if (!action || typeof action !== 'string')
return null;
const openChatId = ev.open_chat_id ?? ev.context?.open_chat_id;
const openMessageId = ev.open_message_id ?? ev.context?.open_message_id;
return {
action: action.trim(),
senderOpenId: (0, card_action_operator_1.resolveCardCallbackOperatorId)(ev.operator),
openChatId,
openMessageId,
};
}
catch {
return null;
}
}
function buildMarkdownCard(text) {
return {
schema: '2.0',
body: {
elements: [
{
tag: 'markdown',
content: text,
},
],
},
};
}
/**
* Dispatch a Feishu interactive card action to business plugins through
* the OpenClaw SDK's standard interactive dispatch pipeline.
*
* Returns `undefined` when:
* - the event does not look like an interactive action we can route, or
* - no plugin handler is registered for the derived namespace.
*
* @param params.cfg - OpenClaw config snapshot.
* @param params.accountId - Current Feishu account id.
* @param params.data - Raw `card.action.trigger` event payload.
*/
async function dispatchFeishuPluginInteractiveHandler(params) {
const basics = extractBasics(params.data);
if (!basics)
return undefined;
if (!basics.action)
return undefined;
const respond = {
reply: async (args) => {
if (!basics.openChatId || !String(args?.text || '').trim())
return;
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: String(args?.text || ''),
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
},
followUp: async (args) => {
if (!basics.openChatId || !String(args?.text || '').trim())
return;
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: String(args?.text || ''),
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
},
editMessage: async (args) => {
if (!basics.openMessageId) {
if (Array.isArray(args?.blocks) && args.blocks.length && basics.openChatId) {
await (0, send_1.sendCardFeishu)({
cfg: params.cfg,
to: basics.openChatId,
card: { schema: '2.0', body: { elements: args.blocks } },
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
return;
}
if (typeof args?.text === 'string' && args.text.trim() && basics.openChatId) {
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: args.text,
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
}
return;
}
if (Array.isArray(args?.blocks) && args.blocks.length) {
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: { schema: '2.0', body: { elements: args.blocks } },
accountId: params.accountId,
});
return;
}
if (typeof args?.text === 'string' && args.text.trim()) {
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: buildMarkdownCard(args.text),
accountId: params.accountId,
});
return;
}
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: { schema: '2.0', body: { elements: [] } },
accountId: params.accountId,
});
},
};
try {
const dedupeId = `feishu:${params.accountId}:${basics.openChatId ?? '-'}:${basics.openMessageId ?? '-'}:${basics.senderOpenId ?? '-'}:${basics.action}`;
let cardResponse;
const result = await (0, plugin_runtime_1.dispatchPluginInteractiveHandler)({
channel: 'feishu',
data: basics.action,
dedupeId,
invoke: async (match) => {
const { registration, namespace, payload } = match;
const handlerCtx = {
channel: 'feishu',
accountId: params.accountId,
senderId: basics.senderOpenId,
conversationId: basics.openChatId,
messageId: basics.openMessageId,
namespace,
payload,
action: basics.action,
rawEvent: params.data,
respond,
};
cardResponse = await registration.handler(handlerCtx);
// If the handler returns a card response, treat it as handled.
return { handled: cardResponse !== undefined };
},
});
if (!result.matched)
return undefined;
return cardResponse;
}
catch (err) {
log.warn(`interactive dispatch failed: ${String(err)}`);
return {
toast: {
type: 'error',
content: '交互处理失败,请稍后重试',
},
};
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* WebSocket monitoring for the Lark/Feishu channel plugin.
*
* Manages per-account WSClient connections and routes inbound Feishu
* events (messages, bot membership changes, read receipts) to the
* appropriate handlers.
*/
import type { MonitorFeishuOpts } from './types';
export type { MonitorFeishuOpts } from './types';
/**
* Start monitoring for all enabled Feishu accounts (or a single
* account when `opts.accountId` is specified).
*/
export declare function monitorFeishuProvider(opts?: MonitorFeishuOpts): Promise<void>;
@@ -0,0 +1,140 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* WebSocket monitoring for the Lark/Feishu channel plugin.
*
* Manages per-account WSClient connections and routes inbound Feishu
* events (messages, bot membership changes, read receipts) to the
* appropriate handlers.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.monitorFeishuProvider = monitorFeishuProvider;
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
const lark_logger_1 = require("../core/lark-logger.js");
const shutdown_hooks_1 = require("../core/shutdown-hooks.js");
const dedup_1 = require("../messaging/inbound/dedup.js");
const event_handlers_1 = require("./event-handlers.js");
const mlog = (0, lark_logger_1.larkLogger)('channel/monitor');
// ---------------------------------------------------------------------------
// Single-account monitor
// ---------------------------------------------------------------------------
/**
* Start monitoring a single Feishu account.
*
* Creates a LarkClient, probes bot identity, registers event handlers,
* and starts a WebSocket connection. Returns a Promise that resolves
* when the abort signal fires (or immediately if already aborted).
*/
async function monitorSingleAccount(params) {
const { account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
const error = runtime?.error ?? ((...args) => mlog.error(args.map(String).join(' ')));
// Only websocket mode is supported in the monitor path.
const connectionMode = account.config.connectionMode ?? 'websocket';
if (connectionMode !== 'websocket') {
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
return;
}
// Message dedup — filters duplicate deliveries from WebSocket reconnects.
const dedupCfg = account.config.dedup;
const messageDedup = new dedup_1.MessageDedup({
ttlMs: dedupCfg?.ttlMs,
maxEntries: dedupCfg?.maxEntries,
});
log(`feishu[${accountId}]: message dedup enabled (ttl=${messageDedup['ttlMs']}ms, max=${messageDedup['maxEntries']})`);
log(`feishu[${accountId}]: starting WebSocket connection...`);
// Create LarkClient instance — manages SDK client, WS, and bot identity.
const lark = lark_client_1.LarkClient.fromAccount(account);
// Attach dedup instance so it is disposed together with the client.
lark.messageDedup = messageDedup;
/** Per-chat history maps (used for group-chat context window). */
const chatHistories = new Map();
const ctx = {
get cfg() {
return lark_client_1.LarkClient.runtime.config.loadConfig();
},
lark,
accountId,
chatHistories,
messageDedup,
runtime,
log,
error,
};
await lark.startWS({
handlers: {
'im.message.receive_v1': (data) => (0, event_handlers_1.handleMessageEvent)(ctx, data),
'im.message.message_read_v1': async () => { },
'im.message.reaction.created_v1': (data) => (0, event_handlers_1.handleReactionEvent)(ctx, data),
// These events are expected in normal usage but do not affect the
// plugin's current behavior. Register no-op handlers to avoid SDK
// warnings about missing handlers.
'im.message.reaction.deleted_v1': async () => { },
'im.chat.access_event.bot_p2p_chat_entered_v1': async () => { },
'im.chat.member.bot.added_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'added'),
'im.chat.member.bot.deleted_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'removed'),
'vc.bot.meeting_invited_v1': (data) => (0, event_handlers_1.handleVcMeetingInvitedEvent)(ctx, data),
// Drive comment event — fires when a user adds a comment or reply on a document.
'drive.notice.comment_add_v1': (data) => (0, event_handlers_1.handleCommentEvent)(ctx, data),
// 飞书 SDK EventDispatcher.register 不支持带返回值的处理器,此处 as any 是 SDK 类型限制的变通
'card.action.trigger': ((data) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(0, event_handlers_1.handleCardActionEvent)(ctx, data)),
},
abortSignal,
});
// startWS resolves when abortSignal fires — probe result is logged inside startWS.
log(`feishu[${accountId}]: bot open_id resolved: ${lark.botOpenId ?? 'unknown'}`);
log(`feishu[${accountId}]: WebSocket client started`);
mlog.info(`websocket started for account ${accountId}`);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Start monitoring for all enabled Feishu accounts (or a single
* account when `opts.accountId` is specified).
*/
async function monitorFeishuProvider(opts = {}) {
const cfg = opts.config;
if (!cfg) {
throw new Error('Config is required for Feishu monitor');
}
// Store the original global config so plugin commands (doctor, diagnose)
// can access cross-account information even when running inside an
// account-scoped config context.
lark_client_1.LarkClient.setGlobalConfig(cfg);
const log = opts.runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
// Single-account mode.
if (opts.accountId) {
const account = (0, accounts_1.getLarkAccount)(cfg, opts.accountId);
if (!account.enabled || !account.configured) {
throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
}
await monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
});
await (0, shutdown_hooks_1.drainShutdownHooks)({ log });
return;
}
// Multi-account mode: start all enabled accounts in parallel.
const accounts = (0, accounts_1.getEnabledLarkAccounts)(cfg);
if (accounts.length === 0) {
throw new Error('No enabled Feishu accounts configured');
}
log(`feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(', ')}`);
await Promise.all(accounts.map((account) => monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
})));
await (0, shutdown_hooks_1.drainShutdownHooks)({ log });
}
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding configuration mutation helpers.
*
* Pure functions that apply Feishu channel configuration changes
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
* in CLI commands and other configuration flows.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { DmPolicy } from 'openclaw/plugin-sdk/setup';
export declare function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig;
export declare function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig;
export declare function setFeishuGroupPolicy(cfg: ClawdbotConfig, groupPolicy: 'open' | 'allowlist' | 'disabled'): ClawdbotConfig;
export declare function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig;
export declare function setFeishuGroups(cfg: ClawdbotConfig, groups: Record<string, object>): ClawdbotConfig;
export declare function parseAllowFromInput(raw: string): string[];
@@ -0,0 +1,96 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding configuration mutation helpers.
*
* Pure functions that apply Feishu channel configuration changes
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
* in CLI commands and other configuration flows.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.setFeishuDmPolicy = setFeishuDmPolicy;
exports.setFeishuAllowFrom = setFeishuAllowFrom;
exports.setFeishuGroupPolicy = setFeishuGroupPolicy;
exports.setFeishuGroupAllowFrom = setFeishuGroupAllowFrom;
exports.setFeishuGroups = setFeishuGroups;
exports.parseAllowFromInput = parseAllowFromInput;
const setup_1 = require("openclaw/plugin-sdk/setup");
// ---------------------------------------------------------------------------
// Config mutation helpers
// ---------------------------------------------------------------------------
function setFeishuDmPolicy(cfg, dmPolicy) {
const allowFrom = dmPolicy === 'open'
? (0, setup_1.addWildcardAllowFrom)(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setFeishuAllowFrom(cfg, allowFrom) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
allowFrom,
},
},
};
}
function setFeishuGroupPolicy(cfg, groupPolicy) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
groupPolicy,
},
},
};
}
function setFeishuGroupAllowFrom(cfg, groupAllowFrom) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
}
function setFeishuGroups(cfg, groups) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groups,
},
},
};
}
// ---------------------------------------------------------------------------
// Input helpers
// ---------------------------------------------------------------------------
function parseAllowFromInput(raw) {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy groupAllowFrom migration for Feishu onboarding.
*
* Handles the migration of chat_id entries (oc_xxx) from
* groupAllowFrom to the groups config, preserving the original
* semantic of "allow this group for any sender".
*/
import type { ClawdbotConfig, WizardPrompter } from 'openclaw/plugin-sdk';
/**
* Detect and migrate legacy chat_id entries in groupAllowFrom.
*
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
* which groups could use the bot.
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
*
* This function prompts the user and, if confirmed, moves chat_ids
* to the groups config and keeps only sender IDs in groupAllowFrom.
*/
export declare function migrateLegacyGroupAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig>;
@@ -0,0 +1,70 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy groupAllowFrom migration for Feishu onboarding.
*
* Handles the migration of chat_id entries (oc_xxx) from
* groupAllowFrom to the groups config, preserving the original
* semantic of "allow this group for any sender".
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.migrateLegacyGroupAllowFrom = migrateLegacyGroupAllowFrom;
const onboarding_config_1 = require("./onboarding-config.js");
/**
* Detect and migrate legacy chat_id entries in groupAllowFrom.
*
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
* which groups could use the bot.
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
*
* This function prompts the user and, if confirmed, moves chat_ids
* to the groups config and keeps only sender IDs in groupAllowFrom.
*/
async function migrateLegacyGroupAllowFrom(params) {
let next = params.cfg;
const { prompter } = params;
const existingGroupAllowFrom = next.channels?.feishu?.groupAllowFrom ?? [];
const legacyChatIds = existingGroupAllowFrom.filter((e) => String(e).startsWith('oc_'));
const senderAllowFrom = existingGroupAllowFrom.filter((e) => !String(e).startsWith('oc_'));
if (legacyChatIds.length === 0) {
return next;
}
await prompter.note([
`⚠️ Detected legacy config: groupAllowFrom contains chat_ids (${legacyChatIds.join(', ')})`,
'',
'Old semantic: groupAllowFrom controlled which groups could use the bot.',
'New semantic: groupAllowFrom is for SENDER filtering (open_ids like ou_xxx).',
'',
'Recommended migration:',
` 1. Move chat_ids (oc_xxx) → channels.feishu.groups`,
` 2. Keep sender IDs (ou_xxx) in groupAllowFrom`,
].join('\n'), 'Legacy config detected');
const migrate = await prompter.confirm({
message: `Migrate ${legacyChatIds.length} chat_id(s) to groups config?`,
initialValue: true,
});
if (migrate) {
const existingGroups = next.channels?.feishu?.groups ?? {};
const migratedGroups = {
...existingGroups,
};
for (const chatId of legacyChatIds) {
if (!migratedGroups[String(chatId)]) {
migratedGroups[String(chatId)] = {
enabled: true,
groupPolicy: 'open',
};
}
}
next = (0, onboarding_config_1.setFeishuGroups)(next, migratedGroups);
next = (0, onboarding_config_1.setFeishuGroupAllowFrom)(next, senderAllowFrom);
await prompter.note(`✅ Migrated: ${legacyChatIds.length} chat_id(s) moved to groups, ` +
`${senderAllowFrom.length} sender(s) kept in groupAllowFrom`, 'Migration complete');
}
else {
await prompter.note('Skipped migration. Please update config manually to avoid issues.', 'Migration skipped');
}
return next;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
*
* Implements the ChannelOnboardingAdapter interface so the `openclaw
* setup` wizard can configure Feishu credentials, domain, group
* policies, and DM allowlists interactively.
*/
import type { ChannelSetupWizardAdapter } from 'openclaw/plugin-sdk/setup';
export declare const feishuOnboardingAdapter: ChannelSetupWizardAdapter;
@@ -0,0 +1,300 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
*
* Implements the ChannelOnboardingAdapter interface so the `openclaw
* setup` wizard can configure Feishu credentials, domain, group
* policies, and DM allowlists interactively.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.feishuOnboardingAdapter = void 0;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const setup_1 = require("openclaw/plugin-sdk/setup");
const accounts_1 = require("../core/accounts.js");
const probe_1 = require("./probe.js");
const onboarding_config_1 = require("./onboarding-config.js");
const onboarding_migrate_1 = require("./onboarding-migrate.js");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const channel = 'feishu';
// ---------------------------------------------------------------------------
// Prompter helpers
// ---------------------------------------------------------------------------
async function noteFeishuCredentialHelp(prompter) {
await prompter.note([
'1) Go to Feishu Open Platform (open.feishu.cn)',
'2) Create a self-built app',
'3) Get App ID and App Secret from Credentials page',
'4) Enable required permissions: im:message, im:chat, contact:user.base:readonly',
'5) Publish the app or add it to a test group',
'Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.',
`Docs: ${(0, setup_1.formatDocsLink)('/channels/feishu', 'feishu')}`,
].join('\n'), 'Feishu credentials');
}
async function promptFeishuAllowFrom(params) {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note([
'Allowlist Feishu DMs by open_id or user_id.',
'You can find user open_id in Feishu admin console or via API.',
'Examples:',
'- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
].join('\n'), 'Feishu allowlist');
while (true) {
const entry = await params.prompter.text({
message: 'Feishu allowFrom (user open_ids)',
placeholder: 'ou_xxxxx, ou_yyyyy',
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
});
const parts = (0, onboarding_config_1.parseAllowFromInput)(String(entry));
if (parts.length === 0) {
await params.prompter.note('Enter at least one user.', 'Feishu allowlist');
continue;
}
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts])];
return (0, onboarding_config_1.setFeishuAllowFrom)(params.cfg, unique);
}
}
// ---------------------------------------------------------------------------
// Credential acquisition
// ---------------------------------------------------------------------------
async function acquireCredentials(params) {
const { prompter, feishuCfg } = params;
let next = params.cfg;
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
const canUseEnv = Boolean(!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim());
let appId = null;
let appSecret = null;
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message: 'FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?',
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
}
else {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
}
else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: 'Feishu credentials already configured. Keep them?',
initialValue: true,
});
if (!keep) {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
}
else {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
return { cfg: next, appId, appSecret };
}
// ---------------------------------------------------------------------------
// DM policy
// ---------------------------------------------------------------------------
const dmPolicy = {
label: 'Feishu',
channel,
policyKey: 'channels.feishu.dmPolicy',
allowFromKey: 'channels.feishu.allowFrom',
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? 'pairing',
setPolicy: (cfg, policy) => (0, onboarding_config_1.setFeishuDmPolicy)(cfg, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------
exports.feishuOnboardingAdapter = {
channel,
// -----------------------------------------------------------------------
// getStatus
// -----------------------------------------------------------------------
getStatus: async ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu;
const configured = Boolean((0, accounts_1.getLarkCredentials)(feishuCfg));
// Attempt a live probe when credentials are present.
let probeResult = null;
if (configured && feishuCfg) {
try {
probeResult = await (0, probe_1.probeFeishu)(feishuCfg);
}
catch {
// Ignore probe errors -- status degrades gracefully.
}
}
const statusLines = [];
if (!configured) {
statusLines.push('Feishu: needs app credentials');
}
else if (probeResult?.ok) {
statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? 'bot'}`);
}
else {
statusLines.push('Feishu: configured (connection not verified)');
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? 'configured' : 'needs app creds',
quickstartScore: configured ? 2 : 0,
};
},
// -----------------------------------------------------------------------
// configure
// -----------------------------------------------------------------------
configure: async ({ cfg, prompter }) => {
const feishuCfg = cfg.channels?.feishu;
const resolved = (0, accounts_1.getLarkCredentials)(feishuCfg);
let next = cfg;
// Show credential help if nothing is configured yet.
if (!resolved) {
await noteFeishuCredentialHelp(prompter);
}
// --- Credential acquisition ---
const creds = await acquireCredentials({ cfg: next, prompter, feishuCfg });
next = creds.cfg;
// --- Persist and test credentials ---
if (creds.appId && creds.appSecret) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
enabled: true,
appId: creds.appId,
appSecret: creds.appSecret,
},
},
};
const testCfg = next.channels?.feishu;
try {
const probe = await (0, probe_1.probeFeishu)(testCfg);
if (probe.ok) {
await prompter.note(`Connected as ${probe.botName ?? probe.botOpenId ?? 'bot'}`, 'Feishu connection test');
}
else {
await prompter.note(`Connection failed: ${probe.error ?? 'unknown error'}`, 'Feishu connection test');
}
}
catch (err) {
await prompter.note(`Connection test failed: ${String(err)}`, 'Feishu connection test');
}
}
// --- Domain selection ---
const currentDomain = next.channels?.feishu?.domain ?? 'feishu';
const domain = await prompter.select({
message: 'Which Feishu domain?',
options: [
{ value: 'feishu', label: 'Feishu (feishu.cn) - China' },
{ value: 'lark', label: 'Lark (larksuite.com) - International' },
],
initialValue: currentDomain,
});
if (domain) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain,
},
},
};
}
// --- Legacy migration ---
next = await (0, onboarding_migrate_1.migrateLegacyGroupAllowFrom)({ cfg: next, prompter });
// --- Group policy ---
const groupPolicy = await prompter.select({
message: 'Group chat policy — which groups can interact with the bot?',
options: [
{
value: 'allowlist',
label: 'Allowlist — only groups listed in `groups` config (default)',
},
{
value: 'open',
label: 'Open — any group (requires @mention)',
},
{
value: 'disabled',
label: 'Disabled — no group interactions',
},
],
initialValue: next.channels?.feishu?.groupPolicy ?? 'allowlist',
});
if (groupPolicy) {
next = (0, onboarding_config_1.setFeishuGroupPolicy)(next, groupPolicy);
}
// --- Group sender allowlist ---
if (groupPolicy !== 'disabled') {
const existing = next.channels?.feishu?.groupAllowFrom ?? [];
const entry = await prompter.text({
message: 'Group sender allowlist — which users can trigger the bot in allowed groups? (user open_ids)',
placeholder: 'ou_xxxxx, ou_yyyyy',
initialValue: existing.length > 0 ? existing.map(String).join(', ') : undefined,
});
if (entry) {
const parts = (0, onboarding_config_1.parseAllowFromInput)(String(entry));
if (parts.length > 0) {
next = (0, onboarding_config_1.setFeishuGroupAllowFrom)(next, parts);
}
}
else if (groupPolicy === 'allowlist') {
await prompter.note('Empty sender list + allowlist = nobody can trigger. ' +
"Use groupPolicy 'open' if you want anyone in allowed groups to trigger.", 'Note');
}
}
return { cfg: next, accountId: account_id_1.DEFAULT_ACCOUNT_ID };
},
// -----------------------------------------------------------------------
// dmPolicy
// -----------------------------------------------------------------------
dmPolicy,
// -----------------------------------------------------------------------
// disable
// -----------------------------------------------------------------------
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ChannelPlugin interface implementation for the Lark/Feishu channel.
*
* This is the top-level entry point that the OpenClaw plugin system uses to
* discover capabilities, resolve accounts, obtain outbound adapters, and
* start the inbound event gateway.
*/
import type { ChannelPlugin } from 'openclaw/plugin-sdk';
import type { LarkAccount } from '../core/types';
export declare const feishuPlugin: ChannelPlugin<LarkAccount>;
@@ -0,0 +1,310 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ChannelPlugin interface implementation for the Lark/Feishu channel.
*
* This is the top-level entry point that the OpenClaw plugin system uses to
* discover capabilities, resolve accounts, obtain outbound adapters, and
* start the inbound event gateway.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.feishuPlugin = void 0;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const channel_status_1 = require("openclaw/plugin-sdk/channel-status");
const accounts_1 = require("../core/accounts.js");
const outbound_1 = require("../messaging/outbound/outbound.js");
const actions_1 = require("../messaging/outbound/actions.js");
const policy_1 = require("../messaging/inbound/policy.js");
const lark_client_1 = require("../core/lark-client.js");
const send_1 = require("../messaging/outbound/send.js");
const targets_1 = require("../core/targets.js");
const onboarding_auth_1 = require("../tools/onboarding-auth.js");
const lark_logger_1 = require("../core/lark-logger.js");
const config_schema_1 = require("../core/config-schema.js");
const config_adapter_1 = require("./config-adapter.js");
const directory_1 = require("./directory.js");
const pluginLog = (0, lark_logger_1.larkLogger)('channel/plugin');
/** 状态轮询的探针结果缓存时长(5 分钟)。 */
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert nullable SDK params to optional params for directory functions. */
function adaptDirectoryParams(params) {
return {
cfg: params.cfg,
query: params.query ?? undefined,
limit: params.limit ?? undefined,
accountId: params.accountId ?? undefined,
};
}
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta = {
id: 'feishu',
label: 'Feishu',
selectionLabel: 'Lark/Feishu (\u98DE\u4E66)',
docsPath: '/channels/feishu',
docsLabel: 'feishu',
blurb: '\u98DE\u4E66/Lark enterprise messaging.',
aliases: ['lark'],
order: 70,
};
// ---------------------------------------------------------------------------
// Channel plugin definition
// ---------------------------------------------------------------------------
exports.feishuPlugin = {
id: 'feishu',
meta: {
...meta,
},
// -------------------------------------------------------------------------
// Pairing
// -------------------------------------------------------------------------
pairing: {
idLabel: 'feishuUserId',
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ''),
notifyApproval: async ({ cfg, id }) => {
const accountId = (0, accounts_1.getDefaultLarkAccountId)(cfg);
pluginLog.info('notifyApproval called', { id, accountId });
// 1. 发送配对成功消息(保持现有行为)
await (0, send_1.sendMessageFeishu)({
cfg,
to: id,
text: channel_status_1.PAIRING_APPROVED_MESSAGE,
accountId,
});
// 2. 触发 onboarding
try {
await (0, onboarding_auth_1.triggerOnboarding)({ cfg, userOpenId: id, accountId });
pluginLog.info('onboarding completed', { id });
}
catch (err) {
pluginLog.warn('onboarding failed', { id, error: String(err) });
}
},
},
// -------------------------------------------------------------------------
// Capabilities
// -------------------------------------------------------------------------
capabilities: {
chatTypes: ['direct', 'group'],
media: true,
reactions: true,
threads: true,
polls: false,
nativeCommands: true,
blockStreaming: true,
},
// -------------------------------------------------------------------------
// Agent prompt
// -------------------------------------------------------------------------
agentPrompt: {
messageToolHints: () => [
'- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.',
'- Feishu supports interactive cards for rich messages.',
'- Feishu reactions use UPPERCASE emoji type names (e.g. `OK`,`THUMBSUP`,`THANKS`,`MUSCLE`,`FINGERHEART`,`APPLAUSE`,`FISTBUMP`,`JIAYI`,`DONE`,`SMILE`,`BLUSH` ), not Unicode emoji characters.',
"- Feishu `action=delete`/`action=unsend` only deletes messages sent by the bot. When the user quotes a message and says 'delete this', use the **quoted message's** message_id, not the user's own message_id.",
],
},
// -------------------------------------------------------------------------
// Groups
// -------------------------------------------------------------------------
groups: {
resolveToolPolicy: policy_1.resolveFeishuGroupToolPolicy,
},
// -------------------------------------------------------------------------
// Reload
// -------------------------------------------------------------------------
reload: { configPrefixes: ['channels.feishu'] },
// -------------------------------------------------------------------------
// Config schema (JSON Schema)
// -------------------------------------------------------------------------
configSchema: {
schema: config_schema_1.FEISHU_CONFIG_JSON_SCHEMA,
},
// -------------------------------------------------------------------------
// Config adapter
// -------------------------------------------------------------------------
config: {
listAccountIds: (cfg) => (0, accounts_1.getLarkAccountIds)(cfg),
resolveAccount: (cfg, accountId) => (0, accounts_1.getLarkAccount)(cfg, accountId),
defaultAccountId: (cfg) => (0, accounts_1.getDefaultLarkAccountId)(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
return (0, config_adapter_1.setAccountEnabled)(cfg, accountId, enabled);
},
deleteAccount: ({ cfg, accountId }) => {
return (0, config_adapter_1.deleteAccount)(cfg, accountId);
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
brand: account.brand,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
// -------------------------------------------------------------------------
// Security
// -------------------------------------------------------------------------
security: {
collectWarnings: ({ cfg, accountId }) => (0, config_adapter_1.collectFeishuSecurityWarnings)({ cfg, accountId: accountId ?? account_id_1.DEFAULT_ACCOUNT_ID }),
},
// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
setup: {
resolveAccountId: () => account_id_1.DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
return (0, config_adapter_1.applyAccountConfig)(cfg, accountId, { enabled: true });
},
},
// -------------------------------------------------------------------------
// Messaging
// -------------------------------------------------------------------------
messaging: {
normalizeTarget: (raw) => (0, targets_1.normalizeFeishuTarget)(raw) ?? undefined,
targetResolver: {
looksLikeId: targets_1.looksLikeFeishuId,
hint: '<chatId|user:openId|chat:chatId>',
},
},
// -------------------------------------------------------------------------
// Directory
// -------------------------------------------------------------------------
directory: {
self: async () => null,
listPeers: async (p) => (0, directory_1.listFeishuDirectoryPeers)(adaptDirectoryParams(p)),
listGroups: async (p) => (0, directory_1.listFeishuDirectoryGroups)(adaptDirectoryParams(p)),
listPeersLive: async (p) => (0, directory_1.listFeishuDirectoryPeersLive)(adaptDirectoryParams(p)),
listGroupsLive: async (p) => (0, directory_1.listFeishuDirectoryGroupsLive)(adaptDirectoryParams(p)),
},
// -------------------------------------------------------------------------
// Outbound
// -------------------------------------------------------------------------
outbound: outbound_1.feishuOutbound,
// -------------------------------------------------------------------------
// Threading
// -------------------------------------------------------------------------
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: (0, targets_1.normalizeFeishuTarget)(context.To ?? '') ?? undefined,
currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
currentMessageId: context.CurrentMessageId,
hasRepliedRef,
}),
},
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
actions: actions_1.feishuMessageActions,
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
status: {
defaultRuntime: {
accountId: account_id_1.DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
port: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => {
return await lark_client_1.LarkClient.fromAccount(account).probe({ maxAgeMs: PROBE_CACHE_TTL_MS });
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
brand: account.brand,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
// -------------------------------------------------------------------------
// Gateway
// -------------------------------------------------------------------------
gateway: {
startAccount: async (ctx) => {
const { monitorFeishuProvider } = await Promise.resolve().then(() => __importStar(require('./monitor.js')));
const account = (0, accounts_1.getLarkAccount)(ctx.cfg, ctx.accountId);
const port = account.config?.webhookPort ?? null;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? 'websocket'})`);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
},
stopAccount: async (ctx) => {
ctx.log?.info(`stopping feishu[${ctx.accountId}]`);
await lark_client_1.LarkClient.clearCache(ctx.accountId);
ctx.log?.info(`stopped feishu[${ctx.accountId}]`);
},
},
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type LarkClientCredentials } from '../core/lark-client';
import type { FeishuProbeResult } from './types';
/**
* Probe the Feishu bot connection by calling the bot/v3/info API.
*
* Returns a result indicating whether the bot is reachable and its
* basic identity (name, open_id). Used by onboarding and status
* checks to verify credentials before committing them to config.
*/
export declare function probeFeishu(credentials?: LarkClientCredentials): Promise<FeishuProbeResult>;
@@ -0,0 +1,24 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.probeFeishu = probeFeishu;
const lark_client_1 = require("../core/lark-client.js");
/**
* Probe the Feishu bot connection by calling the bot/v3/info API.
*
* Returns a result indicating whether the bot is reachable and its
* basic identity (name, open_id). Used by onboarding and status
* checks to verify credentials before committing them to config.
*/
async function probeFeishu(credentials) {
if (!credentials?.appId || !credentials?.appSecret) {
return {
ok: false,
error: 'missing credentials (appId, appSecret)',
};
}
return lark_client_1.LarkClient.fromCredentials(credentials).probe();
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Channel type definitions for the Lark/Feishu channel plugin.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { LarkClient } from '../core/lark-client';
import type { MessageDedup } from '../messaging/inbound/dedup';
export type { FeishuProbeResult } from '../core/types';
export interface MonitorFeishuOpts {
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
accountId?: string;
}
export interface FeishuDirectoryPeer {
kind: 'user';
id: string;
name?: string;
}
export interface FeishuDirectoryGroup {
kind: 'group';
id: string;
name?: string;
}
export interface MonitorContext {
cfg: ClawdbotConfig;
lark: LarkClient;
accountId: string;
chatHistories: Map<string, HistoryEntry[]>;
messageDedup: MessageDedup;
runtime?: RuntimeEnv;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
@@ -0,0 +1,8 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Channel type definitions for the Lark/Feishu channel plugin.
*/
Object.defineProperty(exports, "__esModule", { value: true });