auto: sync OpenClaw config 2026-09-09 16:13
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '交互处理失败,请稍后重试',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
Reference in New Issue
Block a user