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

This commit is contained in:
2026-09-09 16:13:31 +08:00
parent c79d73c0fe
commit 0aa13d3cf0
440 changed files with 51636 additions and 13 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Interactive card building for Lark/Feishu.
*
* Provides utilities to construct Feishu Interactive Message Cards for
* different agent response states (thinking, streaming, complete, confirm).
*/
import type { FooterSessionMetrics } from './reply-dispatcher-types';
import { type ToolUseDisplayStep } from './tool-use-display';
/**
* Element ID used for the streaming text area in cards. The CardKit
* `cardElement.content()` API targets this element for typewriter-effect
* streaming updates.
*/
export declare const STREAMING_ELEMENT_ID = "streaming_content";
export declare const REASONING_ELEMENT_ID = "reasoning_content";
export interface CardElement {
tag: string;
[key: string]: unknown;
}
export interface FeishuCard {
config: {
wide_screen_mode: boolean;
update_multi?: boolean;
locales?: string[];
summary?: {
content: string;
};
};
header?: {
title: {
tag: 'plain_text';
content: string;
i18n_content?: Record<string, string>;
};
template: string;
};
elements: CardElement[];
}
export type CardState = 'thinking' | 'streaming' | 'complete' | 'confirm';
export interface ConfirmData {
operationDescription: string;
pendingOperationId: string;
preview?: string;
}
/**
* Split a payload text into optional `reasoningText` and `answerText`.
*
* Handles two formats produced by the framework:
* 1. "Reasoning:\n_italic line_\n…" prefix (from `formatReasoningMessage`)
* 2. `<think>…</think>` / `<thinking>…</thinking>` XML tags
*
* Equivalent to the framework's `splitTelegramReasoningText()`.
*/
export declare function splitReasoningText(text?: string): {
reasoningText?: string;
answerText?: string;
};
/**
* Strip reasoning blocks — both XML tags with their content and any
* "Reasoning:\n" prefixed content.
*/
export declare function stripReasoningTags(text: string): string;
/**
* Format reasoning duration into a human-readable i18n pair.
* e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
*/
export declare function formatReasoningDuration(ms: number): {
zh: string;
en: string;
};
/**
* Format tool-use duration into a human-readable i18n pair.
*/
export declare function formatToolUseDuration(ms: number): {
zh: string;
en: string;
};
/**
* Format milliseconds into a human-readable duration string.
*/
export declare function formatElapsed(ms: number): string;
export declare function compactNumber(value: number): string;
export declare function formatFooterRuntimeSegments(params: {
footer?: {
status?: boolean;
elapsed?: boolean;
tokens?: boolean;
cache?: boolean;
context?: boolean;
model?: boolean;
};
metrics?: FooterSessionMetrics;
elapsedMs?: number;
isError?: boolean;
isAborted?: boolean;
}): {
primaryZh: string[];
primaryEn: string[];
detailZh: string[];
detailEn: string[];
};
/**
* Build a full Feishu Interactive Message Card JSON object for the
* given state.
*/
export declare function buildCardContent(state: CardState, data?: {
text?: string;
reasoningText?: string;
reasoningElapsedMs?: number;
toolUseSteps?: ToolUseDisplayStep[];
toolUseTitleSuffix?: {
zh: string;
en: string;
};
toolUseElapsedMs?: number;
showToolUse?: boolean;
confirmData?: ConfirmData;
elapsedMs?: number;
isError?: boolean;
isAborted?: boolean;
footer?: {
status?: boolean;
elapsed?: boolean;
tokens?: boolean;
cache?: boolean;
context?: boolean;
model?: boolean;
};
footerMetrics?: FooterSessionMetrics;
}): FeishuCard;
/**
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
*/
/**
* Build the initial CardKit 2.0 streaming card with a loading icon.
* Optionally includes a tool-use pending panel above the streaming area.
*/
export declare function buildStreamingThinkingCard(showToolUse?: boolean): Record<string, unknown>;
/**
* Build a CardKit 2.0 card for the pre-answer streaming phase.
* Used both for the initial card and for live updates during tool calls.
*/
export declare function buildStreamingPreAnswerCard(params: {
steps?: ToolUseDisplayStep[];
elapsedMs?: number;
showToolUse?: boolean;
}): Record<string, unknown>;
export declare function toCardKit2(card: FeishuCard): Record<string, unknown>;
@@ -0,0 +1,794 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Interactive card building for Lark/Feishu.
*
* Provides utilities to construct Feishu Interactive Message Cards for
* different agent response states (thinking, streaming, complete, confirm).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.REASONING_ELEMENT_ID = exports.STREAMING_ELEMENT_ID = void 0;
exports.splitReasoningText = splitReasoningText;
exports.stripReasoningTags = stripReasoningTags;
exports.formatReasoningDuration = formatReasoningDuration;
exports.formatToolUseDuration = formatToolUseDuration;
exports.formatElapsed = formatElapsed;
exports.compactNumber = compactNumber;
exports.formatFooterRuntimeSegments = formatFooterRuntimeSegments;
exports.buildCardContent = buildCardContent;
exports.buildStreamingThinkingCard = buildStreamingThinkingCard;
exports.buildStreamingPreAnswerCard = buildStreamingPreAnswerCard;
exports.toCardKit2 = toCardKit2;
const markdown_style_1 = require("./markdown-style.js");
const tool_use_display_1 = require("./tool-use-display.js");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Element ID used for the streaming text area in cards. The CardKit
* `cardElement.content()` API targets this element for typewriter-effect
* streaming updates.
*/
exports.STREAMING_ELEMENT_ID = 'streaming_content';
exports.REASONING_ELEMENT_ID = 'reasoning_content';
const TOOL_USE_STEP_CONTENT_INDENT = '0px 0px 0px 22px';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// ---- Reasoning text utilities ----
// Mirrors the logic in the framework's `splitTelegramReasoningText` and
// related helpers from `plugin-sdk/telegram/reasoning-lane-coordinator`.
// Those are not exported from the public plugin-sdk entry, so we replicate
// the same detection/splitting logic here.
const REASONING_PREFIX = 'Reasoning:\n';
/**
* Split a payload text into optional `reasoningText` and `answerText`.
*
* Handles two formats produced by the framework:
* 1. "Reasoning:\n_italic line_\n…" prefix (from `formatReasoningMessage`)
* 2. `<think>…</think>` / `<thinking>…</thinking>` XML tags
*
* Equivalent to the framework's `splitTelegramReasoningText()`.
*/
function splitReasoningText(text) {
if (typeof text !== 'string' || !text.trim())
return {};
const trimmed = text.trim();
// Case 1: "Reasoning:\n..." prefix — the entire payload is reasoning
if (trimmed.startsWith(REASONING_PREFIX) && trimmed.length > REASONING_PREFIX.length) {
return { reasoningText: cleanReasoningPrefix(trimmed) };
}
// Case 2: XML thinking tags — extract content and strip from answer
const taggedReasoning = extractThinkingContent(text);
const strippedAnswer = stripReasoningTags(text);
if (!taggedReasoning && strippedAnswer === text) {
return { answerText: text };
}
return {
reasoningText: taggedReasoning || undefined,
answerText: strippedAnswer || undefined,
};
}
/**
* Extract content from `<think>`, `<thinking>`, `<thought>` blocks.
* Handles both closed and unclosed (streaming) tags.
*/
function extractThinkingContent(text) {
if (!text)
return '';
const scanRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
let result = '';
let lastIndex = 0;
let inThinking = false;
for (const match of text.matchAll(scanRe)) {
const idx = match.index ?? 0;
if (inThinking) {
result += text.slice(lastIndex, idx);
}
inThinking = match[1] !== '/';
lastIndex = idx + match[0].length;
}
// Handle unclosed tag (still streaming)
if (inThinking) {
result += text.slice(lastIndex);
}
return result.trim();
}
/**
* Strip reasoning blocks — both XML tags with their content and any
* "Reasoning:\n" prefixed content.
*/
function stripReasoningTags(text) {
// Strip complete XML blocks
let result = text.replace(/<\s*(?:think(?:ing)?|thought|antthinking)\s*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi, '');
// Strip unclosed tag at end (streaming)
result = result.replace(/<\s*(?:think(?:ing)?|thought|antthinking)\s*>[\s\S]*$/gi, '');
// Strip orphaned closing tags
result = result.replace(/<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi, '');
return result.trim();
}
/**
* Clean a "Reasoning:\n_italic_" formatted message back to plain text.
* Strips the prefix and per-line italic markdown wrappers.
*/
function cleanReasoningPrefix(text) {
let cleaned = text.replace(/^Reasoning:\s*/i, '');
cleaned = cleaned
.split('\n')
.map((line) => line.replace(/^_(.+)_$/, '$1'))
.join('\n');
return cleaned.trim();
}
/**
* Format reasoning duration into a human-readable i18n pair.
* e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
*/
function formatReasoningDuration(ms) {
const d = formatElapsed(ms);
return { zh: `思考了 ${d}`, en: `Thought for ${d}` };
}
/**
* Format tool-use duration into a human-readable i18n pair.
*/
function formatToolUseDuration(ms) {
const d = formatElapsed(ms);
return { zh: `执行耗时 ${d}`, en: `Tool use for ${d}` };
}
/**
* Format milliseconds into a human-readable duration string.
*/
function formatElapsed(ms) {
const seconds = ms / 1000;
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
}
/**
* Build footer meta-info: notation-sized text with i18n support.
* Error text is rendered in red; normal text uses default grey (notation).
*/
function buildFooter(zhText, enText, isError) {
const zhContent = isError ? `<font color='red'>${zhText}</font>` : zhText;
const enContent = isError ? `<font color='red'>${enText}</font>` : enText;
return [
{
tag: 'markdown',
content: enContent,
i18n_content: { zh_cn: zhContent, en_us: enContent },
text_size: 'notation',
},
];
}
function compactNumber(value) {
const abs = Math.abs(value);
if (abs >= 1_000_000) {
const m = value / 1_000_000;
return Math.abs(m) >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1)}m`;
}
if (abs >= 1_000) {
const k = value / 1_000;
return Math.abs(k) >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
}
return `${Math.round(value)}`;
}
function formatFooterRuntimeSegments(params) {
const { footer, metrics, elapsedMs, isError, isAborted } = params;
const primaryZh = [];
const primaryEn = [];
const detailZh = [];
const detailEn = [];
// --- Primary line: status, elapsed, model ---
if (footer?.status) {
if (isError) {
primaryZh.push('出错');
primaryEn.push('Error');
}
else if (isAborted) {
primaryZh.push('已停止');
primaryEn.push('Stopped');
}
else {
primaryZh.push('已完成');
primaryEn.push('Completed');
}
}
if (footer?.elapsed && elapsedMs != null) {
const d = formatElapsed(elapsedMs);
primaryZh.push(`耗时 ${d}`);
primaryEn.push(`Elapsed ${d}`);
}
if (footer?.model && metrics?.model) {
const model = metrics.model.trim();
if (model) {
primaryZh.push(model);
primaryEn.push(model);
}
}
// --- Detail line: tokens, cache, context ---
if (footer?.tokens && metrics) {
const inTokens = typeof metrics.inputTokens === 'number' ? Math.max(0, metrics.inputTokens) : undefined;
const outTokens = typeof metrics.outputTokens === 'number' ? Math.max(0, metrics.outputTokens) : undefined;
if (inTokens != null && outTokens != null) {
const inLabel = compactNumber(inTokens);
const outLabel = compactNumber(outTokens);
detailZh.push(`${inLabel}${outLabel}`);
detailEn.push(`${inLabel}${outLabel}`);
}
}
if (footer?.cache && metrics) {
const read = typeof metrics.cacheRead === 'number' ? Math.max(0, metrics.cacheRead) : undefined;
const write = typeof metrics.cacheWrite === 'number' ? Math.max(0, metrics.cacheWrite) : undefined;
const inputVal = typeof metrics.inputTokens === 'number' ? Math.max(0, metrics.inputTokens) : undefined;
if (read != null && write != null && inputVal != null) {
const total = read + write + inputVal;
const hit = total > 0 ? Math.round((read / total) * 100) : 0;
const left = compactNumber(read);
const right = compactNumber(write);
detailZh.push(`缓存 ${left}/${right} (${hit}%)`);
detailEn.push(`Cache ${left}/${right} (${hit}%)`);
}
}
if (footer?.context && metrics) {
const freshTotal = metrics.totalTokensFresh === false ? undefined : metrics.totalTokens;
const total = typeof freshTotal === 'number' ? Math.max(0, freshTotal) : undefined;
const ctx = typeof metrics.contextTokens === 'number' ? Math.max(0, metrics.contextTokens) : undefined;
if (total != null && ctx != null) {
const totalLabel = compactNumber(total);
const ctxLabel = compactNumber(ctx);
const pct = ctx > 0 ? Math.round((total / ctx) * 100) : 0;
const pctLabel = `${pct}%`;
detailZh.push(`上下文 ${totalLabel}/${ctxLabel} (${pctLabel})`);
detailEn.push(`Context ${totalLabel}/${ctxLabel} (${pctLabel})`);
}
}
return { primaryZh, primaryEn, detailZh, detailEn };
}
// ---------------------------------------------------------------------------
// buildCardContent
// ---------------------------------------------------------------------------
/**
* Build a full Feishu Interactive Message Card JSON object for the
* given state.
*/
function buildCardContent(state, data = {}) {
switch (state) {
case 'thinking':
return buildThinkingCard();
case 'streaming':
return buildStreamingCard(data.text ?? '', {
reasoningText: data.reasoningText,
showToolUse: data.showToolUse,
toolUseSteps: data.toolUseSteps,
toolUseTitleSuffix: data.toolUseTitleSuffix,
});
case 'complete':
return buildCompleteCard({
text: data.text ?? '',
elapsedMs: data.elapsedMs,
isError: data.isError,
reasoningText: data.reasoningText,
reasoningElapsedMs: data.reasoningElapsedMs,
toolUseSteps: data.toolUseSteps,
toolUseTitleSuffix: data.toolUseTitleSuffix,
toolUseElapsedMs: data.toolUseElapsedMs,
showToolUse: data.showToolUse,
isAborted: data.isAborted,
footer: data.footer,
footerMetrics: data.footerMetrics,
});
case 'confirm':
return buildConfirmCard(data.confirmData);
default:
throw new Error(`Unknown card state: ${state}`);
}
}
// ---------------------------------------------------------------------------
// Private card builders
// ---------------------------------------------------------------------------
function buildThinkingCard() {
return {
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
elements: [
{
tag: 'markdown',
content: 'Thinking...',
i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
},
],
};
}
function buildStreamingCard(partialText, params = {}) {
const { showToolUse = true, toolUseSteps, toolUseTitleSuffix, reasoningText } = params;
const elements = [];
const hasToolUse = Boolean(toolUseSteps?.length);
if (showToolUse) {
elements.push(hasToolUse
? buildToolUsePanel({
toolUseSteps,
titleSuffix: toolUseTitleSuffix,
})
: buildStreamingToolUsePendingPanel());
}
if (!partialText && reasoningText) {
// Reasoning phase: show reasoning content in notation style
elements.push({
tag: 'markdown',
content: `💭 **Thinking...**\n\n${reasoningText}`,
i18n_content: {
zh_cn: `💭 **思考中...**\n\n${reasoningText}`,
en_us: `💭 **Thinking...**\n\n${reasoningText}`,
},
text_size: 'notation',
});
}
else if (partialText) {
// Answer phase: show answer content only
elements.push({
tag: 'markdown',
content: (0, markdown_style_1.optimizeMarkdownStyle)(partialText),
});
}
return {
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
elements,
};
}
function buildCompleteCard(params) {
const { text, elapsedMs, isError, reasoningText, reasoningElapsedMs, toolUseSteps, toolUseTitleSuffix, toolUseElapsedMs, showToolUse = true, isAborted, footer, footerMetrics, } = params;
const elements = [];
if (showToolUse) {
elements.push(buildToolUsePanel({
toolUseSteps,
toolUseElapsedMs,
titleSuffix: toolUseTitleSuffix,
}));
}
// Collapsible reasoning panel (before main content)
if (reasoningText) {
const dur = reasoningElapsedMs ? formatReasoningDuration(reasoningElapsedMs) : null;
const zhLabel = dur ? dur.zh : '思考';
const enLabel = dur ? dur.en : 'Thought';
elements.push({
tag: 'collapsible_panel',
expanded: false,
header: {
title: {
tag: 'markdown',
content: `💭 ${enLabel}`,
i18n_content: {
zh_cn: `💭 ${zhLabel}`,
en_us: `💭 ${enLabel}`,
},
},
vertical_align: 'center',
icon: {
tag: 'standard_icon',
token: 'down-small-ccm_outlined',
size: '16px 16px',
},
icon_position: 'follow_text',
icon_expanded_angle: -180,
},
border: { color: 'grey', corner_radius: '5px' },
vertical_spacing: '8px',
padding: '8px 8px 8px 8px',
elements: [
{
tag: 'markdown',
content: reasoningText,
text_size: 'notation',
},
],
});
}
// Full text content
elements.push({
tag: 'markdown',
content: (0, markdown_style_1.optimizeMarkdownStyle)(text),
});
// Footer meta-info: split into two lines for readability.
// Line 1 (primary): status · elapsed · model
// Line 2 (detail): tokens · cache · context
const fp = formatFooterRuntimeSegments({
footer,
metrics: footerMetrics,
elapsedMs,
isError,
isAborted,
});
const footerZhLines = [];
const footerEnLines = [];
if (fp.primaryZh.length > 0) {
footerZhLines.push(fp.primaryZh.join(' · '));
footerEnLines.push(fp.primaryEn.join(' · '));
}
if (fp.detailZh.length > 0) {
footerZhLines.push(fp.detailZh.join(' · '));
footerEnLines.push(fp.detailEn.join(' · '));
}
if (footerZhLines.length > 0) {
elements.push(...buildFooter(footerZhLines.join('\n'), footerEnLines.join('\n'), isError));
}
// Use the answer text as the feed preview summary.
// Strip markdown syntax so the preview reads as plain text.
const summaryText = text.replace(/[*_`#>[\]()~]/g, '').trim();
const summary = summaryText ? { content: summaryText.slice(0, 120) } : undefined;
return {
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'], summary },
elements,
};
}
function buildConfirmCard(confirmData) {
const elements = [];
// Operation description
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: confirmData.operationDescription,
},
});
// Preview (if available)
if (confirmData.preview) {
elements.push({ tag: 'hr' });
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**Preview:**\n${confirmData.preview}`,
},
});
}
// Confirm / Reject / Preview buttons
elements.push({ tag: 'hr' });
elements.push({
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: 'Confirm' },
type: 'primary',
value: {
action: 'confirm_write',
operation_id: confirmData.pendingOperationId,
},
},
{
tag: 'button',
text: { tag: 'plain_text', content: 'Reject' },
type: 'danger',
value: {
action: 'reject_write',
operation_id: confirmData.pendingOperationId,
},
},
...(confirmData.preview
? []
: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: 'Preview',
},
type: 'default',
value: {
action: 'preview_write',
operation_id: confirmData.pendingOperationId,
},
},
]),
],
});
return {
config: { wide_screen_mode: true, update_multi: true },
header: {
title: {
tag: 'plain_text',
content: '\ud83d\udd12 Confirmation Required',
},
template: 'orange',
},
elements,
};
}
// ---------------------------------------------------------------------------
// toCardKit2
// ---------------------------------------------------------------------------
/**
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
*/
/**
* Build the initial CardKit 2.0 streaming card with a loading icon.
* Optionally includes a tool-use pending panel above the streaming area.
*/
function buildStreamingThinkingCard(showToolUse = true) {
return buildStreamingPreAnswerCard({ showToolUse });
}
/**
* Build a CardKit 2.0 card for the pre-answer streaming phase.
* Used both for the initial card and for live updates during tool calls.
*/
function buildStreamingPreAnswerCard(params) {
const { steps, elapsedMs, showToolUse = true } = params;
const hasSteps = Boolean(steps?.length);
const elements = [];
if (showToolUse) {
elements.push(hasSteps ? buildStreamingToolUseActivePanel({ steps: steps, elapsedMs }) : buildStreamingToolUsePendingPanel());
}
elements.push({
tag: 'markdown',
content: '',
text_align: 'left',
text_size: 'normal_v2',
margin: '0px 0px 0px 0px',
element_id: exports.STREAMING_ELEMENT_ID,
});
elements.push({
tag: 'markdown',
content: ' ',
icon: {
tag: 'custom_icon',
img_key: 'img_v3_02vb_496bec09-4b43-4773-ad6b-0cdd103cd2bg',
size: '16px 16px',
},
element_id: 'loading_icon',
});
return {
schema: '2.0',
config: {
streaming_mode: true,
locales: ['zh_cn', 'en_us'],
summary: {
content: 'Processing...',
i18n_content: { zh_cn: '处理中...', en_us: 'Processing...' },
},
},
body: { elements },
};
}
/**
* Build the collapsible panel for the active pre-answer phase.
* Used by buildStreamingPreAnswerCard when at least one step exists.
*/
function buildStreamingToolUseActivePanel(params) {
const { steps, elapsedMs } = params;
const enParts = ['Tool use'];
const zhParts = ['工具执行'];
if (steps.length > 0) {
enParts.push(`${steps.length} step${steps.length === 1 ? '' : 's'}`);
zhParts.push(`${steps.length}`);
}
if (elapsedMs != null && elapsedMs > 0) {
const d = formatElapsed(elapsedMs);
enParts.push(`(${d})`);
zhParts.push(`(${d})`);
}
return {
tag: 'collapsible_panel',
expanded: true,
header: {
title: {
tag: 'plain_text',
content: `🛠️ ${enParts.join(' · ')}`,
i18n_content: {
zh_cn: `🛠️ ${zhParts.join(' · ')}`,
en_us: `🛠️ ${enParts.join(' · ')}`,
},
text_color: 'grey',
text_size: 'notation',
},
vertical_align: 'center',
icon: {
tag: 'standard_icon',
token: 'down-small-ccm_outlined',
color: 'grey',
size: '16px 16px',
},
icon_position: 'right',
icon_expanded_angle: -180,
},
border: { color: 'grey', corner_radius: '5px' },
vertical_spacing: '4px',
padding: '8px 8px 8px 8px',
elements: steps.flatMap((step) => buildToolUseStepElements(step)),
};
}
function toCardKit2(card) {
const result = {
schema: '2.0',
config: card.config,
body: { elements: card.elements },
};
if (card.header)
result.header = card.header;
return result;
}
function buildStreamingToolUsePendingPanel() {
return {
tag: 'collapsible_panel',
expanded: false,
header: {
title: {
tag: 'plain_text',
content: '🛠️ Tool use pending',
i18n_content: {
zh_cn: '🛠️ 等待工具执行',
en_us: '🛠️ Tool use pending',
},
text_color: 'grey',
text_size: 'notation',
},
vertical_align: 'center',
icon: {
tag: 'standard_icon',
token: 'down-small-ccm_outlined',
color: 'grey',
size: '16px 16px',
},
icon_position: 'right',
icon_expanded_angle: -180,
},
border: { color: 'grey', corner_radius: '5px' },
vertical_spacing: '4px',
padding: '8px 8px 8px 8px',
elements: [],
};
}
function buildToolUsePanel(params) {
const { toolUseSteps = [], toolUseElapsedMs, titleSuffix } = params;
const duration = toolUseElapsedMs ? formatToolUseDuration(toolUseElapsedMs) : null;
const zhTitleParts = [duration?.zh ?? '工具执行'];
const enTitleParts = [duration?.en ?? 'Tool use'];
if (titleSuffix) {
zhTitleParts.push(titleSuffix.zh);
enTitleParts.push(titleSuffix.en);
}
const stepElements = toolUseSteps.length > 0
? toolUseSteps.flatMap((step) => buildToolUseStepElements(step))
: [buildToolUsePlaceholder()];
return {
tag: 'collapsible_panel',
expanded: false,
header: {
title: {
tag: 'plain_text',
content: `🛠️ ${enTitleParts.join(' · ')}`,
i18n_content: {
zh_cn: `🛠️ ${zhTitleParts.join(' · ')}`,
en_us: `🛠️ ${enTitleParts.join(' · ')}`,
},
text_color: 'grey',
text_size: 'notation',
},
vertical_align: 'center',
icon: {
tag: 'standard_icon',
token: 'down-small-ccm_outlined',
color: 'grey',
size: '16px 16px',
},
icon_position: 'right',
icon_expanded_angle: -180,
},
border: { color: 'grey', corner_radius: '5px' },
vertical_spacing: '4px',
padding: '8px 8px 8px 8px',
elements: stepElements,
};
}
function buildToolUseStepElements(step) {
const elements = [buildToolUseStepTitleElement(step)];
const detailElement = buildToolUseStepDetailElement(step);
if (detailElement) {
elements.push(detailElement);
}
const outputElement = buildToolUseStepOutputElement(step);
if (outputElement) {
elements.push(outputElement);
}
return elements;
}
function buildToolUsePlaceholder(labels) {
const zh = labels?.zh ?? '暂无工具步骤';
const en = labels?.en ?? tool_use_display_1.EMPTY_TOOL_USE_PLACEHOLDER;
return {
tag: 'div',
text: {
tag: 'plain_text',
content: en,
i18n_content: {
zh_cn: zh,
en_us: en,
},
text_color: 'grey',
text_size: 'notation',
},
};
}
function buildToolUseStepTitleElement(step) {
return {
tag: 'div',
icon: {
tag: 'standard_icon',
token: step.iconToken,
color: 'grey',
},
text: {
tag: 'lark_md',
content: buildToolUseStepTitleMarkdown(step),
text_size: 'notation',
},
};
}
function buildToolUseStepTitleMarkdown(step) {
const status = formatToolUseStepStatus(step.status);
return (0, markdown_style_1.optimizeMarkdownStyle)(`**${escapeToolUseMarkdownText(step.title)}** · <font color='${status.color}'>${status.label}</font>`, 1);
}
function buildToolUseStepDetailElement(step) {
const detail = step.detail?.trim();
if (!detail)
return undefined;
return {
tag: 'div',
margin: TOOL_USE_STEP_CONTENT_INDENT,
text: {
tag: 'plain_text',
content: detail,
text_color: 'grey',
text_size: 'notation',
},
};
}
function buildToolUseStepOutputElement(step) {
const content = buildToolUseStepOutputMarkdown(step);
if (!content)
return undefined;
return {
tag: 'div',
margin: TOOL_USE_STEP_CONTENT_INDENT,
text: {
tag: 'lark_md',
content,
text_size: 'notation',
},
};
}
function buildToolUseStepOutputMarkdown(step) {
const lines = [];
if (step.errorBlock) {
lines.push('**Error**');
lines.push(formatToolUseCodeBlock(step.errorBlock.content, step.errorBlock.language));
}
else if (step.resultBlock) {
lines.push('**Result**');
lines.push(formatToolUseCodeBlock(step.resultBlock.content, step.resultBlock.language));
}
if (lines.length === 0)
return undefined;
return (0, markdown_style_1.optimizeMarkdownStyle)(lines.join('\n'), 1);
}
function formatToolUseStepStatus(status) {
switch (status) {
case 'running':
return { label: 'Running', color: 'turquoise' };
case 'error':
return { label: 'Failed', color: 'red' };
case 'success':
default:
return { label: 'Succeeded', color: 'green' };
}
}
function formatToolUseCodeBlock(content, language) {
const normalized = content.replace(/\r\n/g, '\n').trim();
const fence = '`'.repeat(Math.max(3, longestBacktickRun(normalized) + 1));
return `${fence}${language}\n${normalized}\n${fence}`;
}
function longestBacktickRun(value) {
const matches = value.match(/`+/g) ?? [];
return matches.reduce((max, run) => Math.max(max, run.length), 0);
}
function escapeToolUseMarkdownText(value) {
return value.replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>])/g, '\\$1');
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Unified card API error handling.
*
* Provides structured error class for CardKit API responses, sub-error
* parsing for the generic 230099 code, and helper predicates used by
* reply-dispatcher and streaming-card-controller.
*/
/** 卡片 API 级别错误码。 */
export declare const CARD_ERROR: {
/** 发送频率限制 */
readonly RATE_LIMITED: 230020;
/** 卡片内容创建失败(通用码,需检查子错误) */
readonly CARD_CONTENT_FAILED: 230099;
};
/** 230099 子错误码,嵌套在 msg 的 ErrCode 字段中。 */
export declare const CARD_CONTENT_SUB_ERROR: {
/** 卡片元素(表格等)数量超限 */
readonly ELEMENT_LIMIT: 11310;
};
export declare const FEISHU_CARD_TABLE_LIMIT = 3;
export interface MarkdownTableMatch {
index: number;
length: number;
raw: string;
}
/** CardKit API 返回非零 code 时的结构化错误。 */
export declare class CardKitApiError extends Error {
readonly code: number;
readonly msg: string;
constructor(params: {
api: string;
code: number;
msg: string;
context: string;
});
}
/**
* 从 msg 字符串中提取子错误码。
*
* 示例输入: "Failed to create card content, ext=ErrCode: 11310; ErrMsg: element exceeds the limit; code:230099"
* 返回 11310 或 null。
*/
export declare function extractSubCode(msg: string): number | null;
/**
* 从任意抛错对象中解析卡片 API 错误结构。
*
* 返回 { code, subCode, errMsg },如果无法提取 code 则返回 null。
*/
export declare function parseCardApiError(err: unknown): {
code: number;
subCode: number | null;
errMsg: string;
} | null;
/**
* 判断错误是否为卡片表格数量超限。
*
* 匹配条件:code 230099 + subCode 11310 + errMsg 含 "table number over limit"。
* 11310 是通用的元素超限码(也覆盖模板可见性、组件上限等),
* 必须同时检查 errMsg 确认是表格数量导致的。
*
* 实际错误格式(生产日志 2026-03-13):
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
*/
export declare function isCardTableLimitError(err: unknown): boolean;
/** 判断错误是否为卡片发送频率限制(230020)。 */
export declare function isCardRateLimitError(err: unknown): boolean;
/**
* 收集正文里可被飞书卡片实际渲染的 markdown 表格。
*
* 代码块里的示例表格不会被飞书解析成卡片表格元素,因此这里要先排除,
* 让 shouldUseCard() 预检和 sanitizeTextForCard() 降级逻辑使用同一份结果。
*/
export declare function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[];
/**
* 对多段 markdown 文本共享一个表格预算。
*
* 段落按数组顺序消耗额度,适合处理“reasoning + 正文”这类会被飞书
* 作为同一张卡片渲染的多块文本。
*/
export declare function sanitizeTextSegmentsForCard(texts: readonly string[], tableLimit?: number): string[];
/**
* 对正文中超出 tableLimit 的 markdown 表格降级为 code block
* 避免飞书卡片因表格数超限触发 230099/11310。
*
* 前 tableLimit 张表格保持原样(可正常卡片渲染);
* 超出部分用反引号包裹,阻止飞书将其解析为卡片表格元素。
*/
export declare function sanitizeTextForCard(text: string, tableLimit?: number): string;
@@ -0,0 +1,206 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Unified card API error handling.
*
* Provides structured error class for CardKit API responses, sub-error
* parsing for the generic 230099 code, and helper predicates used by
* reply-dispatcher and streaming-card-controller.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CardKitApiError = exports.FEISHU_CARD_TABLE_LIMIT = exports.CARD_CONTENT_SUB_ERROR = exports.CARD_ERROR = void 0;
exports.extractSubCode = extractSubCode;
exports.parseCardApiError = parseCardApiError;
exports.isCardTableLimitError = isCardTableLimitError;
exports.isCardRateLimitError = isCardRateLimitError;
exports.findMarkdownTablesOutsideCodeBlocks = findMarkdownTablesOutsideCodeBlocks;
exports.sanitizeTextSegmentsForCard = sanitizeTextSegmentsForCard;
exports.sanitizeTextForCard = sanitizeTextForCard;
const api_error_1 = require("../core/api-error.js");
// ---------------------------------------------------------------------------
// Error code constants
// ---------------------------------------------------------------------------
/** 卡片 API 级别错误码。 */
exports.CARD_ERROR = {
/** 发送频率限制 */
RATE_LIMITED: 230020,
/** 卡片内容创建失败(通用码,需检查子错误) */
CARD_CONTENT_FAILED: 230099,
};
/** 230099 子错误码,嵌套在 msg 的 ErrCode 字段中。 */
exports.CARD_CONTENT_SUB_ERROR = {
/** 卡片元素(表格等)数量超限 */
ELEMENT_LIMIT: 11310,
};
// 经验性的飞书卡片表格上限 -- 4+ 张触发 230099/113102026-03 实测)。
exports.FEISHU_CARD_TABLE_LIMIT = 3;
// ---------------------------------------------------------------------------
// Error class
// ---------------------------------------------------------------------------
/** CardKit API 返回非零 code 时的结构化错误。 */
class CardKitApiError extends Error {
code;
msg;
constructor(params) {
const { api, code, msg, context } = params;
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`);
this.name = 'CardKitApiError';
this.code = code;
this.msg = msg;
}
}
exports.CardKitApiError = CardKitApiError;
// ---------------------------------------------------------------------------
// Sub-error extraction
// ---------------------------------------------------------------------------
/**
* 从 msg 字符串中提取子错误码。
*
* 示例输入: "Failed to create card content, ext=ErrCode: 11310; ErrMsg: element exceeds the limit; code:230099"
* 返回 11310 或 null。
*/
function extractSubCode(msg) {
const match = /ErrCode:\s*(\d+)/.exec(msg);
if (!match)
return null;
const code = Number(match[1]);
return Number.isFinite(code) ? code : null;
}
// ---------------------------------------------------------------------------
// Structured error parsing
// ---------------------------------------------------------------------------
/**
* 从任意抛错对象中解析卡片 API 错误结构。
*
* 返回 { code, subCode, errMsg },如果无法提取 code 则返回 null。
*/
function parseCardApiError(err) {
const code = (0, api_error_1.extractLarkApiCode)(err);
if (code === undefined)
return null;
// 按优先级提取 msg 文本
let errMsg = '';
if (err && typeof err === 'object') {
const e = err;
if (typeof e.msg === 'string') {
errMsg = e.msg;
}
else if (typeof e.response?.data?.msg === 'string') {
// Axios errors: response.data.msg carries the Feishu detail with ErrCode
errMsg = e.response.data.msg;
}
else if (typeof e.message === 'string') {
// Fallback to generic Error.message (e.g. CardKitApiError)
errMsg = e.message;
}
}
const subCode = extractSubCode(errMsg);
return { code, subCode, errMsg };
}
// ---------------------------------------------------------------------------
// Helper predicates
// ---------------------------------------------------------------------------
/**
* 判断错误是否为卡片表格数量超限。
*
* 匹配条件:code 230099 + subCode 11310 + errMsg 含 "table number over limit"。
* 11310 是通用的元素超限码(也覆盖模板可见性、组件上限等),
* 必须同时检查 errMsg 确认是表格数量导致的。
*
* 实际错误格式(生产日志 2026-03-13):
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
*/
function isCardTableLimitError(err) {
const parsed = parseCardApiError(err);
if (!parsed)
return false;
return (parsed.code === exports.CARD_ERROR.CARD_CONTENT_FAILED &&
parsed.subCode === exports.CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
/table number over limit/i.test(parsed.errMsg));
}
/** 判断错误是否为卡片发送频率限制(230020)。 */
function isCardRateLimitError(err) {
const parsed = parseCardApiError(err);
if (!parsed)
return false;
return parsed.code === exports.CARD_ERROR.RATE_LIMITED;
}
// ---------------------------------------------------------------------------
// Text sanitization
// ---------------------------------------------------------------------------
/**
* 收集正文里可被飞书卡片实际渲染的 markdown 表格。
*
* 代码块里的示例表格不会被飞书解析成卡片表格元素,因此这里要先排除,
* 让 shouldUseCard() 预检和 sanitizeTextForCard() 降级逻辑使用同一份结果。
*/
function findMarkdownTablesOutsideCodeBlocks(text) {
const codeBlockRanges = [];
const codeBlockRegex = /```[\s\S]*?```/g;
let codeBlockMatch = codeBlockRegex.exec(text);
while (codeBlockMatch != null) {
codeBlockRanges.push({
start: codeBlockMatch.index,
end: codeBlockMatch.index + codeBlockMatch[0].length,
});
codeBlockMatch = codeBlockRegex.exec(text);
}
const isInsideCodeBlock = (idx) => codeBlockRanges.some((range) => idx >= range.start && idx < range.end);
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g;
const matches = [];
let tableMatch = tableRegex.exec(text);
while (tableMatch != null) {
if (!isInsideCodeBlock(tableMatch.index)) {
matches.push({
index: tableMatch.index,
length: tableMatch[0].length,
raw: tableMatch[0],
});
}
tableMatch = tableRegex.exec(text);
}
return matches;
}
/**
* 对多段 markdown 文本共享一个表格预算。
*
* 段落按数组顺序消耗额度,适合处理“reasoning + 正文”这类会被飞书
* 作为同一张卡片渲染的多块文本。
*/
function sanitizeTextSegmentsForCard(texts, tableLimit = exports.FEISHU_CARD_TABLE_LIMIT) {
let remainingTableBudget = tableLimit;
return texts.map((text) => {
const matches = findMarkdownTablesOutsideCodeBlocks(text);
if (matches.length <= remainingTableBudget) {
remainingTableBudget -= matches.length;
return text;
}
const sanitizedText = wrapTablesBeyondLimit(text, matches, Math.max(remainingTableBudget, 0));
remainingTableBudget = 0;
return sanitizedText;
});
}
/**
* 对正文中超出 tableLimit 的 markdown 表格降级为 code block
* 避免飞书卡片因表格数超限触发 230099/11310。
*
* 前 tableLimit 张表格保持原样(可正常卡片渲染);
* 超出部分用反引号包裹,阻止飞书将其解析为卡片表格元素。
*/
function sanitizeTextForCard(text, tableLimit = exports.FEISHU_CARD_TABLE_LIMIT) {
return sanitizeTextSegmentsForCard([text], tableLimit)[0];
}
function wrapTablesBeyondLimit(text, matches, keepCount) {
if (matches.length <= keepCount)
return text;
// Back-to-front replacement keeps the original indices stable.
let result = text;
for (let i = matches.length - 1; i >= keepCount; i--) {
const { index, length, raw } = matches[i];
const replacement = `\`\`\`\n${raw}\n\`\`\``;
result = result.slice(0, index) + replacement + result.slice(index + length);
}
return result;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* CardKit streaming APIs for Lark/Feishu.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuSendResult } from '../messaging/types';
/**
* Create a card entity via the CardKit API.
*
* Returns the card_id directly, bypassing the idConvert step.
* The card can then be sent via IM API and streamed via CardKit.
*/
export declare function createCardEntity(params: {
cfg: ClawdbotConfig;
card: Record<string, unknown>;
accountId?: string;
}): Promise<string | null>;
/**
* Stream text content to a specific card element using the CardKit API.
*
* The card automatically diffs the new content against the previous
* content and renders incremental changes with a typewriter animation.
*
* @param params.cardId - CardKit card ID (from `convertMessageToCardId`).
* @param params.elementId - The element ID to update (e.g. `STREAMING_ELEMENT_ID`).
* @param params.content - The full cumulative text (not a delta).
* @param params.sequence - Monotonically increasing sequence number.
*/
export declare function streamCardContent(params: {
cfg: ClawdbotConfig;
cardId: string;
elementId: string;
content: string;
sequence: number;
accountId?: string;
}): Promise<void>;
/**
* Fully replace a card using the CardKit API.
*
* Used for the final "complete" state update (with action buttons, green
* header, etc.) after streaming finishes.
*
* @param params.cardId - CardKit card ID.
* @param params.card - The new card JSON content.
* @param params.sequence - Monotonically increasing sequence number.
*/
export declare function updateCardKitCard(params: {
cfg: ClawdbotConfig;
cardId: string;
card: Record<string, unknown>;
sequence: number;
accountId?: string;
}): Promise<void>;
export declare function updateCardKitCardForAuth(params: {
cfg: ClawdbotConfig;
cardId: string;
card: Record<string, unknown>;
sequence: number;
accountId?: string;
}): Promise<void>;
/**
* Send an interactive card message by referencing a CardKit card_id.
*
* The content format is: {"type":"card","data":{"card_id":"xxx"}}
* This links the IM message to the CardKit card entity, enabling
* streaming updates via cardElement.content().
*/
export declare function sendCardByCardId(params: {
cfg: ClawdbotConfig;
to: string;
cardId: string;
replyToMessageId?: string;
replyInThread?: boolean;
accountId?: string;
}): Promise<FeishuSendResult>;
/**
* Close (or open) the streaming mode on a CardKit card.
*
* Must be called after streaming is complete to restore normal card
* behaviour (forwarding, interaction callbacks, etc.).
*/
export declare function setCardStreamingMode(params: {
cfg: ClawdbotConfig;
cardId: string;
streamingMode: boolean;
sequence: number;
accountId?: string;
}): Promise<void>;
@@ -0,0 +1,203 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* CardKit streaming APIs for Lark/Feishu.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createCardEntity = createCardEntity;
exports.streamCardContent = streamCardContent;
exports.updateCardKitCard = updateCardKitCard;
exports.updateCardKitCardForAuth = updateCardKitCardForAuth;
exports.sendCardByCardId = sendCardByCardId;
exports.setCardStreamingMode = setCardStreamingMode;
const lark_client_1 = require("../core/lark-client.js");
const lark_logger_1 = require("../core/lark-logger.js");
const message_unavailable_1 = require("../core/message-unavailable.js");
const targets_1 = require("../core/targets.js");
const card_error_1 = require("./card-error.js");
const log = (0, lark_logger_1.larkLogger)('card/cardkit');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* 记录 CardKit API 响应日志,检测错误码并抛出异常。
*
* 默认 fail-fastbody-level 非零 code 视为业务错误,立即抛出,
* 由调用方(streaming-card-controller 等)统一走 catch → guard 处理。
*/
function logCardKitResponse(params) {
const { resp, api, context } = params;
const { code, msg } = resp;
log.info(`cardkit ${api} response`, { code, msg, context });
if (code && code !== 0) {
log.warn(`cardkit ${api} FAILED`, {
code,
msg,
context,
fullResponse: resp,
});
throw new card_error_1.CardKitApiError({ api, code, msg: msg ?? '', context });
}
}
// ---------------------------------------------------------------------------
// CardKit streaming APIs
// ---------------------------------------------------------------------------
/**
* Create a card entity via the CardKit API.
*
* Returns the card_id directly, bypassing the idConvert step.
* The card can then be sent via IM API and streamed via CardKit.
*/
async function createCardEntity(params) {
const { cfg, card, accountId } = params;
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId).sdk;
// SDK 返回类型不完整,运行时包含 code/msg/data 字段
const response = (await client.cardkit.v1.card.create({
data: {
type: 'card_json',
data: JSON.stringify(card),
},
}));
// 兼容不同 SDK 包装层:优先 data.card_id,回退顶层 card_id
const cardId = (response.data?.card_id ?? response.card_id) ?? null;
logCardKitResponse({
resp: response,
api: 'card.create',
context: `cardId=${cardId}`,
});
return cardId;
}
/**
* Stream text content to a specific card element using the CardKit API.
*
* The card automatically diffs the new content against the previous
* content and renders incremental changes with a typewriter animation.
*
* @param params.cardId - CardKit card ID (from `convertMessageToCardId`).
* @param params.elementId - The element ID to update (e.g. `STREAMING_ELEMENT_ID`).
* @param params.content - The full cumulative text (not a delta).
* @param params.sequence - Monotonically increasing sequence number.
*/
async function streamCardContent(params) {
const { cfg, cardId, elementId, content, sequence, accountId } = params;
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId).sdk;
// SDK 返回类型不完整,运行时包含 code/msg 字段
const resp = (await client.cardkit.v1.cardElement.content({
data: { content, sequence },
path: { card_id: cardId, element_id: elementId },
}));
logCardKitResponse({
resp,
api: 'cardElement.content',
context: `seq=${sequence}, contentLen=${content.length}`,
});
}
/**
* Fully replace a card using the CardKit API.
*
* Used for the final "complete" state update (with action buttons, green
* header, etc.) after streaming finishes.
*
* @param params.cardId - CardKit card ID.
* @param params.card - The new card JSON content.
* @param params.sequence - Monotonically increasing sequence number.
*/
async function updateCardKitCard(params) {
const { cfg, cardId, card, sequence, accountId } = params;
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId).sdk;
// SDK 返回类型不完整,运行时包含 code/msg 字段
const resp = (await client.cardkit.v1.card.update({
data: {
card: { type: 'card_json', data: JSON.stringify(card) },
sequence,
},
path: { card_id: cardId },
}));
logCardKitResponse({
resp,
api: 'card.update',
context: `seq=${sequence}, cardId=${cardId}`,
});
}
async function updateCardKitCardForAuth(params) {
return updateCardKitCard(params);
}
/**
* Send an interactive card message by referencing a CardKit card_id.
*
* The content format is: {"type":"card","data":{"card_id":"xxx"}}
* This links the IM message to the CardKit card entity, enabling
* streaming updates via cardElement.content().
*/
async function sendCardByCardId(params) {
const { cfg, to, cardId, replyToMessageId, replyInThread, accountId } = params;
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId).sdk;
const contentPayload = JSON.stringify({
type: 'card',
data: { card_id: cardId },
});
if (replyToMessageId) {
// 规范化 message_id,处理合成 ID(如 "om_xxx:auth-complete"
const normalizedId = (0, targets_1.normalizeMessageId)(replyToMessageId);
const response = await (0, message_unavailable_1.runWithMessageUnavailableGuard)({
messageId: normalizedId,
operation: 'im.message.reply(interactive.cardkit)',
fn: () => client.im.message.reply({
path: { message_id: normalizedId },
data: {
content: contentPayload,
msg_type: 'interactive',
reply_in_thread: replyInThread,
},
}),
});
return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
}
const target = (0, targets_1.normalizeFeishuTarget)(to);
if (!target) {
throw new Error(`[feishu-send] Invalid target: "${to}"`);
}
const receiveIdType = (0, targets_1.resolveReceiveIdType)(target);
const response = await client.im.message.create({
// SDK 类型将 receive_id_type 限定为字面量联合,但运行时接受动态值
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: { receive_id_type: receiveIdType },
data: {
receive_id: target,
msg_type: 'interactive',
content: contentPayload,
},
});
return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
}
/**
* Close (or open) the streaming mode on a CardKit card.
*
* Must be called after streaming is complete to restore normal card
* behaviour (forwarding, interaction callbacks, etc.).
*/
async function setCardStreamingMode(params) {
const { cfg, cardId, streamingMode, sequence, accountId } = params;
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId).sdk;
// SDK 返回类型不完整,运行时包含 code/msg 字段
const resp = (await client.cardkit.v1.card.settings({
data: {
settings: JSON.stringify({ streaming_mode: streamingMode }),
sequence,
},
path: { card_id: cardId },
}));
logCardKitResponse({
resp,
api: 'card.settings',
context: `seq=${sequence}, streaming_mode=${streamingMode}`,
});
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Generic throttled flush controller.
*
* A pure scheduling primitive that manages timer-based throttling,
* mutex-guarded flushing, and reflush-on-conflict. Contains no
* business logic — the actual flush work is provided via a callback.
*/
export declare class FlushController {
private readonly doFlush;
private flushInProgress;
private flushResolvers;
private needsReflush;
private pendingFlushTimer;
private lastUpdateTime;
private isCompleted;
constructor(doFlush: () => Promise<void>);
/** Mark the controller as completed — no more flushes after current one. */
complete(): void;
/** Cancel any pending deferred flush timer. */
cancelPendingFlush(): void;
/** Wait for any in-progress flush to finish. */
waitForFlush(): Promise<void>;
/**
* Execute a flush (mutex-guarded, with reflush on conflict).
*
* If a flush is already in progress, marks needsReflush so a
* follow-up flush fires immediately after the current one completes.
*/
flush(): Promise<void>;
/**
* Throttled update entry point.
*
* @param throttleMs - Minimum interval between flushes (varies by
* CardKit vs IM patch mode). Passed in by the caller so this
* controller remains business-logic-free.
*/
throttledUpdate(throttleMs: number): Promise<void>;
/** Overridable gate: subclasses / consumers can set via setCardMessageReady. */
private _cardMessageReady;
cardMessageReady(): boolean;
setCardMessageReady(ready: boolean): void;
}
@@ -0,0 +1,138 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Generic throttled flush controller.
*
* A pure scheduling primitive that manages timer-based throttling,
* mutex-guarded flushing, and reflush-on-conflict. Contains no
* business logic — the actual flush work is provided via a callback.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.FlushController = void 0;
const reply_dispatcher_types_1 = require("./reply-dispatcher-types.js");
// ---------------------------------------------------------------------------
// FlushController
// ---------------------------------------------------------------------------
class FlushController {
doFlush;
flushInProgress = false;
flushResolvers = [];
needsReflush = false;
pendingFlushTimer = null;
lastUpdateTime = 0;
isCompleted = false;
constructor(doFlush) {
this.doFlush = doFlush;
}
/** Mark the controller as completed — no more flushes after current one. */
complete() {
this.isCompleted = true;
}
/** Cancel any pending deferred flush timer. */
cancelPendingFlush() {
if (this.pendingFlushTimer) {
clearTimeout(this.pendingFlushTimer);
this.pendingFlushTimer = null;
}
}
/** Wait for any in-progress flush to finish. */
waitForFlush() {
if (!this.flushInProgress)
return Promise.resolve();
return new Promise((resolve) => this.flushResolvers.push(resolve));
}
/**
* Execute a flush (mutex-guarded, with reflush on conflict).
*
* If a flush is already in progress, marks needsReflush so a
* follow-up flush fires immediately after the current one completes.
*/
async flush() {
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
if (this.flushInProgress && !this.isCompleted)
this.needsReflush = true;
return;
}
this.flushInProgress = true;
this.needsReflush = false;
// Update timestamp BEFORE the API call to prevent concurrent callers
// from also entering the flush (race condition fix).
this.lastUpdateTime = Date.now();
try {
await this.doFlush();
this.lastUpdateTime = Date.now();
}
finally {
this.flushInProgress = false;
const resolvers = this.flushResolvers;
this.flushResolvers = [];
for (const resolve of resolvers)
resolve();
// If events arrived while the API call was in flight,
// schedule an immediate follow-up flush.
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
this.needsReflush = false;
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null;
void this.flush();
}, 0);
}
}
}
/**
* Throttled update entry point.
*
* @param throttleMs - Minimum interval between flushes (varies by
* CardKit vs IM patch mode). Passed in by the caller so this
* controller remains business-logic-free.
*/
async throttledUpdate(throttleMs) {
if (!this.cardMessageReady())
return;
const now = Date.now();
const elapsed = now - this.lastUpdateTime;
if (elapsed >= throttleMs) {
this.cancelPendingFlush();
if (elapsed > reply_dispatcher_types_1.THROTTLE_CONSTANTS.LONG_GAP_THRESHOLD_MS) {
// After a long gap, batch briefly so the first visible update
// contains meaningful text rather than just 1-2 characters.
this.lastUpdateTime = now;
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null;
void this.flush();
}, reply_dispatcher_types_1.THROTTLE_CONSTANTS.BATCH_AFTER_GAP_MS);
}
else {
await this.flush();
}
}
else if (!this.pendingFlushTimer) {
// Inside throttle window — schedule a deferred flush
const delay = throttleMs - elapsed;
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null;
void this.flush();
}, delay);
}
}
// ------------------------------------------------------------------
// Internal
// ------------------------------------------------------------------
/** Overridable gate: subclasses / consumers can set via setCardMessageReady. */
_cardMessageReady = false;
cardMessageReady() {
return this._cardMessageReady;
}
setCardMessageReady(ready) {
this._cardMessageReady = ready;
if (ready) {
// Initialize the timestamp so the first throttledUpdate sees a
// small elapsed time (matching original behavior where
// lastCardUpdateTime = Date.now() was set during card creation).
this.lastUpdateTime = Date.now();
}
}
}
exports.FlushController = FlushController;
+45
View File
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ImageResolver — converts image URLs in markdown to Feishu image keys.
*
* Used by StreamingCardController to asynchronously download and upload
* images referenced via `![alt](https://...)` in model-generated markdown,
* replacing them with `![alt](img_xxx)` that Feishu cards can render.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
export interface ImageResolverOptions {
cfg: ClawdbotConfig;
accountId: string | undefined;
/** Called when a previously-pending image upload completes. */
onImageResolved: () => void;
}
export declare class ImageResolver {
/** URL → imageKey for successfully uploaded images. */
private readonly resolved;
/** URL → upload Promise for in-flight uploads (dedup). */
private readonly pending;
/** URLs that have already failed — skip retries. */
private readonly failed;
private readonly cfg;
private readonly accountId;
private readonly onImageResolved;
constructor(opts: ImageResolverOptions);
/**
* Synchronously resolve image URLs in markdown text.
*
* - `img_xxx` references are kept as-is.
* - URLs with a cached imageKey are replaced inline.
* - URLs with an in-flight upload are stripped (will appear after re-flush).
* - New URLs trigger an async upload and are stripped for now.
*/
resolveImages(text: string): string;
/**
* Resolve all image URLs in text synchronously: trigger uploads for new
* URLs, wait for all pending uploads, then return text with image keys.
*/
resolveImagesAwait(text: string, timeoutMs: number): Promise<string>;
private startUpload;
private doUpload;
}
@@ -0,0 +1,116 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ImageResolver — converts image URLs in markdown to Feishu image keys.
*
* Used by StreamingCardController to asynchronously download and upload
* images referenced via `![alt](https://...)` in model-generated markdown,
* replacing them with `![alt](img_xxx)` that Feishu cards can render.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImageResolver = void 0;
const media_1 = require("../messaging/outbound/media.js");
const lark_logger_1 = require("../core/lark-logger.js");
const log = (0, lark_logger_1.larkLogger)('card/image-resolver');
/** Matches complete markdown image syntax: `![alt](value)` */
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
class ImageResolver {
/** URL → imageKey for successfully uploaded images. */
resolved = new Map();
/** URL → upload Promise for in-flight uploads (dedup). */
pending = new Map();
/** URLs that have already failed — skip retries. */
failed = new Set();
cfg;
accountId;
onImageResolved;
constructor(opts) {
this.cfg = opts.cfg;
this.accountId = opts.accountId;
this.onImageResolved = opts.onImageResolved;
}
/**
* Synchronously resolve image URLs in markdown text.
*
* - `img_xxx` references are kept as-is.
* - URLs with a cached imageKey are replaced inline.
* - URLs with an in-flight upload are stripped (will appear after re-flush).
* - New URLs trigger an async upload and are stripped for now.
*/
resolveImages(text) {
if (!text.includes('!['))
return text;
return text.replace(IMAGE_RE, (fullMatch, alt, value) => {
// Already a Feishu image key — keep.
if (value.startsWith('img_'))
return fullMatch;
// Not a remote URL — strip (local paths, data URIs, etc.).
if (!value.startsWith('http://') && !value.startsWith('https://'))
return '';
// Cached — replace with image key.
const cached = this.resolved.get(value);
if (cached)
return `![${alt}](${cached})`;
// Already failed — don't retry, strip.
if (this.failed.has(value))
return '';
// Upload in progress — strip for now.
if (this.pending.has(value))
return '';
// New URL — kick off async upload, strip for now.
this.startUpload(value);
return '';
});
}
/**
* Resolve all image URLs in text synchronously: trigger uploads for new
* URLs, wait for all pending uploads, then return text with image keys.
*/
async resolveImagesAwait(text, timeoutMs) {
// First pass: trigger uploads for any new URLs
this.resolveImages(text);
if (this.pending.size > 0) {
log.info('resolveImagesAwait: waiting for uploads', { count: this.pending.size, timeoutMs });
const allUploads = Promise.all(this.pending.values());
const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
await Promise.race([allUploads, timeout]);
if (this.pending.size > 0) {
log.warn('resolveImagesAwait: timed out with pending uploads', {
remaining: this.pending.size,
});
}
}
// Second pass: replace URLs with resolved image keys
return this.resolveImages(text);
}
startUpload(url) {
const uploadPromise = this.doUpload(url);
this.pending.set(url, uploadPromise);
}
async doUpload(url) {
try {
log.info('uploading image', { url });
const buffer = await (0, media_1.fetchRemoteImageBuffer)(url);
const { imageKey } = await (0, media_1.uploadImageLark)({
cfg: this.cfg,
image: buffer,
imageType: 'message',
accountId: this.accountId,
});
log.info('image uploaded', { url, imageKey });
this.resolved.set(url, imageKey);
this.pending.delete(url);
this.onImageResolved();
return imageKey;
}
catch (err) {
log.warn('image upload failed', { url, error: String(err) });
this.pending.delete(url);
this.failed.add(url);
return null;
}
}
}
exports.ImageResolver = ImageResolver;
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Markdown 样式优化工具
*/
/**
* 优化 Markdown 样式:
* - 标题降级:H1 → H4H2~H6 → H5
* - 表格前后增加段落间距
* - 有序列表:序号后确保只有一个空格
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
* - 代码块内容不受影响
*/
export declare function optimizeMarkdownStyle(text: string, cardVersion?: number): string;
@@ -0,0 +1,106 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Markdown 样式优化工具
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.optimizeMarkdownStyle = optimizeMarkdownStyle;
/**
* 优化 Markdown 样式:
* - 标题降级:H1 → H4H2~H6 → H5
* - 表格前后增加段落间距
* - 有序列表:序号后确保只有一个空格
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
* - 代码块内容不受影响
*/
function optimizeMarkdownStyle(text, cardVersion = 2) {
try {
let r = _optimizeMarkdownStyle(text, cardVersion);
r = stripInvalidImageKeys(r);
return r;
}
catch {
return text;
}
}
function _optimizeMarkdownStyle(text, cardVersion = 2) {
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
const MARK = '___CB_';
const codeBlocks = [];
let r = text.replace(/(^|\n)(`{3,})([^\n]*)\n[\s\S]*?\n\2(?=\n|$)/g, (m, prefix = '') => {
const block = m.slice(String(prefix).length);
return `${prefix}${MARK}${codeBlocks.push(block) - 1}___`;
});
// ── 2. 标题降级 ────────────────────────────────────────────────────
// 只有当原文档包含 h1~h3 标题时才执行降级
// 先处理 H2~H6 → H5,再处理 H1 → H4
// 顺序不能颠倒:若先 H1→H4,H4(####)会被后面的 #{2,6} 再次匹配成 H5
const hasH1toH3 = /^#{1,3} /m.test(text);
if (hasH1toH3) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2~H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4
}
if (cardVersion >= 2) {
// ── 3. 连续标题间增加段落间距 ───────────────────────────────────────
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2');
// ── 4. 表格前后增加段落间距 ─────────────────────────────────────────
// 4a. 非表格行直接跟表格行时,先补一个空行
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
// 4b. 表格前:在空行之前插入 <br>(即 \n\n| → \n<br>\n\n|
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1');
// 4c. 表格后:在表格块末尾追加 <br>(跳过后接分隔线/标题/加粗/文末的情况)
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => {
const after = r.slice(offset + m.length).replace(/^\n+/, '');
if (!after || /^(---|#{4,5} |\*\*)/.test(after))
return m;
return m + '\n<br>\n';
});
// 4d. 表格前是普通文本(非标题、非加粗行)时,只需 <br>,去掉多余空行
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3');
// 4d2. 表格前是加粗行时,<br> 紧贴加粗行,空行保留在后面
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3');
// 4e. 表格后是普通文本(非标题、非加粗行)时,只需 <br>,去掉多余空行
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3');
// ── 5. 还原代码块,并在前后追加 <br> ──────────────────────────────
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, `\n<br>\n${block}\n<br>\n`);
});
}
else {
// ── 5. 还原代码块(无 <br>)───────────────────────────────────────
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, block);
});
}
// ── 6. 压缩多余空行(3 个以上连续换行 → 2 个)────────────────────
r = r.replace(/\n{3,}/g, '\n\n');
return r;
}
// ---------------------------------------------------------------------------
// stripInvalidImageKeys
// ---------------------------------------------------------------------------
/** Matches complete markdown image syntax: `![alt](value)` */
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
/**
* Strip `![alt](value)` where value is not a valid Feishu image key
* (`img_xxx`). Prevents CardKit error 200570.
*
* HTTP URLs are stripped as well — ImageResolver should have already
* replaced them with `img_xxx` keys before this point. This serves
* as a safety net for any unresolved URLs.
*/
function stripInvalidImageKeys(text) {
if (!text.includes('!['))
return text;
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
if (value.startsWith('img_'))
return fullMatch;
return ''; // strip all non-img_ image references (URLs, local paths, etc.)
});
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared utilities for the reasoning display subsystem.
*/
export declare function normalizeToolName(name?: string): string;
export declare function truncateText(value: string, maxLength: number): string;
export declare function redactInlineSecrets(value: string): string;
/**
* Sanitize tool params for safe logging.
* Logs only param key names (no values) to avoid leaking sensitive data.
*/
export declare function sanitizeParamsForLog(params?: Record<string, unknown>): string;
@@ -0,0 +1,64 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared utilities for the reasoning display subsystem.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeToolName = normalizeToolName;
exports.truncateText = truncateText;
exports.redactInlineSecrets = redactInlineSecrets;
exports.sanitizeParamsForLog = sanitizeParamsForLog;
function normalizeToolName(name) {
return name?.trim().toLowerCase() ?? '';
}
function truncateText(value, maxLength) {
if (value.length <= maxLength)
return value;
return `${value.slice(0, maxLength - 3)}...`;
}
const INLINE_ASSIGNMENT_RE = /(^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)(=(?:"[^"]*"|'[^']*'|[^\s"'`]+))/g;
const AUTH_HEADER_SECRET_RE = /(Authorization\s*:\s*(?:Bearer|Basic|Token)\s+)([^'"\s]+)/gi;
const QUOTED_HEADER_ARG_RE = /((?:^|[\s"'`])(?:-H|--header)\s+)(['"])([A-Za-z0-9_-]+)(\s*:\s*)([^'"]*)(\2)/gi;
const UNQUOTED_HEADER_ARG_RE = /((?:^|[\s"'`])(?:-H|--header)\s+)([A-Za-z0-9_-]+)(\s*:\s*)([^\s"'`]+)/gi;
const SECRET_FLAG_RE = /((?:^|[\s"'`]))(--?[A-Za-z0-9][A-Za-z0-9-]*)(=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s"'`]+))/g;
const SENSITIVE_NAME_RE = /token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
function redactInlineSecrets(value) {
return value
.replace(INLINE_ASSIGNMENT_RE, (match, prefix, key) => isSensitiveName(key) ? `${prefix}${key}=[redacted]` : match)
.replace(AUTH_HEADER_SECRET_RE, '$1[redacted]')
.replace(QUOTED_HEADER_ARG_RE, (match, prefix, quote, name, separator) => shouldRedactHeaderValue(name) ? `${prefix}${quote}${name}${separator}[redacted]${quote}` : match)
.replace(UNQUOTED_HEADER_ARG_RE, (match, prefix, name, separator) => shouldRedactHeaderValue(name) ? `${prefix}${name}${separator}[redacted]` : match)
.replace(SECRET_FLAG_RE, (match, prefix, flag, separator, doubleQuoted, singleQuoted, bare) => {
const normalizedFlag = flag.replace(/^-+/, '');
if (!isSensitiveName(normalizedFlag))
return match;
const redactedValue = doubleQuoted !== undefined
? '"[redacted]"'
: singleQuoted !== undefined
? "'[redacted]'"
: bare !== undefined
? '[redacted]'
: '[redacted]';
return `${prefix}${flag}${separator}${redactedValue}`;
});
}
function isSensitiveName(value) {
return SENSITIVE_NAME_RE.test(value);
}
function shouldRedactHeaderValue(name) {
return !/^authorization$/i.test(name) && isSensitiveName(name);
}
/**
* Sanitize tool params for safe logging.
* Logs only param key names (no values) to avoid leaking sensitive data.
*/
function sanitizeParamsForLog(params) {
if (!params || typeof params !== 'object')
return '';
const keys = Object.keys(params);
if (keys.length === 0)
return '{}';
return `{${keys.join(',')}}`;
}
@@ -0,0 +1,132 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Type definitions for the Feishu reply dispatcher subsystem.
*
* Consolidates all interfaces, state shapes, and constants used across
* reply-dispatcher.ts, streaming-card-controller.ts, flush-controller.ts,
* and unavailable-guard.ts.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { ReplyDispatcher } from 'openclaw/plugin-sdk/reply-runtime';
import type { FeishuFooterConfig } from '../core/types';
import type { ToolUseDisplayConfig } from './tool-use-config';
export declare const CARD_PHASES: {
readonly idle: "idle";
readonly creating: "creating";
readonly streaming: "streaming";
readonly completed: "completed";
readonly aborted: "aborted";
readonly terminated: "terminated";
readonly creation_failed: "creation_failed";
};
export type CardPhase = (typeof CARD_PHASES)[keyof typeof CARD_PHASES];
export declare const TERMINAL_PHASES: ReadonlySet<CardPhase>;
/**
* Why a terminal phase was entered.
*
* - `normal` — streaming completed successfully (onIdle).
* - `error` — an error occurred during reply generation (onError).
* - `abort` — explicitly cancelled by the caller (abortCard).
* - `unavailable` — source message was deleted/recalled (UnavailableGuard).
* - `creation_failed` — card creation failed, falling back to static delivery.
*/
export type TerminalReason = 'normal' | 'error' | 'abort' | 'unavailable' | 'creation_failed';
export declare const PHASE_TRANSITIONS: Record<CardPhase, ReadonlySet<CardPhase>>;
export interface ReasoningState {
accumulatedReasoningText: string;
reasoningStartTime: number | null;
reasoningElapsedMs: number;
isReasoningPhase: boolean;
}
export interface ToolUseState {
startedAt: number | null;
elapsedMs: number;
isActive: boolean;
}
export interface StreamingTextState {
accumulatedText: string;
completedText: string;
streamingPrefix: string;
lastPartialText: string;
lastFlushedText: string;
}
export interface CardKitState {
cardKitCardId: string | null;
originalCardKitCardId: string | null;
cardKitSequence: number;
cardMessageId: string | null;
}
/**
* Throttle intervals for card updates.
*
* - `CARDKIT_MS`: CardKit `cardElement.content()` — designed for streaming,
* low throttle is fine.
* - `PATCH_MS`: `im.message.patch` — strict rate limits (code 230020).
* - `LONG_GAP_THRESHOLD_MS`: After a long idle gap (tool call / LLM thinking),
* defer the first flush briefly.
* - `BATCH_AFTER_GAP_MS`: Batching window after a long gap.
*/
export declare const THROTTLE_CONSTANTS: {
readonly CARDKIT_MS: 100;
readonly PATCH_MS: 1500;
readonly LONG_GAP_THRESHOLD_MS: 2000;
readonly BATCH_AFTER_GAP_MS: 300;
readonly REASONING_STATUS_MS: 1500;
};
export declare const EMPTY_REPLY_FALLBACK_TEXT = "Done.";
export interface CreateFeishuReplyDispatcherParams {
cfg: ClawdbotConfig;
agentId: string;
sessionKey: string;
chatId: string;
replyToMessageId?: string;
/** Account ID for multi-account support. */
accountId?: string;
/** Chat type for scene-aware reply mode selection. */
chatType?: 'p2p' | 'group';
/** When true, typing indicators are suppressed entirely. */
skipTyping?: boolean;
/** When true, replies are sent into the thread instead of main chat. */
replyInThread?: boolean;
/** Thread root id when the reply lives inside a thread; used for sentinel keying. */
threadId?: string;
toolUseDisplay: ToolUseDisplayConfig;
}
/**
* The structured return type of createFeishuReplyDispatcher.
*
* `replyOptions` is typed as `Record<string, unknown>` because the consumer
* (`dispatchReplyFromConfig`) accepts the SDK-internal `GetReplyOptions`
* which is not re-exported from `openclaw/plugin-sdk`. The record type
* is compatible with spread-assignment into `dispatchReplyFromConfig`.
*/
export interface FeishuReplyDispatcherResult {
dispatcher: ReplyDispatcher;
replyOptions: Record<string, unknown>;
markDispatchIdle: () => void;
markFullyComplete: () => void;
abortCard: () => Promise<void>;
}
export interface FooterSessionMetrics {
inputTokens?: number;
outputTokens?: number;
cacheRead?: number;
cacheWrite?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
contextTokens?: number;
model?: string;
}
export interface StreamingCardDeps {
cfg: ClawdbotConfig;
agentId: string;
sessionKey: string;
accountId: string | undefined;
chatId: string;
replyToMessageId: string | undefined;
replyInThread: boolean | undefined;
toolUseDisplay: ToolUseDisplayConfig;
resolvedFooter: Required<FeishuFooterConfig>;
}
@@ -0,0 +1,61 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Type definitions for the Feishu reply dispatcher subsystem.
*
* Consolidates all interfaces, state shapes, and constants used across
* reply-dispatcher.ts, streaming-card-controller.ts, flush-controller.ts,
* and unavailable-guard.ts.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EMPTY_REPLY_FALLBACK_TEXT = exports.THROTTLE_CONSTANTS = exports.PHASE_TRANSITIONS = exports.TERMINAL_PHASES = exports.CARD_PHASES = void 0;
// ---------------------------------------------------------------------------
// CardPhase — explicit state machine replacing boolean flags
// ---------------------------------------------------------------------------
exports.CARD_PHASES = {
idle: 'idle',
creating: 'creating',
streaming: 'streaming',
completed: 'completed',
aborted: 'aborted',
terminated: 'terminated',
creation_failed: 'creation_failed',
};
exports.TERMINAL_PHASES = new Set([
'completed',
'aborted',
'terminated',
'creation_failed',
]);
exports.PHASE_TRANSITIONS = {
idle: new Set(['creating', 'aborted', 'terminated']),
creating: new Set(['streaming', 'creation_failed', 'aborted', 'terminated']),
streaming: new Set(['completed', 'aborted', 'terminated']),
completed: new Set(),
aborted: new Set(),
terminated: new Set(),
creation_failed: new Set(),
};
// ---------------------------------------------------------------------------
// Throttle constants
// ---------------------------------------------------------------------------
/**
* Throttle intervals for card updates.
*
* - `CARDKIT_MS`: CardKit `cardElement.content()` — designed for streaming,
* low throttle is fine.
* - `PATCH_MS`: `im.message.patch` — strict rate limits (code 230020).
* - `LONG_GAP_THRESHOLD_MS`: After a long idle gap (tool call / LLM thinking),
* defer the first flush briefly.
* - `BATCH_AFTER_GAP_MS`: Batching window after a long gap.
*/
exports.THROTTLE_CONSTANTS = {
CARDKIT_MS: 100,
PATCH_MS: 1500,
LONG_GAP_THRESHOLD_MS: 2000,
BATCH_AFTER_GAP_MS: 300,
REASONING_STATUS_MS: 1500,
};
exports.EMPTY_REPLY_FALLBACK_TEXT = 'Done.';
+15
View File
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reply dispatcher factory for the Lark/Feishu channel plugin.
*
* Thin factory function that:
* 1. Resolves account, reply mode, and typing indicator config
* 2. In streaming mode, delegates to StreamingCardController
* 3. In static mode, delivers via sendMessageFeishu / sendMarkdownCardFeishu
* 4. Assembles and returns FeishuReplyDispatcherResult
*/
import type { CreateFeishuReplyDispatcherParams, FeishuReplyDispatcherResult } from './reply-dispatcher-types';
export type { CreateFeishuReplyDispatcherParams } from './reply-dispatcher-types';
export declare function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams): FeishuReplyDispatcherResult;
@@ -0,0 +1,455 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reply dispatcher factory for the Lark/Feishu channel plugin.
*
* Thin factory function that:
* 1. Resolves account, reply mode, and typing indicator config
* 2. In streaming mode, delegates to StreamingCardController
* 3. In static mode, delivers via sendMessageFeishu / sendMarkdownCardFeishu
* 4. Assembles and returns FeishuReplyDispatcherResult
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createFeishuReplyDispatcher = createFeishuReplyDispatcher;
const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
const channel_feedback_1 = require("openclaw/plugin-sdk/channel-feedback");
const accounts_1 = require("../core/accounts.js");
const footer_config_1 = require("../core/footer-config.js");
const lark_client_1 = require("../core/lark-client.js");
const lark_logger_1 = require("../core/lark-logger.js");
const deliver_1 = require("../messaging/outbound/deliver.js");
const send_1 = require("../messaging/outbound/send.js");
const typing_1 = require("../messaging/outbound/typing.js");
const builder_1 = require("./builder.js");
const card_error_1 = require("./card-error.js");
const reply_mode_1 = require("./reply-mode.js");
const streaming_card_controller_1 = require("./streaming-card-controller.js");
const unavailable_guard_1 = require("./unavailable-guard.js");
const log = (0, lark_logger_1.larkLogger)('card/reply-dispatcher');
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function createFeishuReplyDispatcher(params) {
const core = lark_client_1.LarkClient.runtime;
const { cfg, agentId, chatId, sessionKey, replyToMessageId, accountId, replyInThread, threadId } = params;
// Resolve account so we can read per-account config (e.g. replyMode)
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const feishuCfg = account.config;
// accountScopedCfg 用于需要 account-level 覆盖的配置项(如 tableMode
const accountScopedCfg = (0, accounts_1.createAccountScopedConfig)(cfg, account.accountId);
const prefixContext = (0, channel_runtime_1.createReplyPrefixContext)({ cfg, agentId });
// ---- Reply mode resolution ----
const chatType = params.chatType;
const effectiveReplyMode = (0, reply_mode_1.resolveReplyMode)({ feishuCfg, chatType });
const replyMode = (0, reply_mode_1.expandAutoMode)({
mode: effectiveReplyMode,
streaming: feishuCfg?.streaming,
chatType,
});
const useStreamingCards = replyMode === 'streaming';
// ---- Block streaming for static mode ----
const enableBlockStreaming = feishuCfg?.blockStreaming === true && !useStreamingCards;
const { toolUseDisplay } = params;
const resolvedFooter = (0, footer_config_1.resolveFooterConfig)(feishuCfg?.footer);
log.info('reply mode resolved', {
effectiveReplyMode,
replyMode,
chatType,
});
log.info('footer config resolved', {
accountId: account.accountId,
sessionKey,
chatType,
useStreamingCards,
rawFooter: feishuCfg?.footer ?? null,
resolvedFooter,
});
// ---- Chunk & render settings (static mode only) ----
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, 'feishu', accountId, { fallbackLimit: 4000 });
const chunkMode = core.channel.text.resolveChunkMode(cfg, 'feishu');
// 使用 accountScopedCfg 以支持 per-account tableMode 覆盖
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: accountScopedCfg,
channel: 'feishu',
});
// ---- Streaming card controller (instantiated only when needed) ----
const controller = useStreamingCards
? new streaming_card_controller_1.StreamingCardController({
cfg,
agentId,
sessionKey,
accountId,
chatId,
replyToMessageId,
replyInThread,
toolUseDisplay,
resolvedFooter,
})
: null;
// ---- Static mode unavailable guard ----
// In streaming mode the controller owns its own guard; in static mode
// we still need unavailable-message detection for typing and deliver.
let staticAborted = false;
const staticGuard = controller
? null
: new unavailable_guard_1.UnavailableGuard({
replyToMessageId,
getCardMessageId: () => null,
onTerminate: () => {
staticAborted = true;
},
});
const shouldSkip = (source) => {
if (controller)
return controller.shouldSkipForUnavailable(source);
return staticGuard?.shouldSkip(source) ?? false;
};
const isTerminated = () => {
if (controller)
return controller.isTerminated;
return staticGuard?.isTerminated ?? false;
};
// ---- Typing indicator (reaction-based) ----
let typingState = null;
let typingStopped = false;
const typingCallbacks = (0, channel_runtime_1.createTypingCallbacks)({
keepaliveIntervalMs: 0,
start: async () => {
if (shouldSkip('typing.start.precheck'))
return;
if (!replyToMessageId || typingStopped || params.skipTyping)
return;
if (typingState?.reactionId)
return;
typingState = await (0, typing_1.addTypingIndicator)({
cfg,
messageId: replyToMessageId,
accountId,
});
if (shouldSkip('typing.start.postcheck'))
return;
if (typingStopped && typingState) {
await (0, typing_1.removeTypingIndicator)({ cfg, state: typingState, accountId });
typingState = null;
log.info('removed typing indicator (raced with stop)');
return;
}
log.info('added typing indicator reaction');
},
stop: async () => {
typingStopped = true;
if (!typingState)
return;
await (0, typing_1.removeTypingIndicator)({ cfg, state: typingState, accountId });
typingState = null;
log.info('removed typing indicator reaction');
},
onStartError: (err) => {
(0, channel_feedback_1.logTypingFailure)({
log: (message) => log.warn(message),
channel: 'feishu',
action: 'start',
error: err,
});
},
onStopError: (err) => {
(0, channel_feedback_1.logTypingFailure)({
log: (message) => log.warn(message),
channel: 'feishu',
action: 'stop',
error: err,
});
},
});
// ---- dispatchFullyComplete flag (static mode) ----
let dispatchFullyComplete = false;
// ---- Build dispatcher ----
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
onReplyStart: async () => {
if (shouldSkip('onReplyStart'))
return;
await typingCallbacks.onReplyStart?.();
},
deliver: async (payload, meta) => {
log.debug('deliver called', {
textPreview: payload.text?.slice(0, 100),
kind: meta?.kind,
});
if (shouldSkip('deliver.entry'))
return;
// ---- Abort guard ----
// Only check aborted (not isTerminalPhase) so that
// creation_failed can still fallthrough to static delivery.
if (staticAborted || controller?.isTerminated || controller?.isAborted) {
log.debug('deliver: skipped (aborted)');
return;
}
// ---- Post-dispatch guard ----
if (dispatchFullyComplete) {
log.debug('deliver: skipped (dispatch already complete)');
return;
}
// 提取文本和媒体 URL
const text = getVisiblePayloadText(payload);
const reasoningText = payload.isReasoning === true ? (payload.text ?? '') : '';
const payloadMediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (!text.trim() && !reasoningText.trim() && payloadMediaUrls.length === 0) {
log.debug('deliver: empty text and no media, skipping');
return;
}
// ---- Streaming card mode ----
if (controller) {
if (meta?.kind === 'tool' && shouldRouteToolPayloadToCard(payload, toolUseDisplay.showToolUse)) {
await controller.onToolPayload(payload);
return;
}
const controllerText = reasoningText.trim() ? reasoningText : text;
if (controllerText.trim()) {
await controller.ensureCardCreated();
if (controller.isTerminated)
return;
if (controller.cardMessageId) {
if (payload.isReasoning === true) {
await controller.onReasoningStream({ ...payload, text: controllerText });
return;
}
await controller.onDeliver({ ...payload, text: controllerText });
return;
}
// Card creation failed — fall through to static delivery
log.warn('deliver: card creation failed, falling back to static delivery');
}
}
// ---- Static text delivery ----
if (text.trim()) {
if ((0, reply_mode_1.shouldUseCard)(text)) {
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
log.info('deliver: sending card chunks', {
count: chunks.length,
chatId,
});
// Runtime fallback: shouldUseCard() 通过但 API 仍拒绝(表格数超限)
let cardTableLimitHit = false;
for (const chunk of chunks) {
if (cardTableLimitHit) {
// 已触发降级,后续 chunk 直接走纯文本
try {
await (0, send_1.sendMessageFeishu)({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
replyInThread,
accountId,
threadId,
});
}
catch (fallbackErr) {
if (staticGuard?.terminate('deliver.textFallback', fallbackErr))
return;
throw fallbackErr;
}
continue;
}
try {
await (0, send_1.sendMarkdownCardFeishu)({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
replyInThread,
accountId,
});
}
catch (err) {
if (staticGuard?.terminate('deliver.cardChunk', err))
return;
// 卡片表格数超出飞书限制 — 降级为纯文本
if ((0, card_error_1.isCardTableLimitError)(err)) {
log.warn('card table limit exceeded (230099/11310), falling back to text', { chatId });
cardTableLimitHit = true;
try {
await (0, send_1.sendMessageFeishu)({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
replyInThread,
accountId,
threadId,
});
}
catch (fallbackErr) {
if (staticGuard?.terminate('deliver.textFallback', fallbackErr))
return;
throw fallbackErr;
}
continue;
}
throw err;
}
}
}
else {
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
log.info('deliver: sending text chunks', {
count: chunks.length,
chatId,
});
for (const chunk of chunks) {
try {
await (0, send_1.sendMessageFeishu)({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
replyInThread,
accountId,
threadId,
});
}
catch (err) {
if (staticGuard?.terminate('deliver.textChunk', err))
return;
throw err;
}
}
}
}
// ---- Static media delivery ----
for (const mediaUrl of payloadMediaUrls) {
if (!mediaUrl?.trim())
continue;
try {
log.info('deliver: sending media via static path', {
mediaUrl: mediaUrl.slice(0, 80),
});
await (0, deliver_1.sendMediaLark)({
cfg,
to: chatId,
mediaUrl,
accountId,
replyToMessageId,
replyInThread,
});
}
catch (mediaErr) {
if (staticGuard?.terminate('deliver.media', mediaErr))
return;
log.error('deliver: static media send failed', {
error: String(mediaErr),
});
}
}
},
onError: async (err, info) => {
if (controller) {
if (controller.terminateIfUnavailable('onError', err)) {
typingCallbacks.onIdle?.();
return;
}
await controller.onError(err, info);
typingCallbacks.onIdle?.();
return;
}
// Static mode error handling
if (staticGuard?.terminate('onError', err)) {
typingCallbacks.onIdle?.();
return;
}
log.error(`${info.kind} reply failed`, { error: String(err) });
typingCallbacks.onIdle?.();
},
onIdle: async () => {
if (isTerminated() || shouldSkip('onIdle')) {
typingCallbacks.onIdle?.();
return;
}
if (!dispatchFullyComplete) {
typingCallbacks.onIdle?.();
return;
}
if (controller) {
await controller.onIdle();
}
typingCallbacks.onIdle?.();
},
onCleanup: async () => {
typingCallbacks.onCleanup?.();
},
});
// ---- Abort card (delegates to controller or no-op for static) ----
const abortCard = controller ? () => controller.abortCard() : async () => { };
return {
dispatcher,
replyOptions: {
...replyOptions,
...(controller
? {
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
}
: {}),
onModelSelected: (ctx) => {
prefixContext.onModelSelected(ctx);
},
disableBlockStreaming: !enableBlockStreaming,
...(controller
? {
onReasoningStream: (payload) => controller.onReasoningStream(payload),
onPartialReply: (payload) => controller.onPartialReply(payload),
onToolStart: (payload) => controller.onToolStart(payload),
}
: {}),
},
markDispatchIdle,
markFullyComplete: () => {
dispatchFullyComplete = true;
controller?.markFullyComplete();
},
abortCard,
};
}
function getVisiblePayloadText(payload) {
if (payload.isReasoning === true)
return '';
const rawText = payload.text ?? '';
if (!rawText)
return '';
const split = (0, builder_1.splitReasoningText)(rawText);
if (split.answerText != null) {
return split.answerText;
}
return (0, builder_1.stripReasoningTags)(rawText);
}
function shouldRouteToolPayloadToCard(payload, showToolUse) {
if (!showToolUse)
return false;
if (!getVisiblePayloadText(payload).trim())
return false;
if (payload.interactive)
return false;
if (payload.btw)
return false;
if (payload.audioAsVoice)
return false;
if (payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0)
return false;
const execApproval = payload.channelData && typeof payload.channelData === 'object' && !Array.isArray(payload.channelData)
? payload.channelData.execApproval
: undefined;
if (execApproval && typeof execApproval === 'object' && !Array.isArray(execApproval)) {
return false;
}
return true;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Pure functions for resolving the Feishu reply mode.
*
* Extracted from reply-dispatcher.ts to enable independent testing
* and eliminate `as any` casts on FeishuConfig.
*/
import type { FeishuConfig } from '../core/types';
type ReplyModeValue = 'auto' | 'static' | 'streaming';
/**
* Resolve the effective reply mode based on configuration and chat type.
*
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
*/
export declare function resolveReplyMode(params: {
feishuCfg: FeishuConfig | undefined;
chatType?: 'p2p' | 'group';
}): ReplyModeValue;
/**
* Expand "auto" mode to a concrete mode based on streaming flag and chat type.
*
* When streaming === true: group → static, direct → streaming (legacy behavior).
* When streaming is unset: always static (new default).
*/
export declare function expandAutoMode(params: {
mode: ReplyModeValue;
streaming: boolean | undefined;
chatType?: 'p2p' | 'group';
}): 'static' | 'streaming';
/**
* Detect whether the text contains markdown elements that benefit from
* being rendered inside a Feishu interactive card (fenced code blocks or
* markdown tables).
*/
export declare function shouldUseCard(text: string): boolean;
export {};
@@ -0,0 +1,79 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Pure functions for resolving the Feishu reply mode.
*
* Extracted from reply-dispatcher.ts to enable independent testing
* and eliminate `as any` casts on FeishuConfig.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveReplyMode = resolveReplyMode;
exports.expandAutoMode = expandAutoMode;
exports.shouldUseCard = shouldUseCard;
const card_error_1 = require("./card-error.js");
// ---------------------------------------------------------------------------
// resolveReplyMode
// ---------------------------------------------------------------------------
/**
* Resolve the effective reply mode based on configuration and chat type.
*
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
*/
function resolveReplyMode(params) {
const { feishuCfg, chatType } = params;
// streaming 布尔总开关:仅 true 时允许流式,未设置或 false 一律 static
if (feishuCfg?.streaming !== true)
return 'static';
const replyMode = feishuCfg?.replyMode;
if (!replyMode)
return 'auto';
if (typeof replyMode === 'string')
return replyMode;
// Object form: pick scene-specific value
const sceneMode = chatType === 'group' ? replyMode.group : chatType === 'p2p' ? replyMode.direct : undefined;
return sceneMode ?? replyMode.default ?? 'auto';
}
// ---------------------------------------------------------------------------
// expandAutoMode
// ---------------------------------------------------------------------------
/**
* Expand "auto" mode to a concrete mode based on streaming flag and chat type.
*
* When streaming === true: group → static, direct → streaming (legacy behavior).
* When streaming is unset: always static (new default).
*/
function expandAutoMode(params) {
const { mode, streaming, chatType } = params;
if (mode !== 'auto')
return mode;
return streaming === true ? (chatType === 'group' ? 'static' : 'streaming') : 'static';
}
// ---------------------------------------------------------------------------
// shouldUseCard
// ---------------------------------------------------------------------------
/**
* Detect whether the text contains markdown elements that benefit from
* being rendered inside a Feishu interactive card (fenced code blocks or
* markdown tables).
*/
function shouldUseCard(text) {
// Markdown tables NO LONGER force a card. Feishu messages render markdown
// tables natively, and wrapping a reply in a card breaks bot-at-bot @
// delivery (cards have limited @ support). Only fenced code blocks still
// benefit from card rendering.
//
// The table-count guard is kept as a safety valve: when a reply also
// contains an excessive number of markdown tables, skip the card entirely
// rather than risk a card-render failure.
const tableMatches = (0, card_error_1.findMarkdownTablesOutsideCodeBlocks)(text);
if (tableMatches.length > card_error_1.FEISHU_CARD_TABLE_LIMIT) {
return false;
}
// Fenced code blocks
if (/```[\s\S]*?```/.test(text)) {
return true;
}
return false;
}
@@ -0,0 +1,118 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Streaming card controller for the Lark/Feishu channel plugin.
*
* Manages the full lifecycle of a streaming CardKit card:
* idle → creating → streaming → completed / aborted / terminated.
*
* Delegates throttling to FlushController and message-unavailable
* detection to UnavailableGuard.
*/
import type { ReplyPayload } from 'openclaw/plugin-sdk';
import type { CardPhase, StreamingCardDeps, TerminalReason } from './reply-dispatcher-types';
interface TerminalCardTextImageResolver {
resolveImages(text: string): string;
}
interface TerminalCardContentInput {
text: string;
reasoningText?: string;
}
export declare class StreamingCardController {
private phase;
private cardKit;
private text;
private reasoning;
private toolUse;
private readonly flush;
private readonly guard;
private readonly imageResolver;
private createEpoch;
private _terminalReason;
private dispatchFullyComplete;
private cardCreationPromise;
private disposeShutdownHook;
private readonly dispatchStartTime;
private readonly deps;
private elapsed;
private needsFooterMetrics;
private getFooterSessionMetrics;
constructor(deps: StreamingCardDeps);
get cardMessageId(): string | null;
get isTerminalPhase(): boolean;
/**
* Whether the card has been explicitly aborted (via abortCard()).
*
* Distinct from isTerminalPhase — creation_failed is NOT an abort;
* it should allow fallthrough to static delivery in the factory.
*/
get isAborted(): boolean;
/** Whether the reply pipeline was terminated due to an unavailable message. */
get isTerminated(): boolean;
/** Check if the pipeline should skip further operations for this source. */
shouldSkipForUnavailable(source: string): boolean;
/** Attempt to terminate the pipeline due to an unavailable message error. */
terminateIfUnavailable(source: string, err?: unknown): boolean;
/** Why the controller entered a terminal phase, or null if still active. */
get terminalReason(): TerminalReason | null;
/** @internal — exposed for test assertions only. */
get currentPhase(): CardPhase;
private get shouldDisplayToolUse();
private computeToolUseDisplay;
private get visibleToolUseElapsedMs();
private computeToolUseTitleSuffix;
/**
* Unified callback guard — returns true if the pipeline is active
* and the callback should proceed.
*
* Combines three checks:
* 1. guard.isTerminated — message recalled/deleted
* 2. guard.shouldSkip(source) — eagerly detect unavailable messages
* 3. isTerminalPhase — completed/aborted/terminated/creation_failed
*/
private shouldProceed;
private isStaleCreate;
private transition;
private onEnterTerminalPhase;
private markToolUseActivity;
private captureToolUseElapsed;
/**
* Handle a deliver() call in streaming card mode.
*
* Accumulates text from the SDK's deliver callbacks to build the
* authoritative "completedText" for the final card.
*/
onDeliver(payload: ReplyPayload): Promise<void>;
onReasoningStream(payload: ReplyPayload): Promise<void>;
onToolStart(payload: {
name?: string;
phase?: string;
}): Promise<void>;
onToolPayload(_payload: ReplyPayload): Promise<void>;
onPartialReply(payload: ReplyPayload): Promise<void>;
onError(err: unknown, info: {
kind: string;
}): Promise<void>;
onIdle(): Promise<void>;
markFullyComplete(): void;
abortCard(): Promise<void>;
ensureCardCreated(): Promise<void>;
private performFlush;
private buildDisplayText;
private throttledCardUpdate;
private lastToolUseStatusUpdateTime;
private throttledToolUseStatusUpdate;
private updateToolUseStatus;
private finalizeCard;
/**
* Close streaming mode then update card content (shared by onError and abortCard).
*/
private closeStreamingAndUpdate;
}
/**
* 终态卡片的正文和 reasoning 都会被飞书按 markdown 渲染,
* 因此两者都要先做图片替换与表格降级,避免再次撞到 230099/11310。
*/
export declare function prepareTerminalCardContent(content: TerminalCardContentInput, imageResolver: TerminalCardTextImageResolver, tableLimit?: number): TerminalCardContentInput;
export {};
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Resolution logic for Feishu tool-use display.
*
* The source of truth is OpenClaw's effective verbose state:
* inline `/verbose` override > session store override > config default.
* Feishu channel config only retains UI-level detail (`showFullPaths`).
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuConfig } from '../core/types';
export type ToolUseMode = 'off' | 'on' | 'full';
export interface ToolUseDisplayConfig {
mode: ToolUseMode;
showToolUse: boolean;
showToolResultDetails: boolean;
showFullPaths: boolean;
}
export declare function resolveToolUseDisplayConfig(params: {
cfg: ClawdbotConfig;
feishuCfg: FeishuConfig | undefined;
agentId: string;
sessionKey: string;
body?: string;
}): ToolUseDisplayConfig;
@@ -0,0 +1,76 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Resolution logic for Feishu tool-use display.
*
* The source of truth is OpenClaw's effective verbose state:
* inline `/verbose` override > session store override > config default.
* Feishu channel config only retains UI-level detail (`showFullPaths`).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveToolUseDisplayConfig = resolveToolUseDisplayConfig;
const agent_runtime_1 = require("openclaw/plugin-sdk/agent-runtime");
const config_runtime_1 = require("openclaw/plugin-sdk/config-runtime");
function resolveToolUseDisplayConfig(params) {
const mode = resolveEffectiveVerboseMode(params);
return {
mode,
showToolUse: mode !== 'off',
showToolResultDetails: mode === 'full',
showFullPaths: params.feishuCfg?.toolUseDisplay?.showFullPaths === true,
};
}
function resolveEffectiveVerboseMode(params) {
return (extractInlineVerboseMode(params.body) ??
resolveSessionVerboseMode(params.cfg, params.sessionKey, params.agentId) ??
normalizeToolUseMode(params.cfg.agents?.defaults?.verboseDefault) ??
'off');
}
function resolveSessionVerboseMode(cfg, sessionKey, agentId) {
try {
const cfgWithSession = cfg;
const sessionStorePath = cfgWithSession.session?.store ?? cfgWithSession.sessions?.store;
const storePath = (0, config_runtime_1.resolveStorePath)(sessionStorePath, { agentId });
const store = (0, config_runtime_1.loadSessionStore)(storePath);
const candidateKeys = resolveCandidateSessionKeys(cfg, sessionKey);
for (const candidateKey of candidateKeys) {
const resolved = (0, config_runtime_1.resolveSessionStoreEntry)({ store, sessionKey: candidateKey });
const mode = normalizeToolUseMode(resolved.existing?.verboseLevel);
if (mode)
return mode;
if (resolved.existing)
return undefined;
}
return undefined;
}
catch {
return undefined;
}
}
function resolveCandidateSessionKeys(cfg, sessionKey) {
const key = sessionKey.trim().toLowerCase();
const defaultAgentId = (0, agent_runtime_1.resolveDefaultAgentId)(cfg);
const fallbackKey = key.replace(/^(agent):[^:]+:/, `$1:${defaultAgentId}:`);
return fallbackKey !== key ? [key, fallbackKey] : [key];
}
function extractInlineVerboseMode(body) {
if (!body)
return undefined;
const matches = body.matchAll(/(?:^|\s)\/(?:verbose|v)(?::|\s+)(on|off|full)\b/gi);
let last;
for (const match of matches) {
last = normalizeToolUseMode(match[1]);
}
return last;
}
function normalizeToolUseMode(value) {
if (typeof value !== 'string')
return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'off' || normalized === 'on' || normalized === 'full') {
return normalized;
}
return undefined;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Structured tool-use display for Lark/Feishu cards.
*/
import type { ToolUseTraceStep } from './tool-use-trace-store';
export type ToolUseStepStatus = ToolUseTraceStep['status'];
export interface ToolUseDisplayBlock {
language: 'json' | 'text';
content: string;
}
export interface ToolUseDisplayStep {
title: string;
detail?: string;
iconToken: string;
status: ToolUseStepStatus;
resultBlock?: ToolUseDisplayBlock;
errorBlock?: ToolUseDisplayBlock;
}
export interface ToolUseDisplayResult {
content: string;
stepCount: number;
steps: ToolUseDisplayStep[];
}
export declare const EMPTY_TOOL_USE_PLACEHOLDER = "No tool steps available";
export declare function normalizeToolUseDisplay(params: {
traceSteps?: ToolUseTraceStep[];
showFullPaths?: boolean;
showResultDetails?: boolean;
}): ToolUseDisplayResult;
export declare function buildToolUseTitleSuffix(params: {
stepCount: number;
}): {
zh: string;
en: string;
};
@@ -0,0 +1,476 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Structured tool-use display for Lark/Feishu cards.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EMPTY_TOOL_USE_PLACEHOLDER = void 0;
exports.normalizeToolUseDisplay = normalizeToolUseDisplay;
exports.buildToolUseTitleSuffix = buildToolUseTitleSuffix;
const reasoning_utils_1 = require("./reasoning-utils.js");
exports.EMPTY_TOOL_USE_PLACEHOLDER = 'No tool steps available';
const DEFAULT_SUMMARY_PREFERENCE = ['matched', 'code', 'quoted', 'url', 'line'];
const TOOL_DESCRIPTORS = [
{
aliases: ['skill'],
iconToken: 'app-default_outlined',
title: 'Load skill',
sanitizer: 'skill',
paramKeys: ['skill', 'name'],
summaryPatterns: [/^(?:load|use)\s+skill\s+(.+)$/i],
},
{
aliases: ['read', 'open'],
iconToken: 'file-link-text_outlined',
title: 'Read',
sanitizer: 'path',
paramKeys: ['file_path', 'path', 'file'],
summaryPatterns: [/^(?:read|open)\s+(?:file\s+)?(.+)$/i],
summaryPreference: ['code', 'quoted', 'matched', 'line'],
},
{
aliases: ['write', 'edit'],
iconToken: 'edit_outlined',
title: 'Edit',
sanitizer: 'path',
paramKeys: ['file_path', 'path', 'file'],
summaryPatterns: [/^(?:edit|write)\s+(?:file\s+)?(.+)$/i],
summaryPreference: ['code', 'quoted', 'matched', 'line'],
},
{
aliases: ['web_search', 'web-search', 'search'],
iconToken: 'search_outlined',
title: 'Search web',
sanitizer: 'search',
paramKeys: ['query', 'q'],
summaryPatterns: [/^(?:search\s+(?:web\s+)?(?:for|about)|query)\s+(.+)$/i],
summaryPreference: ['quoted', 'matched', 'line'],
},
{
aliases: ['web_fetch', 'web-fetch', 'fetch'],
iconToken: 'language_outlined',
title: 'Fetch web page',
sanitizer: 'url',
paramKeys: ['url'],
summaryPatterns: [/^(?:fetch|open)\s+(?:web\s+page\s+)?(?:from\s+)?(.+)$/i],
summaryPreference: ['url', 'matched', 'quoted', 'line'],
},
{
aliases: ['grep'],
iconToken: 'doc-search_outlined',
title: 'Search text',
sanitizer: 'generic',
detailFromParams: (params) => buildPatternDetail(params, { includeTarget: true }),
summaryPatterns: [/^(?:search\s+text(?:\s+by\s+pattern)?|grep)\s+(.+)$/i],
},
{
aliases: ['glob'],
iconToken: 'folder_outlined',
title: 'Search files',
sanitizer: 'generic',
paramKeys: ['pattern'],
summaryPatterns: [/^(?:search\s+files(?:\s+by\s+pattern)?|glob)\s+(.+)$/i],
},
{
aliases: ['exec', 'bash', 'command', 'run'],
iconToken: 'setting_outlined',
title: 'Run command',
sanitizer: 'command',
paramKeys: ['description', 'command', 'script'],
summaryPatterns: [/^(?:run|execute)\s+(?:command|script)?\s*(.+)$/i],
summaryPreference: ['code', 'quoted', 'matched', 'line'],
},
{
aliases: ['browser', 'playwright', 'navigate'],
iconToken: 'browser-mac_outlined',
title: 'Browser',
sanitizer: 'url',
paramKeys: ['url'],
summaryPatterns: [/^(?:open|browse|visit|navigate\s+to)\s+(.+)$/i],
summaryPreference: ['url', 'quoted', 'matched', 'line'],
},
{
aliases: ['agent', 'task', 'spawn'],
iconToken: 'robot_outlined',
title: 'Run sub-agent',
sanitizer: 'generic',
paramKeys: ['task', 'description', 'prompt'],
summaryPatterns: [/^(?:run\s+sub-?agent|spawn\s+agent)\s+(.+)$/i],
},
{
aliases: ['check', 'determine', 'verify'],
iconToken: 'list-check_outlined',
title: 'Check',
sanitizer: 'generic',
paramKeys: ['target', 'subject', 'description'],
},
{
aliases: ['summarize', 'analyze', 'prepare'],
iconToken: 'report_outlined',
title: 'Analyze',
sanitizer: 'generic',
paramKeys: ['target', 'subject', 'description'],
},
];
function normalizeToolUseDisplay(params) {
const traceSteps = params.traceSteps ?? [];
const showFullPaths = params.showFullPaths === true;
const showResultDetails = params.showResultDetails === true;
const sources = traceSteps.map(toTraceSource);
const steps = sources
.map((source) => formatToolStep(source, { showFullPaths, showResultDetails }))
.filter((step) => !!step);
return {
content: steps.map((step) => (step.detail ? `- ${step.title}: ${step.detail}` : `- ${step.title}`)).join('\n'),
stepCount: steps.length,
steps,
};
}
function buildToolUseTitleSuffix(params) {
const { stepCount } = params;
return {
zh: `查看 ${stepCount} 个步骤`,
en: `Show ${stepCount} step${stepCount === 1 ? '' : 's'}`,
};
}
function toTraceSource(step) {
return {
toolName: step.toolName,
params: step.params,
result: step.result,
error: step.error,
durationMs: step.durationMs,
status: step.status,
};
}
function formatToolStep(source, options) {
const descriptor = resolveToolDescriptor(source.toolName);
const rawDetail = (descriptor ? extractDetailFromParams(source.params, descriptor) : undefined) ??
(descriptor ? extractDetailFromSummary(source.summaryText, descriptor) : cleanupLine(source.summaryText ?? '')) ??
undefined;
const detail = rawDetail ? sanitizeToolDetail(descriptor?.sanitizer ?? 'generic', rawDetail, options) : undefined;
const title = buildToolTitle(source, descriptor, rawDetail);
const status = resolveStepStatus(source);
const errorBlock = source.error ? buildErrorBlock(source.error, descriptor) : undefined;
const resultBlock = !errorBlock && options.showResultDetails ? buildResultBlock(source, descriptor) : undefined;
return {
title,
detail,
iconToken: descriptor?.iconToken ?? 'setting-inter_outlined',
status,
resultBlock,
errorBlock,
};
}
function buildToolTitle(source, descriptor, rawDetail) {
const baseTitle = descriptor?.title === 'Read' && rawDetail && isSkillPathValue(rawDetail)
? 'Skill Read'
: (descriptor?.title ?? humanizeToolName(source.toolName ?? 'tool'));
const durationLabel = source.durationMs != null ? formatDurationLabel(source.durationMs) : undefined;
return durationLabel ? `${baseTitle} (${durationLabel})` : baseTitle;
}
function resolveToolDescriptor(toolName) {
const normalizedName = (0, reasoning_utils_1.normalizeToolName)(toolName);
return TOOL_DESCRIPTORS.find((descriptor) => descriptor.aliases.some((alias) => normalizedName === alias || normalizedName.startsWith(`${alias}_`) || normalizedName.startsWith(`${alias}-`)));
}
function extractDetailFromParams(params, descriptor) {
if (!params)
return undefined;
if (descriptor.detailFromParams)
return descriptor.detailFromParams(params);
for (const key of descriptor.paramKeys ?? []) {
const value = params[key];
const text = extractScalarText(value);
if (text)
return text;
}
return undefined;
}
function extractDetailFromSummary(summaryText, descriptor) {
if (!summaryText)
return undefined;
const lines = summaryText
.replace(/\r\n/g, '\n')
.split('\n')
.map((line) => cleanupLine(stripMarkdown(line)))
.filter((line) => line && !isNoiseLine(line));
for (const line of lines) {
const signals = buildSummarySignals(line, descriptor.summaryPatterns ?? []);
const detail = pickSummaryDetail(signals, descriptor.summaryPreference ?? DEFAULT_SUMMARY_PREFERENCE);
if (detail)
return detail;
}
return undefined;
}
function buildSummarySignals(line, patterns) {
const matched = patterns
.map((pattern) => line.match(pattern)?.[1]?.trim())
.find((value) => Boolean(value));
return {
line,
matched,
code: extractFirstCodeSpan(line),
quoted: extractFirstQuotedText(line),
url: extractFirstUrl(line),
};
}
function pickSummaryDetail(signals, preference) {
for (const key of preference) {
const value = signals[key];
if (value)
return value;
}
return undefined;
}
function buildResultBlock(source, descriptor) {
if (source.result == null)
return undefined;
if (descriptor && ['Read', 'Edit', 'Fetch web page', 'Browser'].includes(descriptor.title)) {
return undefined;
}
return buildDisplayBlock(sanitizeDisplayBlockValue(source.result, descriptor));
}
function buildErrorBlock(error, descriptor) {
return buildDisplayBlock(sanitizeDisplayBlockValue(error, descriptor), 'text');
}
function sanitizeDisplayBlockValue(value, descriptor) {
if (descriptor?.sanitizer === 'command' && typeof value === 'string') {
return (0, reasoning_utils_1.redactInlineSecrets)(value);
}
return value;
}
function buildPatternDetail(params, options) {
const pattern = extractScalarText(params.pattern);
const target = extractScalarText(params.glob ?? params.path ?? params.file_path);
if (pattern && target && options.includeTarget) {
return `${pattern} in ${target}`;
}
return pattern ?? target ?? undefined;
}
function extractScalarText(value) {
if (typeof value === 'string')
return value.trim() || undefined;
if (typeof value === 'number' || typeof value === 'boolean')
return String(value);
return undefined;
}
function sanitizeToolDetail(kind, value, options) {
if (kind === 'command') {
const cleaned = normalizeInlineDisplayText(value);
if (!cleaned)
return undefined;
return sanitizeCommandLike(cleaned, options);
}
const cleaned = sanitizeGenericText(value);
if (!cleaned)
return undefined;
switch (kind) {
case 'skill':
return (cleaned
.replace(/^skill\s+/i, '')
.replace(/[-_]+/g, ' ')
.trim() || 'skill');
case 'path':
return sanitizePathLike(cleaned, options);
case 'search':
return stripQuotes(cleaned);
case 'url':
return stripQuotes(cleaned).replace(/^from\s+/i, '');
case 'generic':
default:
return cleaned;
}
}
function normalizeInlineDisplayText(value) {
return value.replace(/\s+/g, ' ').trim();
}
function sanitizePathLike(value, options) {
const cleaned = sanitizeGenericText(value)
.replace(/^(?:from|file|path)\s+/i, '')
.trim();
if (options.showFullPaths)
return cleaned;
const skillMatch = cleaned.match(/(?:^|\/)skills\/([^/]+)\//i);
if (skillMatch?.[1]) {
return skillMatch[1].replace(/[-_]+/g, ' ').trim() || cleaned;
}
const segments = cleaned.split(/[\\/]/).filter(Boolean);
return segments.at(-1) ?? cleaned;
}
function sanitizeCommandLike(value, options) {
const cleaned = stripQuotes(value)
.replace(/^(?:command|script|description)\s+/i, '')
.replace(/^.*?\s+->\s+/i, '')
.trim();
if (!cleaned)
return 'command';
const redacted = (0, reasoning_utils_1.redactInlineSecrets)(cleaned);
return options.showFullPaths ? redacted : redactCommandPaths(redacted);
}
function resolveStepStatus(source) {
if (source.error)
return 'error';
if (source.status)
return source.status;
return 'success';
}
function buildDisplayBlock(value, fallbackLanguage = 'json') {
if (value == null)
return undefined;
if (typeof value === 'string') {
const normalized = value.replace(/\r\n/g, '\n').trim();
if (!normalized)
return undefined;
const parsed = tryParseJson(normalized);
if (parsed && typeof parsed === 'object') {
const prettyJson = stringifyJson(parsed);
if (prettyJson) {
return { language: 'json', content: prettyJson };
}
}
return { language: fallbackLanguage === 'json' ? 'text' : fallbackLanguage, content: normalized };
}
if (typeof value === 'object') {
const prettyJson = stringifyJson(value);
if (prettyJson) {
return { language: 'json', content: prettyJson };
}
}
const normalized = String(value).trim();
return normalized ? { language: 'text', content: normalized } : undefined;
}
function stringifyJson(value) {
try {
return JSON.stringify(value, null, 2);
}
catch {
return undefined;
}
}
function tryParseJson(value) {
const trimmed = value.trim();
if (!trimmed || !/^(?:\{|\[)/.test(trimmed)) {
return undefined;
}
try {
return JSON.parse(trimmed);
}
catch {
return undefined;
}
}
function redactCommandPaths(command) {
return command
.split(/(\s+)/)
.map((segment) => {
if (!segment || /^\s+$/.test(segment))
return segment;
return redactCommandToken(segment);
})
.join('');
}
function redactCommandToken(token) {
const match = token.match(/^([("'`]*)(.*?)([)"'`,;:]*)$/);
if (!match)
return token;
const [, prefix, rawCore, suffix] = match;
const core = redactPathAssignment(rawCore);
return `${prefix}${core}${suffix}`;
}
function redactPathAssignment(value) {
const equalsIndex = value.indexOf('=');
if (equalsIndex > 0) {
const left = value.slice(0, equalsIndex + 1);
const right = value.slice(equalsIndex + 1);
return `${left}${redactStandalonePath(right)}`;
}
return redactStandalonePath(value);
}
function redactStandalonePath(value) {
if (/^https?:\/\//i.test(value))
return sanitizeUrlForDisplay(value);
if (!looksLikePathToken(value))
return value;
return basenameFromPath(value);
}
function sanitizeUrlForDisplay(url) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
for (const key of [...parsed.searchParams.keys()]) {
if (/(secret|token|password|key|credential|bearer|auth)/i.test(key)) {
parsed.searchParams.set(key, '[redacted]');
}
}
return parsed.toString();
}
catch {
return url;
}
}
function looksLikePathToken(value) {
return (value.startsWith('~/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('/') ||
value.includes('/'));
}
function basenameFromPath(value) {
const cleaned = value.replace(/\\/g, '/').replace(/\/+$/, '');
const segments = cleaned.split('/').filter(Boolean);
return segments.at(-1) ?? value;
}
function isSkillPathValue(value) {
return /(?:^|\/)skills\/[^/]+\//i.test(value);
}
function sanitizeGenericText(value) {
return value
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function cleanupLine(line) {
return line
.replace(/^[-*•]\s*/, '')
.replace(/^\d+[.)]\s*/, '')
.replace(/\s+/g, ' ')
.trim();
}
function stripMarkdown(line) {
return line
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/^>\s*/, '')
.trim();
}
function isNoiseLine(line) {
return /^(?:completed|complete|done|success|succeeded|running|started|finished|ok)$/i.test(line);
}
function humanizeToolName(name) {
const cleaned = name.replace(/[-_]+/g, ' ').trim();
if (!cleaned)
return 'Tool';
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function formatDurationLabel(durationMs) {
return durationMs < 1000 ? `${durationMs} ms` : `${(durationMs / 1000).toFixed(1)} s`;
}
function stripQuotes(value) {
return value.replace(/^[`'"]+|[`'"]+$/g, '').trim();
}
function extractFirstCodeSpan(value) {
const match = value.match(/`([^`]+)`/);
return match?.[1]?.trim() || undefined;
}
function extractFirstQuotedText(value) {
const match = value.match(/["']([^"']+)["']/);
return match?.[1]?.trim() || undefined;
}
function extractFirstUrl(value) {
const match = value.match(/\bhttps?:\/\/[^\s"'`]+/i);
return match?.[0]?.trim() || undefined;
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Runtime store for structured tool-use steps.
*
* The Feishu card renderer reads from this store by session key so it can
* render observable, replayable tool execution without relying purely on
* reply payload text.
*/
export interface ToolUseTraceStep {
id: string;
seq: number;
toolName: string;
toolCallId?: string;
runId?: string;
params?: Record<string, unknown>;
result?: unknown;
error?: string;
durationMs?: number;
status: 'running' | 'success' | 'error';
startedAt: number;
finishedAt?: number;
}
export declare function startToolUseTraceRun(sessionKey: string): void;
export declare function clearToolUseTraceRun(sessionKey: string): void;
export declare function hasToolUseTraceRun(sessionKey?: string): boolean;
export declare function recordToolUseStart(params: {
sessionKey?: string;
toolName: string;
toolParams?: Record<string, unknown>;
toolCallId?: string;
runId?: string;
}): void;
export declare function recordToolUseEnd(params: {
sessionKey?: string;
toolName: string;
toolParams?: Record<string, unknown>;
toolCallId?: string;
runId?: string;
result?: unknown;
error?: string;
durationMs?: number;
}): void;
export declare function getToolUseTraceSteps(sessionKey?: string): ToolUseTraceStep[];
export declare function sanitizeTraceValue(value: unknown, depth?: number, context?: {
source?: 'params' | 'result' | 'generic';
key?: string;
}): unknown;
/** @internal — test-only helper to reset module-level state between test cases. */
export declare function _resetForTesting(): void;
@@ -0,0 +1,271 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Runtime store for structured tool-use steps.
*
* The Feishu card renderer reads from this store by session key so it can
* render observable, replayable tool execution without relying purely on
* reply payload text.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.startToolUseTraceRun = startToolUseTraceRun;
exports.clearToolUseTraceRun = clearToolUseTraceRun;
exports.hasToolUseTraceRun = hasToolUseTraceRun;
exports.recordToolUseStart = recordToolUseStart;
exports.recordToolUseEnd = recordToolUseEnd;
exports.getToolUseTraceSteps = getToolUseTraceSteps;
exports.sanitizeTraceValue = sanitizeTraceValue;
exports._resetForTesting = _resetForTesting;
const reasoning_utils_1 = require("./reasoning-utils.js");
const TRACE_TTL_MS = 30 * 60 * 1000;
const MAX_SESSION_TRACES = 128;
const MAX_STEPS_PER_SESSION = 256;
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
const GENERIC_STRING_LIMIT = 512;
const RESULT_STRING_LIMIT = 1024;
const COMMAND_STRING_LIMIT = 4096;
const PATH_STRING_LIMIT = 2048;
const sessionTraces = new Map();
function startToolUseTraceRun(sessionKey) {
if (!sessionKey)
return;
pruneTraceStore();
sessionTraces.set(sessionKey, {
nextSeq: 1,
updatedAt: Date.now(),
steps: [],
currentRunId: undefined,
});
}
function clearToolUseTraceRun(sessionKey) {
if (!sessionKey)
return;
sessionTraces.delete(sessionKey);
}
function hasToolUseTraceRun(sessionKey) {
if (!sessionKey)
return false;
return sessionTraces.has(sessionKey);
}
function recordToolUseStart(params) {
const { sessionKey, toolName, toolParams, toolCallId, runId } = params;
if (!sessionKey || !toolName)
return;
const state = sessionTraces.get(sessionKey);
if (!state)
return;
if (runId) {
if (state.currentRunId === undefined) {
state.currentRunId = runId;
}
else if (state.currentRunId !== runId) {
return;
}
}
const now = Date.now();
if (state.steps.length >= MAX_STEPS_PER_SESSION) {
state.steps.splice(0, state.steps.length - MAX_STEPS_PER_SESSION + 1);
}
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName,
toolCallId: toolCallId || undefined,
runId: runId || undefined,
params: sanitizeTraceValue(toolParams, 0, { source: 'params' }),
status: 'running',
startedAt: now,
});
state.nextSeq += 1;
state.updatedAt = now;
}
function recordToolUseEnd(params) {
const { sessionKey, toolName, toolParams, toolCallId, runId, result, error, durationMs } = params;
if (!sessionKey || !toolName)
return;
const state = sessionTraces.get(sessionKey);
if (!state)
return;
if (runId && state.currentRunId !== undefined && state.currentRunId !== runId) {
return;
}
const now = Date.now();
const sanitizedParams = sanitizeTraceValue(toolParams, 0, { source: 'params' });
const pendingIndex = findPendingStepIndex(state.steps, toolName, sanitizedParams, toolCallId);
if (pendingIndex >= 0) {
const step = state.steps[pendingIndex];
if (!step)
return;
step.status = error ? 'error' : 'success';
step.result = sanitizeTraceValue(result, 0, { source: 'result' });
step.error = error ? (0, reasoning_utils_1.truncateText)(error, 160) : undefined;
step.durationMs = durationMs;
step.finishedAt = now;
if (!step.params && sanitizedParams) {
step.params = sanitizedParams;
}
state.updatedAt = now;
return;
}
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName,
toolCallId: toolCallId || undefined,
runId: runId || undefined,
params: sanitizedParams,
result: sanitizeTraceValue(result, 0, { source: 'result' }),
error: error ? (0, reasoning_utils_1.truncateText)(error, 160) : undefined,
durationMs,
status: error ? 'error' : 'success',
startedAt: now,
finishedAt: now,
});
state.nextSeq += 1;
state.updatedAt = now;
}
function getToolUseTraceSteps(sessionKey) {
if (!sessionKey)
return [];
const state = sessionTraces.get(sessionKey);
if (!state)
return [];
if (Date.now() - state.updatedAt > TRACE_TTL_MS) {
sessionTraces.delete(sessionKey);
return [];
}
const now = Date.now();
return state.steps.map((step) => {
if (step.status === 'running' && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
return { ...step, status: 'error', error: 'timed out', finishedAt: now };
}
return { ...step };
});
}
function findPendingStepIndex(steps, toolName, params, toolCallId) {
if (toolCallId) {
for (let index = steps.length - 1; index >= 0; index -= 1) {
const step = steps[index];
if (!step || step.status !== 'running')
continue;
if (step.toolCallId === toolCallId)
return index;
}
}
const normalizedToolName = (0, reasoning_utils_1.normalizeToolName)(toolName);
const paramsKey = fingerprintTraceValue(params);
for (let index = steps.length - 1; index >= 0; index -= 1) {
const step = steps[index];
if (!step || step.status !== 'running')
continue;
if ((0, reasoning_utils_1.normalizeToolName)(step.toolName) !== normalizedToolName)
continue;
if (fingerprintTraceValue(step.params) !== paramsKey)
continue;
return index;
}
for (let index = steps.length - 1; index >= 0; index -= 1) {
const step = steps[index];
if (!step || step.status !== 'running')
continue;
if ((0, reasoning_utils_1.normalizeToolName)(step.toolName) !== normalizedToolName)
continue;
return index;
}
return -1;
}
function pruneTraceStore() {
const now = Date.now();
for (const [sessionKey, state] of sessionTraces) {
if (now - state.updatedAt > TRACE_TTL_MS) {
sessionTraces.delete(sessionKey);
}
}
if (sessionTraces.size <= MAX_SESSION_TRACES)
return;
const overflow = sessionTraces.size - MAX_SESSION_TRACES;
const entries = [...sessionTraces.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (const [sessionKey] of entries.slice(0, overflow)) {
sessionTraces.delete(sessionKey);
}
}
function sanitizeTraceValue(value, depth = 0, context = {}) {
if (value == null)
return undefined;
if (typeof value === 'string') {
const limit = resolveStringLimit(context);
return (0, reasoning_utils_1.truncateText)(sanitizeTraceString(value, context), limit);
}
if (typeof value === 'number' || typeof value === 'boolean')
return value;
if (depth >= 2)
return '[truncated]';
if (Array.isArray(value)) {
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source }));
}
if (typeof value === 'object') {
const input = value;
const output = {};
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
output[key] = isSensitiveKey(key)
? '[redacted]'
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
}
return output;
}
return (0, reasoning_utils_1.truncateText)(String(value), 180);
}
function sanitizeTraceString(value, context) {
const redactedUrl = redactUrlParams(value);
if (isCommandLikeKey(context.key)) {
return (0, reasoning_utils_1.redactInlineSecrets)(redactedUrl);
}
return redactedUrl;
}
function resolveStringLimit(context) {
const key = context.key?.toLowerCase() ?? '';
if (/(?:^|_)(?:command|script|description|prompt|task)(?:$|_)/.test(key)) {
return COMMAND_STRING_LIMIT;
}
if (/(?:^|_)(?:path|file|url|uri|cwd|folder|dir)(?:$|_)/.test(key)) {
return PATH_STRING_LIMIT;
}
if (context.source === 'result') {
return RESULT_STRING_LIMIT;
}
return GENERIC_STRING_LIMIT;
}
function isCommandLikeKey(key) {
const normalized = key?.toLowerCase() ?? '';
return /(?:^|_)(?:command|script)(?:$|_)/.test(normalized);
}
const SENSITIVE_KEY_RE = /secret|token|password|authorization|cookie|api[-_]?key|credential|private[-_]?key|access[-_]?key|database[-_]?url|connection[-_]?string|bearer|signing[-_]?key|encryption[-_]?key|session[-_]?id|client[-_]?secret|auth[-_]?token/i;
function isSensitiveKey(key) {
return SENSITIVE_KEY_RE.test(key);
}
function redactUrlParams(url) {
return url.replace(/([?&])(api_key|token|secret|key)=[^&]*/gi, '$1$2=[redacted]');
}
function fingerprintTraceValue(value) {
if (value == null)
return '';
if (typeof value !== 'object')
return String(value);
return JSON.stringify(sortTraceValue(value));
}
function sortTraceValue(value) {
if (Array.isArray(value))
return value.map((item) => sortTraceValue(item));
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => [key, sortTraceValue(entryValue)]));
}
return value;
}
/** @internal — test-only helper to reset module-level state between test cases. */
function _resetForTesting() {
sessionTraces.clear();
}
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Guard against operating on unavailable (deleted/recalled) messages.
*
* Encapsulates the terminateDueToUnavailable / shouldSkipForUnavailable
* logic previously scattered as closures in reply-dispatcher.ts.
*/
export interface UnavailableGuardParams {
replyToMessageId: string | undefined;
getCardMessageId: () => string | null;
onTerminate: () => void;
}
export declare class UnavailableGuard {
private terminated;
private readonly replyToMessageId;
private readonly getCardMessageId;
private readonly onTerminate;
constructor(params: UnavailableGuardParams);
get isTerminated(): boolean;
/**
* Check whether the reply pipeline should skip further operations.
* Returns true if the message is already known to be unavailable.
*/
shouldSkip(source: string): boolean;
/**
* Attempt to terminate the reply pipeline due to an unavailable message.
*
* @param source - Descriptive label for the caller (for logging).
* @param err - Optional error that triggered the check.
* @returns true if the pipeline was (or already had been) terminated.
*/
terminate(source: string, err?: unknown): boolean;
}
@@ -0,0 +1,87 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Guard against operating on unavailable (deleted/recalled) messages.
*
* Encapsulates the terminateDueToUnavailable / shouldSkipForUnavailable
* logic previously scattered as closures in reply-dispatcher.ts.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.UnavailableGuard = void 0;
const lark_logger_1 = require("../core/lark-logger.js");
const api_error_1 = require("../core/api-error.js");
const message_unavailable_1 = require("../core/message-unavailable.js");
const log = (0, lark_logger_1.larkLogger)('card/unavailable-guard');
// ---------------------------------------------------------------------------
// UnavailableGuard
// ---------------------------------------------------------------------------
class UnavailableGuard {
terminated = false;
replyToMessageId;
getCardMessageId;
onTerminate;
constructor(params) {
this.replyToMessageId = params.replyToMessageId;
this.getCardMessageId = params.getCardMessageId;
this.onTerminate = params.onTerminate;
}
get isTerminated() {
return this.terminated;
}
/**
* Check whether the reply pipeline should skip further operations.
* Returns true if the message is already known to be unavailable.
*/
shouldSkip(source) {
if (this.terminated)
return true;
if (!this.replyToMessageId)
return false;
if (!(0, message_unavailable_1.isMessageUnavailable)(this.replyToMessageId))
return false;
return this.terminate(source);
}
/**
* Attempt to terminate the reply pipeline due to an unavailable message.
*
* @param source - Descriptive label for the caller (for logging).
* @param err - Optional error that triggered the check.
* @returns true if the pipeline was (or already had been) terminated.
*/
terminate(source, err) {
if (this.terminated)
return true;
const fromError = (0, message_unavailable_1.isMessageUnavailableError)(err) ? err : undefined;
const cardMessageId = this.getCardMessageId();
const state = (0, message_unavailable_1.getMessageUnavailableState)(this.replyToMessageId) ?? (0, message_unavailable_1.getMessageUnavailableState)(cardMessageId ?? undefined);
let apiCode = fromError?.apiCode ?? state?.apiCode;
if (!apiCode && err) {
const detectedCode = (0, api_error_1.extractLarkApiCode)(err);
if ((0, message_unavailable_1.isTerminalMessageApiCode)(detectedCode)) {
const fallbackMessageId = this.replyToMessageId ?? cardMessageId ?? undefined;
if (fallbackMessageId) {
(0, message_unavailable_1.markMessageUnavailable)({
messageId: fallbackMessageId,
apiCode: detectedCode,
operation: source,
});
}
apiCode = detectedCode;
}
}
if (!apiCode)
return false;
this.terminated = true;
this.onTerminate();
const affectedMessageId = fromError?.messageId ?? this.replyToMessageId ?? cardMessageId ?? 'unknown';
log.warn('reply pipeline terminated by unavailable message', {
source,
apiCode,
messageId: affectedMessageId,
});
return true;
}
}
exports.UnavailableGuard = UnavailableGuard;
+47
View File
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Abort trigger detection for the Lark/Feishu channel plugin.
*
* Provides a fast-path check to determine whether an inbound message is
* an abort/stop command *before* it enters the per-chat serial queue.
*
* The trigger word list and normalisation logic are copied from the
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
* make a lightweight decision without importing the full reply pipeline.
* The message still flows through `tryFastAbortFromMessage()` for
* authoritative handling.
*/
import type { FeishuMessageEvent } from '../messaging/types';
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
export declare function isAbortTrigger(text: string): boolean;
/**
* Extended abort detection: matches both bare trigger words and the
* `/stop` command form. Used by the monitor fast-path.
*/
export declare function isLikelyAbortText(text: string): boolean;
/**
* Whether an inbound message expresses intent to stop / interrupt the ongoing
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
* conversational phrases above.
*
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
* the in-flight ping-pong drains instead of being re-armed. Substring match —
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
* the worst case is a missed forced-@ or a self-healing mute (any normal
* message lifts it).
*/
export declare function isConversationStopIntent(text: string): boolean;
/**
* Extract the raw text payload from a Feishu message event.
*
* Only handles `text` type messages. The `message.content` field is a
* JSON string like `{"text":"hello"}`. Returns `undefined` for
* non-text messages or parse failures.
*
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
* a message like `@Bot stop` is detected as `stop`.
*/
export declare function extractRawTextFromEvent(event: FeishuMessageEvent): string | undefined;
@@ -0,0 +1,216 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Abort trigger detection for the Lark/Feishu channel plugin.
*
* Provides a fast-path check to determine whether an inbound message is
* an abort/stop command *before* it enters the per-chat serial queue.
*
* The trigger word list and normalisation logic are copied from the
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
* make a lightweight decision without importing the full reply pipeline.
* The message still flows through `tryFastAbortFromMessage()` for
* authoritative handling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isAbortTrigger = isAbortTrigger;
exports.isLikelyAbortText = isLikelyAbortText;
exports.isConversationStopIntent = isConversationStopIntent;
exports.extractRawTextFromEvent = extractRawTextFromEvent;
// ---------------------------------------------------------------------------
// Trigger word list (synced with OpenClaw core abort.ts)
// ---------------------------------------------------------------------------
const ABORT_TRIGGERS = new Set([
'stop',
'esc',
'abort',
'wait',
'exit',
'interrupt',
'detente',
'deten',
'detén',
'arrete',
'arrête',
'停止',
'やめて',
'止めて',
'रुको',
'توقف',
'стоп',
'остановись',
'останови',
'остановить',
'прекрати',
'halt',
'anhalten',
'aufhören',
'hoer auf',
'stopp',
'pare',
'stop openclaw',
'openclaw stop',
'stop action',
'stop current action',
'stop run',
'stop current run',
'stop agent',
'stop the agent',
"stop don't do anything",
'stop dont do anything',
'stop do not do anything',
'stop doing anything',
'do not do that',
'please stop',
'stop please',
]);
// ---------------------------------------------------------------------------
// Normalisation helpers
// ---------------------------------------------------------------------------
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;:'"'")\]}]+$/u;
function normalizeAbortTriggerText(text) {
return text
.trim()
.toLowerCase()
.replace(/['`]/g, "'")
.replace(/\s+/g, ' ')
.replace(TRAILING_ABORT_PUNCTUATION_RE, '')
.trim();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
function isAbortTrigger(text) {
if (!text)
return false;
const normalized = normalizeAbortTriggerText(text);
return ABORT_TRIGGERS.has(normalized);
}
/**
* Extended abort detection: matches both bare trigger words and the
* `/stop` command form. Used by the monitor fast-path.
*/
function isLikelyAbortText(text) {
if (!text)
return false;
const trimmed = text.trim().toLowerCase();
if (trimmed === '/stop')
return true;
return isAbortTrigger(trimmed);
}
// ---------------------------------------------------------------------------
// Conversation stop-intent (broader than the exact abort triggers)
// ---------------------------------------------------------------------------
/**
* Conversational "please stop / interrupt this exchange" phrases.
*
* Deliberately SEPARATE from {@link ABORT_TRIGGERS} (which is synced word-for-
* word with OpenClaw core and matched by exact equality, e.g. `/stop`). These
* are matched by substring so natural phrasings like "中断对话" or "stop
* talking" are caught. The list is intentionally distinctive to avoid false
* positives — a false positive only means we skip the deterministic peer-@
* backstop for that turn (the model can still @ on its own), which is mild.
*/
const STOP_INTENT_PHRASES = [
// zh — stop / terminate / pause
'中断',
'中止',
'终止',
'停止',
'停下',
'停一下',
'暂停',
'打住',
'停手',
'收手',
// zh — "don't keep going / replying"
'别聊',
'别说了',
'别回复',
'别继续',
'别再聊',
'别再说',
'别吵',
'别争',
'不要回复',
'不要继续',
'不用回复',
'不用继续',
// zh — "wrap up / be quiet"
'结束对话',
'结束讨论',
'结束辩论',
'到此为止',
'闭嘴',
// en
'stop talking',
'stop chatting',
'stop debating',
'stop the debate',
'stop the conversation',
'stop this conversation',
'stop responding',
'stop replying',
'end the conversation',
'end conversation',
'end the debate',
'shut up',
'be quiet',
'cut it out',
'knock it off',
'wrap it up',
'stand down',
];
/**
* Whether an inbound message expresses intent to stop / interrupt the ongoing
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
* conversational phrases above.
*
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
* the in-flight ping-pong drains instead of being re-armed. Substring match —
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
* the worst case is a missed forced-@ or a self-healing mute (any normal
* message lifts it).
*/
function isConversationStopIntent(text) {
if (!text)
return false;
// Drop bot mention placeholders so "@Bot 中断对话" → "中断对话".
const normalized = text.replace(/@_user_\d+/g, '').trim().toLowerCase();
if (!normalized)
return false;
if (isLikelyAbortText(normalized))
return true;
return STOP_INTENT_PHRASES.some((p) => normalized.includes(p));
}
/**
* Extract the raw text payload from a Feishu message event.
*
* Only handles `text` type messages. The `message.content` field is a
* JSON string like `{"text":"hello"}`. Returns `undefined` for
* non-text messages or parse failures.
*
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
* a message like `@Bot stop` is detected as `stop`.
*/
function extractRawTextFromEvent(event) {
if (!event.message || event.message.message_type !== 'text') {
return undefined;
}
try {
const parsed = JSON.parse(event.message.content);
let text = parsed?.text;
if (typeof text !== 'string')
return undefined;
// Strip bot mention placeholders (@_user_1, @_user_2, etc.)
text = text.replace(/@_user_\d+/g, '').trim();
return text || undefined;
}
catch {
return undefined;
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Process-level chat task queue.
*
* Although located in channel/, this module is intentionally shared
* across channel, messaging, tools, and card layers as a process-level
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
*
* Ensures tasks targeting the same account+chat are executed serially.
* Used by both websocket inbound messages and synthetic message paths.
*/
type QueueStatus = 'queued' | 'immediate';
export interface ActiveDispatcherEntry {
abortCard: () => Promise<void>;
abortController?: AbortController;
}
/**
* Append `:thread:{threadId}` suffix when threadId is present.
* Consistent with the SDK's `:thread:` separator convention.
*/
export declare function threadScopedKey(base: string, threadId?: string): string;
export declare function buildQueueKey(accountId: string, chatId: string, threadId?: string): string;
export declare function registerActiveDispatcher(key: string, entry: ActiveDispatcherEntry): void;
export declare function unregisterActiveDispatcher(key: string): void;
export declare function getActiveDispatcher(key: string): ActiveDispatcherEntry | undefined;
/** Check whether the queue has an active task for the given key. */
export declare function hasActiveTask(key: string): boolean;
export declare function enqueueFeishuChatTask(params: {
accountId: string;
chatId: string;
threadId?: string;
task: () => Promise<void>;
}): {
status: QueueStatus;
promise: Promise<void>;
};
/** @internal Test-only: reset all queue and dispatcher state. */
export declare function _resetChatQueueState(): void;
export {};
@@ -0,0 +1,68 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Process-level chat task queue.
*
* Although located in channel/, this module is intentionally shared
* across channel, messaging, tools, and card layers as a process-level
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
*
* Ensures tasks targeting the same account+chat are executed serially.
* Used by both websocket inbound messages and synthetic message paths.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.threadScopedKey = threadScopedKey;
exports.buildQueueKey = buildQueueKey;
exports.registerActiveDispatcher = registerActiveDispatcher;
exports.unregisterActiveDispatcher = unregisterActiveDispatcher;
exports.getActiveDispatcher = getActiveDispatcher;
exports.hasActiveTask = hasActiveTask;
exports.enqueueFeishuChatTask = enqueueFeishuChatTask;
exports._resetChatQueueState = _resetChatQueueState;
const chatQueues = new Map();
const activeDispatchers = new Map();
/**
* Append `:thread:{threadId}` suffix when threadId is present.
* Consistent with the SDK's `:thread:` separator convention.
*/
function threadScopedKey(base, threadId) {
return threadId ? `${base}:thread:${threadId}` : base;
}
function buildQueueKey(accountId, chatId, threadId) {
return threadScopedKey(`${accountId}:${chatId}`, threadId);
}
function registerActiveDispatcher(key, entry) {
activeDispatchers.set(key, entry);
}
function unregisterActiveDispatcher(key) {
activeDispatchers.delete(key);
}
function getActiveDispatcher(key) {
return activeDispatchers.get(key);
}
/** Check whether the queue has an active task for the given key. */
function hasActiveTask(key) {
return chatQueues.has(key);
}
function enqueueFeishuChatTask(params) {
const { accountId, chatId, threadId, task } = params;
const key = buildQueueKey(accountId, chatId, threadId);
const prev = chatQueues.get(key) ?? Promise.resolve();
const status = chatQueues.has(key) ? 'queued' : 'immediate';
const taskPromise = prev.then(task, task);
chatQueues.set(key, taskPromise);
const cleanup = () => {
if (chatQueues.get(key) === taskPromise) {
chatQueues.delete(key);
}
};
taskPromise.then(cleanup, cleanup);
return { status, promise: taskPromise };
}
/** @internal Test-only: reset all queue and dispatcher state. */
function _resetChatQueueState() {
chatQueues.clear();
activeDispatchers.clear();
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Configuration merge helpers for Feishu account management.
*
* Centralises the pattern of merging a partial configuration patch
* into the Feishu section of the top-level ClawdbotConfig, handling
* both the default account (top-level fields) and named accounts
* (nested under `accounts`).
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
/** Set the `enabled` flag on a Feishu account. */
export declare function setAccountEnabled(cfg: ClawdbotConfig, accountId: string, enabled: boolean): ClawdbotConfig;
/** Apply an arbitrary config patch to a Feishu account. */
export declare function applyAccountConfig(cfg: ClawdbotConfig, accountId: string, patch: Record<string, unknown>): ClawdbotConfig;
/** Delete a Feishu account entry from the config. */
export declare function deleteAccount(cfg: ClawdbotConfig, accountId: string): ClawdbotConfig;
/** Collect security warnings for a Feishu account. */
export declare function collectFeishuSecurityWarnings(params: {
cfg: ClawdbotConfig;
accountId: string;
}): string[];
@@ -0,0 +1,107 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Configuration merge helpers for Feishu account management.
*
* Centralises the pattern of merging a partial configuration patch
* into the Feishu section of the top-level ClawdbotConfig, handling
* both the default account (top-level fields) and named accounts
* (nested under `accounts`).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.setAccountEnabled = setAccountEnabled;
exports.applyAccountConfig = applyAccountConfig;
exports.deleteAccount = deleteAccount;
exports.collectFeishuSecurityWarnings = collectFeishuSecurityWarnings;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const accounts_1 = require("../core/accounts.js");
const security_check_1 = require("../core/security-check.js");
/** Generic Feishu account config merge. */
function mergeFeishuAccountConfig(cfg, accountId, patch) {
const isDefault = !accountId || accountId === account_id_1.DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, ...patch },
},
};
}
const feishuCfg = cfg.channels?.feishu;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: { ...feishuCfg?.accounts?.[accountId], ...patch },
},
},
},
};
}
/** Set the `enabled` flag on a Feishu account. */
function setAccountEnabled(cfg, accountId, enabled) {
return mergeFeishuAccountConfig(cfg, accountId, { enabled });
}
/** Apply an arbitrary config patch to a Feishu account. */
function applyAccountConfig(cfg, accountId, patch) {
return mergeFeishuAccountConfig(cfg, accountId, patch);
}
/** Delete a Feishu account entry from the config. */
function deleteAccount(cfg, accountId) {
const isDefault = !accountId || accountId === account_id_1.DEFAULT_ACCOUNT_ID;
if (isDefault) {
// Delete entire feishu config
const next = { ...cfg };
const nextChannels = { ...cfg.channels };
delete nextChannels.feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
}
else {
delete next.channels;
}
return next;
}
// Delete specific account from accounts
const feishuCfg = cfg.channels?.feishu;
const accounts = { ...feishuCfg?.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
}
/** Collect security warnings for a Feishu account. */
function collectFeishuSecurityWarnings(params) {
const { cfg, accountId } = params;
const warnings = [];
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const feishuCfg = account.config;
// cfg.channels.defaults is a cross-channel defaults object (not formally typed)
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
if (groupPolicy === 'open') {
warnings.push(`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any group to interact (mention-gated). To restrict which groups are allowed, set groupPolicy="allowlist" and list group IDs in channels.feishu.groups. To restrict which senders can trigger the bot, set channels.feishu.groupAllowFrom with user open_ids (ou_xxx).`);
}
// Multi-account cross-tenant isolation check (only on first account to avoid duplicates)
const allIds = (0, accounts_1.getLarkAccountIds)(cfg);
if (allIds.length === 0 || accountId === allIds[0]) {
for (const w of (0, security_check_1.collectIsolationWarnings)(cfg)) {
warnings.push(w);
}
}
return warnings;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Directory listing for Feishu peers (users) and groups.
*
* Provides both config-based (offline) and live API directory
* lookups so the outbound subsystem and UI can resolve targets.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuDirectoryGroup, FeishuDirectoryPeer } from './types';
export type { FeishuDirectoryPeer, FeishuDirectoryGroup } from './types';
/**
* List users known from the channel config (allowFrom + dms fields).
*
* Does not make any API calls -- useful when the bot is not yet
* connected or when credentials are unavailable.
*/
export declare function listFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]>;
/**
* List groups known from the channel config (groups + groupAllowFrom).
*/
export declare function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]>;
/**
* List users via the Feishu contact/v3/users API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
export declare function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]>;
/**
* List groups via the Feishu im/v1/chats API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
export declare function listFeishuDirectoryGroupsLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]>;
@@ -0,0 +1,197 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Directory listing for Feishu peers (users) and groups.
*
* Provides both config-based (offline) and live API directory
* lookups so the outbound subsystem and UI can resolve targets.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.listFeishuDirectoryPeers = listFeishuDirectoryPeers;
exports.listFeishuDirectoryGroups = listFeishuDirectoryGroups;
exports.listFeishuDirectoryPeersLive = listFeishuDirectoryPeersLive;
exports.listFeishuDirectoryGroupsLive = listFeishuDirectoryGroupsLive;
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
const targets_1 = require("../core/targets.js");
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Case-insensitive substring match on id and optional name. */
function matchesQuery(id, name, query) {
if (!query)
return true;
return id.toLowerCase().includes(query) || (name?.toLowerCase().includes(query) ?? false);
}
/** Filter items and apply optional limit. */
function applyLimitSlice(items, limit) {
return limit && limit > 0 ? items.slice(0, limit) : items;
}
// ---------------------------------------------------------------------------
// Config-based (offline) directory
// ---------------------------------------------------------------------------
/**
* List users known from the channel config (allowFrom + dms fields).
*
* Does not make any API calls -- useful when the bot is not yet
* connected or when credentials are unavailable.
*/
async function listFeishuDirectoryPeers(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || '';
const ids = new Set();
// Collect from allowFrom entries.
for (const entry of feishuCfg?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
// Collect from per-user DM config keys.
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) {
ids.add(trimmed);
}
}
const peers = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => (0, targets_1.normalizeFeishuTarget)(raw) ?? raw)
.filter((id) => matchesQuery(id, undefined, q))
.map((id) => ({ kind: 'user', id }));
return applyLimitSlice(peers, params.limit);
}
/**
* List groups known from the channel config (groups + groupAllowFrom).
*/
async function listFeishuDirectoryGroups(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || '';
const ids = new Set();
// Collect from per-group config keys.
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
const trimmed = groupId.trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
// Collect from groupAllowFrom entries.
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== '*') {
ids.add(trimmed);
}
}
const groups = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.filter((id) => matchesQuery(id, undefined, q))
.map((id) => ({ kind: 'group', id }));
return applyLimitSlice(groups, params.limit);
}
// ---------------------------------------------------------------------------
// Live API directory
// ---------------------------------------------------------------------------
/**
* List users via the Feishu contact/v3/users API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
async function listFeishuDirectoryPeersLive(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
if (!account.configured) {
return listFeishuDirectoryPeers(params);
}
try {
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
const peers = [];
const limit = params.limit ?? 50;
if (limit <= 0)
return [];
const q = params.query?.trim().toLowerCase() || '';
let pageToken;
do {
const remaining = limit - peers.length;
const response = await client.contact.user.list({
params: {
page_size: Math.min(remaining, 50),
page_token: pageToken,
},
});
if (response.code !== 0 || !response.data?.items)
break;
for (const user of response.data.items) {
if (user.open_id && matchesQuery(user.open_id, user.name, q)) {
peers.push({
kind: 'user',
id: user.open_id,
name: user.name || undefined,
});
}
if (peers.length >= limit)
break;
}
pageToken = response.data?.page_token;
} while (pageToken && peers.length < limit);
return peers;
}
catch {
// Fallback to config-based listing on API failure.
return listFeishuDirectoryPeers(params);
}
}
/**
* List groups via the Feishu im/v1/chats API.
*
* Falls back to config-based listing when credentials are missing or
* the API call fails.
*/
async function listFeishuDirectoryGroupsLive(params) {
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
if (!account.configured) {
return listFeishuDirectoryGroups(params);
}
try {
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
const groups = [];
const limit = params.limit ?? 50;
if (limit <= 0)
return [];
const q = params.query?.trim().toLowerCase() || '';
let pageToken;
do {
const remaining = limit - groups.length;
const response = await client.im.chat.list({
params: {
page_size: Math.min(remaining, 100),
page_token: pageToken,
},
});
if (response.code !== 0 || !response.data?.items)
break;
for (const chat of response.data.items) {
if (chat.chat_id && matchesQuery(chat.chat_id, chat.name, q)) {
groups.push({
kind: 'group',
id: chat.chat_id,
name: chat.name || undefined,
});
}
if (groups.length >= limit)
break;
}
pageToken = response.data?.page_token;
} while (pageToken && groups.length < limit);
return groups;
}
catch {
// Fallback to config-based listing on API failure.
return listFeishuDirectoryGroups(params);
}
}
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event handlers for the Feishu WebSocket monitor.
*
* Extracted from monitor.ts to improve testability and reduce
* function size. Each handler receives a MonitorContext with all
* dependencies needed to process the event.
*/
import type { MonitorContext } from './types';
export declare function handleMessageEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleReactionEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleBotMembershipEvent(ctx: MonitorContext, data: unknown, action: 'added' | 'removed'): Promise<void>;
export declare function handleVcMeetingInvitedEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleCommentEvent(ctx: MonitorContext, data: unknown): Promise<void>;
export declare function handleCardActionEvent(ctx: MonitorContext, data: unknown): Promise<unknown>;
@@ -0,0 +1,380 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event handlers for the Feishu WebSocket monitor.
*
* Extracted from monitor.ts to improve testability and reduce
* function size. Each handler receives a MonitorContext with all
* dependencies needed to process the event.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleMessageEvent = handleMessageEvent;
exports.handleReactionEvent = handleReactionEvent;
exports.handleBotMembershipEvent = handleBotMembershipEvent;
exports.handleVcMeetingInvitedEvent = handleVcMeetingInvitedEvent;
exports.handleCommentEvent = handleCommentEvent;
exports.handleCardActionEvent = handleCardActionEvent;
const handler_1 = require("../messaging/inbound/handler.js");
const reaction_handler_1 = require("../messaging/inbound/reaction-handler.js");
const comment_handler_1 = require("../messaging/inbound/comment-handler.js");
const vc_meeting_invited_handler_1 = require("../messaging/inbound/vc-meeting-invited-handler.js");
const vc_sender_1 = require("../messaging/inbound/vc-sender.js");
const comment_context_1 = require("../messaging/inbound/comment-context.js");
const dedup_1 = require("../messaging/inbound/dedup.js");
const lark_ticket_1 = require("../core/lark-ticket.js");
const lark_logger_1 = require("../core/lark-logger.js");
const auto_auth_1 = require("../tools/auto-auth.js");
const ask_user_question_1 = require("../tools/ask-user-question.js");
const chat_queue_1 = require("./chat-queue.js");
const abort_detect_1 = require("./abort-detect.js");
const interactive_dispatch_1 = require("./interactive-dispatch.js");
const elog = (0, lark_logger_1.larkLogger)('channel/event-handlers');
// ---------------------------------------------------------------------------
// Event ownership validation
// ---------------------------------------------------------------------------
/**
* Verify that the event's app_id matches the current account.
*
* Lark SDK EventDispatcher flattens the v2 envelope header (which
* contains `app_id`) into the handler `data` object, so `app_id` is
* available directly on `data`.
*
* Returns `false` (discard event) when the app_id does not match.
*/
function isEventOwnershipValid(ctx, data) {
const expectedAppId = ctx.lark.account.appId;
if (!expectedAppId)
return true; // appId not configured — skip check
const eventAppId = data.app_id;
if (eventAppId == null)
return true; // SDK did not provide app_id — defensive skip
if (eventAppId !== expectedAppId) {
elog.warn('event app_id mismatch, discarding', {
accountId: ctx.accountId,
expected: expectedAppId,
received: String(eventAppId),
});
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Message handler
// ---------------------------------------------------------------------------
async function handleMessageEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
// Self-echo hard filter — drop messages authored by this very bot before
// dedup and enqueue. Prevents self-reply loops; the primary guardrail
// against bot-to-bot ping-pong.
//
// NOTE: if botOpenId is not yet populated (startup race before probe
// resolves), this filter is skipped. The downstream bot-sender gate
// (checkBotSenderGate) acts as fallback — bot messages default to
// `allowBots='mentions'`, so in groups they require an explicit @-mention
// of this bot to pass; DMs are pass-through under the default.
const senderOpenId = event.sender?.sender_id?.open_id;
const botOpenId = ctx.lark.botOpenId;
if (botOpenId && senderOpenId && senderOpenId === botOpenId) {
log(`feishu[${accountId}]: drop self-echo message ${event.message?.message_id ?? 'unknown'}`);
return;
}
const msgId = event.message?.message_id ?? 'unknown';
const chatId = event.message?.chat_id ?? '';
// In topic groups, reply events carry root_id but not thread_id.
// Use root_id as fallback so different topics get separate queue keys
// and can be processed in parallel.
const threadId = event.message?.thread_id || event.message?.root_id || undefined;
// Dedup — skip duplicate messages (e.g. from WebSocket reconnects).
if (!ctx.messageDedup.tryRecord(msgId, accountId)) {
log(`feishu[${accountId}]: duplicate message ${msgId}, skipping`);
return;
}
// Expiry — discard stale messages from reconnect replay.
if ((0, dedup_1.isMessageExpired)(event.message?.create_time)) {
log(`feishu[${accountId}]: message ${msgId} expired, discarding`);
return;
}
// ---- Abort fast-path ----
// If the message looks like an abort trigger and there is an active
// reply dispatcher for this chat, fire abortCard() immediately
// (before the message enters the serial queue) so the streaming
// card is terminated without waiting for the current task.
const abortText = (0, abort_detect_1.extractRawTextFromEvent)(event);
if (abortText && (0, abort_detect_1.isLikelyAbortText)(abortText)) {
const queueKey = (0, chat_queue_1.buildQueueKey)(accountId, chatId, threadId);
if ((0, chat_queue_1.hasActiveTask)(queueKey)) {
const active = (0, chat_queue_1.getActiveDispatcher)(queueKey);
if (active) {
log(`feishu[${accountId}]: abort fast-path triggered for chat ${chatId} (text="${abortText}")`);
active.abortController?.abort();
active.abortCard().catch((err) => {
error(`feishu[${accountId}]: abort fast-path abortCard failed: ${String(err)}`);
});
}
}
}
const { status } = (0, chat_queue_1.enqueueFeishuChatTask)({
accountId,
chatId,
threadId,
task: async () => {
try {
await (0, lark_ticket_1.withTicket)({
messageId: msgId,
chatId,
accountId,
startTime: Date.now(),
senderOpenId: event.sender?.sender_id?.open_id || '',
chatType: event.message?.chat_type || undefined,
threadId,
}, () => (0, handler_1.handleFeishuMessage)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
}));
}
catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
},
});
log(`feishu[${accountId}]: message ${msgId} in chat ${chatId}${threadId ? ` thread ${threadId}` : ''}${status}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Reaction handler
// ---------------------------------------------------------------------------
async function handleReactionEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
const msgId = event.message_id ?? 'unknown';
log(`feishu[${accountId}]: reaction event on message ${msgId}`);
// ---- Dedup: deterministic key based on message + emoji + operator ----
const emojiType = event.reaction_type?.emoji_type ?? '';
const operatorOpenId = event.user_id?.open_id ?? '';
const dedupKey = `${msgId}:reaction:${emojiType}:${operatorOpenId}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate reaction ${dedupKey}, skipping`);
return;
}
// ---- Expiry: discard stale reaction events ----
if ((0, dedup_1.isMessageExpired)(event.action_time)) {
log(`feishu[${accountId}]: reaction on ${msgId} expired, discarding`);
return;
}
// ---- Pre-resolve real chatId before enqueuing ----
// The API call (3s timeout) runs outside the queue so it doesn't
// block the serial chain, and is read-only so ordering is irrelevant.
const preResolved = await (0, reaction_handler_1.resolveReactionContext)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
accountId,
});
if (!preResolved)
return;
// ---- Enqueue with the real chatId (matches normal message queue key) ----
const { status } = (0, chat_queue_1.enqueueFeishuChatTask)({
accountId,
chatId: preResolved.chatId,
threadId: preResolved.threadId,
task: async () => {
try {
await (0, lark_ticket_1.withTicket)({
messageId: msgId,
chatId: preResolved.chatId,
accountId,
startTime: Date.now(),
senderOpenId: operatorOpenId,
chatType: preResolved.chatType,
threadId: preResolved.threadId,
}, () => (0, reaction_handler_1.handleFeishuReaction)({
cfg: ctx.cfg,
event,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
preResolved,
}));
}
catch (err) {
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
}
},
});
log(`feishu[${accountId}]: reaction on ${msgId} (chatId=${preResolved.chatId}) — ${status}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Bot membership handler
// ---------------------------------------------------------------------------
async function handleBotMembershipEvent(ctx, data, action) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
log(`feishu[${accountId}]: bot ${action} ${action === 'removed' ? 'from' : 'to'} chat ${event.chat_id}`);
}
catch (err) {
error(`feishu[${accountId}]: error handling bot ${action} event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// VC meeting invited handler
// ---------------------------------------------------------------------------
async function handleVcMeetingInvitedEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const event = data;
const meetingNo = event.meeting?.meeting_no?.trim() ?? '';
const eventId = event.event_id?.trim() ?? '';
// Resolve the inviter identity through the shared helper so the
// diagnostics log and the dispatch handler always agree on the
// same sender semantics.
const sender = (0, vc_sender_1.resolveVcSender)(event);
const senderId = sender.senderId;
const invitedBotOpenId = event.bot?.id?.open_id?.trim() ?? '';
// VC invited origin/ownership diagnostics:
// - This handler is only reachable from the WebSocket monitor path.
// - We still log app_id/bot_open_id so operators can confirm the event
// is delivered to the expected bot/account, and see which required
// fields are missing when we skip.
const expectedAppId = ctx.lark.account.appId ?? '';
const eventAppId = event.app_id?.trim() ?? '';
log(`feishu[${accountId}]: vc invited event received (ingress=websocket)` +
`${eventId ? ` event_id=${eventId}` : ''}` +
`${eventAppId ? ` app_id=${eventAppId}` : ' app_id=<missing>'}` +
`${expectedAppId ? ` expected_app_id=${expectedAppId}` : ''}` +
`${invitedBotOpenId ? ` bot_open_id=${invitedBotOpenId}` : ' bot_open_id=<missing>'}` +
`${ctx.lark.botOpenId ? ` expected_bot_open_id=${ctx.lark.botOpenId}` : ''}` +
`${event.invite_time ? ` invite_time=${event.invite_time}` : ''}` +
` meeting_no_present=${meetingNo ? 'true' : 'false'}` +
` sender_present=${senderId ? 'true' : 'false'}` +
` sender_from=${sender.fromFallback}`);
if (!meetingNo) {
log(`feishu[${accountId}]: vc invited event missing meeting_no, skipping`);
return;
}
if (!senderId) {
log(`feishu[${accountId}]: vc invited event missing inviter identity, skipping`);
return;
}
if (ctx.lark.botOpenId && invitedBotOpenId && invitedBotOpenId !== ctx.lark.botOpenId) {
log(`feishu[${accountId}]: vc invited event for another bot, expected=${ctx.lark.botOpenId}, got=${invitedBotOpenId}, skipping`);
return;
}
// Prefer event_id when the SDK exposes it: historical raw payload logs
// show WebSocket reconnect replays reuse the same event_id, while a real
// second invitation yields a new event_id even for the same meeting/bot.
// Fallback to (meeting_no, bot) only when event_id is absent so older
// payload shapes still remain deduplicated.
const dedupBotKey = ctx.lark.botOpenId ?? invitedBotOpenId ?? 'no-bot';
const dedupKey = eventId ? `vc-invited:by-event:${eventId}` : `vc-invited:by-meeting:${meetingNo}:${dedupBotKey}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate vc invited event detected, skipping`);
return;
}
log(`feishu[${accountId}]: vc invited event accepted for synthetic dispatch`);
await (0, vc_meeting_invited_handler_1.handleFeishuVcMeetingInvited)({
cfg: ctx.cfg,
event,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
});
}
catch (err) {
error(`feishu[${accountId}]: error handling vc invited event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Drive comment handler
// ---------------------------------------------------------------------------
async function handleCommentEvent(ctx, data) {
if (!isEventOwnershipValid(ctx, data))
return;
const { accountId, log, error } = ctx;
try {
const parsed = (0, comment_context_1.parseFeishuDriveCommentNoticeEventPayload)(data);
if (!parsed) {
log(`feishu[${accountId}]: invalid comment event payload, skipping`);
return;
}
const commentId = parsed.comment_id ?? '';
const replyId = parsed.reply_id ?? '';
// Parser has normalized notice_meta fields into canonical top-level fields
const _senderOpenId = parsed.user_id?.open_id ?? '';
const isMentioned = parsed.is_mention ?? false;
const eventTimestamp = parsed.action_time;
log(`feishu[${accountId}]: drive comment event: ` +
`type=${parsed.file_type}, comment=${commentId}` +
`${replyId ? `, reply=${replyId}` : ''}` +
`${isMentioned ? ', @bot' : ''}`);
// Dedup: build a deterministic key from the comment/reply IDs
const dedupKey = replyId ? `comment:${commentId}:reply:${replyId}` : `comment:${commentId}`;
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
log(`feishu[${accountId}]: duplicate comment event ${dedupKey}, skipping`);
return;
}
// Expiry check
if ((0, dedup_1.isMessageExpired)(eventTimestamp)) {
log(`feishu[${accountId}]: comment event expired, discarding`);
return;
}
// Dispatch the comment event (no queue serialization needed for comment threads)
await (0, comment_handler_1.handleFeishuCommentEvent)({
cfg: ctx.cfg,
event: parsed,
botOpenId: ctx.lark.botOpenId,
runtime: ctx.runtime,
chatHistories: ctx.chatHistories,
accountId,
});
}
catch (err) {
error(`feishu[${accountId}]: error handling comment event: ${String(err)}`);
}
}
// ---------------------------------------------------------------------------
// Card action handler
// ---------------------------------------------------------------------------
async function handleCardActionEvent(ctx, data) {
try {
// AskUserQuestion:表单卡片交互(宿主内建能力优先)
const askResult = (0, ask_user_question_1.handleAskUserAction)(data, ctx.cfg, ctx.accountId);
if (askResult !== undefined)
return askResult;
// auto-auth:授权/权限引导相关卡片交互(宿主内建能力优先)
const authResult = await (0, auto_auth_1.handleCardAction)(data, ctx.cfg, ctx.accountId);
if (authResult !== undefined)
return authResult;
// 业务自定义卡片交互:使用 SDK 标准 interactive dispatch 管道转发给业务插件。
return await (0, interactive_dispatch_1.dispatchFeishuPluginInteractiveHandler)({ cfg: ctx.cfg, accountId: ctx.accountId, data });
}
catch (err) {
elog.warn(`card.action.trigger handler error: ${err}`);
}
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu interactive dispatch wrapper.
*
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
* standard interactive dispatch pipeline:
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
* - Channel forwards via `dispatchPluginInteractiveHandler()`
*
* We intentionally do NOT maintain any channel-local global registry here.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
export type FeishuInteractiveHandlerResponse = unknown;
export interface FeishuInteractiveHandlerContext {
channel: 'feishu';
accountId: string;
senderId?: string;
conversationId?: string;
messageId?: string;
namespace: string;
payload: string;
action: string;
rawEvent: unknown;
respond: {
reply: (args: {
text: string;
}) => Promise<void>;
followUp: (args: {
text: string;
}) => Promise<void>;
/**
* Best-effort "edit current message" mapping.
* In Feishu, we prefer updating the original interactive card when possible.
*/
editMessage: (args: {
text?: string;
blocks?: unknown[];
}) => Promise<void>;
};
}
/**
* Dispatch a Feishu interactive card action to business plugins through
* the OpenClaw SDK's standard interactive dispatch pipeline.
*
* Returns `undefined` when:
* - the event does not look like an interactive action we can route, or
* - no plugin handler is registered for the derived namespace.
*
* @param params.cfg - OpenClaw config snapshot.
* @param params.accountId - Current Feishu account id.
* @param params.data - Raw `card.action.trigger` event payload.
*/
export declare function dispatchFeishuPluginInteractiveHandler(params: {
cfg: ClawdbotConfig;
accountId: string;
data: unknown;
}): Promise<unknown | undefined>;
@@ -0,0 +1,188 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu interactive dispatch wrapper.
*
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
* standard interactive dispatch pipeline:
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
* - Channel forwards via `dispatchPluginInteractiveHandler()`
*
* We intentionally do NOT maintain any channel-local global registry here.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.dispatchFeishuPluginInteractiveHandler = dispatchFeishuPluginInteractiveHandler;
// NOTE: This is the SDK-standard interactive pipeline.
const plugin_runtime_1 = require("openclaw/plugin-sdk/plugin-runtime");
const card_action_operator_1 = require("../core/card-action-operator.js");
const lark_logger_1 = require("../core/lark-logger.js");
const send_1 = require("../messaging/outbound/send.js");
const log = (0, lark_logger_1.larkLogger)('channel/interactive-dispatch');
function extractBasics(data) {
try {
const ev = data;
const action = ev.action?.value?.action;
if (!action || typeof action !== 'string')
return null;
const openChatId = ev.open_chat_id ?? ev.context?.open_chat_id;
const openMessageId = ev.open_message_id ?? ev.context?.open_message_id;
return {
action: action.trim(),
senderOpenId: (0, card_action_operator_1.resolveCardCallbackOperatorId)(ev.operator),
openChatId,
openMessageId,
};
}
catch {
return null;
}
}
function buildMarkdownCard(text) {
return {
schema: '2.0',
body: {
elements: [
{
tag: 'markdown',
content: text,
},
],
},
};
}
/**
* Dispatch a Feishu interactive card action to business plugins through
* the OpenClaw SDK's standard interactive dispatch pipeline.
*
* Returns `undefined` when:
* - the event does not look like an interactive action we can route, or
* - no plugin handler is registered for the derived namespace.
*
* @param params.cfg - OpenClaw config snapshot.
* @param params.accountId - Current Feishu account id.
* @param params.data - Raw `card.action.trigger` event payload.
*/
async function dispatchFeishuPluginInteractiveHandler(params) {
const basics = extractBasics(params.data);
if (!basics)
return undefined;
if (!basics.action)
return undefined;
const respond = {
reply: async (args) => {
if (!basics.openChatId || !String(args?.text || '').trim())
return;
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: String(args?.text || ''),
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
},
followUp: async (args) => {
if (!basics.openChatId || !String(args?.text || '').trim())
return;
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: String(args?.text || ''),
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
},
editMessage: async (args) => {
if (!basics.openMessageId) {
if (Array.isArray(args?.blocks) && args.blocks.length && basics.openChatId) {
await (0, send_1.sendCardFeishu)({
cfg: params.cfg,
to: basics.openChatId,
card: { schema: '2.0', body: { elements: args.blocks } },
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
return;
}
if (typeof args?.text === 'string' && args.text.trim() && basics.openChatId) {
await (0, send_1.sendMessageFeishu)({
cfg: params.cfg,
to: basics.openChatId,
text: args.text,
replyToMessageId: basics.openMessageId,
accountId: params.accountId,
replyInThread: false,
});
}
return;
}
if (Array.isArray(args?.blocks) && args.blocks.length) {
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: { schema: '2.0', body: { elements: args.blocks } },
accountId: params.accountId,
});
return;
}
if (typeof args?.text === 'string' && args.text.trim()) {
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: buildMarkdownCard(args.text),
accountId: params.accountId,
});
return;
}
await (0, send_1.updateCardFeishu)({
cfg: params.cfg,
messageId: basics.openMessageId,
card: { schema: '2.0', body: { elements: [] } },
accountId: params.accountId,
});
},
};
try {
const dedupeId = `feishu:${params.accountId}:${basics.openChatId ?? '-'}:${basics.openMessageId ?? '-'}:${basics.senderOpenId ?? '-'}:${basics.action}`;
let cardResponse;
const result = await (0, plugin_runtime_1.dispatchPluginInteractiveHandler)({
channel: 'feishu',
data: basics.action,
dedupeId,
invoke: async (match) => {
const { registration, namespace, payload } = match;
const handlerCtx = {
channel: 'feishu',
accountId: params.accountId,
senderId: basics.senderOpenId,
conversationId: basics.openChatId,
messageId: basics.openMessageId,
namespace,
payload,
action: basics.action,
rawEvent: params.data,
respond,
};
cardResponse = await registration.handler(handlerCtx);
// If the handler returns a card response, treat it as handled.
return { handled: cardResponse !== undefined };
},
});
if (!result.matched)
return undefined;
return cardResponse;
}
catch (err) {
log.warn(`interactive dispatch failed: ${String(err)}`);
return {
toast: {
type: 'error',
content: '交互处理失败,请稍后重试',
},
};
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* WebSocket monitoring for the Lark/Feishu channel plugin.
*
* Manages per-account WSClient connections and routes inbound Feishu
* events (messages, bot membership changes, read receipts) to the
* appropriate handlers.
*/
import type { MonitorFeishuOpts } from './types';
export type { MonitorFeishuOpts } from './types';
/**
* Start monitoring for all enabled Feishu accounts (or a single
* account when `opts.accountId` is specified).
*/
export declare function monitorFeishuProvider(opts?: MonitorFeishuOpts): Promise<void>;
@@ -0,0 +1,140 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* WebSocket monitoring for the Lark/Feishu channel plugin.
*
* Manages per-account WSClient connections and routes inbound Feishu
* events (messages, bot membership changes, read receipts) to the
* appropriate handlers.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.monitorFeishuProvider = monitorFeishuProvider;
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
const lark_logger_1 = require("../core/lark-logger.js");
const shutdown_hooks_1 = require("../core/shutdown-hooks.js");
const dedup_1 = require("../messaging/inbound/dedup.js");
const event_handlers_1 = require("./event-handlers.js");
const mlog = (0, lark_logger_1.larkLogger)('channel/monitor');
// ---------------------------------------------------------------------------
// Single-account monitor
// ---------------------------------------------------------------------------
/**
* Start monitoring a single Feishu account.
*
* Creates a LarkClient, probes bot identity, registers event handlers,
* and starts a WebSocket connection. Returns a Promise that resolves
* when the abort signal fires (or immediately if already aborted).
*/
async function monitorSingleAccount(params) {
const { account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
const error = runtime?.error ?? ((...args) => mlog.error(args.map(String).join(' ')));
// Only websocket mode is supported in the monitor path.
const connectionMode = account.config.connectionMode ?? 'websocket';
if (connectionMode !== 'websocket') {
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
return;
}
// Message dedup — filters duplicate deliveries from WebSocket reconnects.
const dedupCfg = account.config.dedup;
const messageDedup = new dedup_1.MessageDedup({
ttlMs: dedupCfg?.ttlMs,
maxEntries: dedupCfg?.maxEntries,
});
log(`feishu[${accountId}]: message dedup enabled (ttl=${messageDedup['ttlMs']}ms, max=${messageDedup['maxEntries']})`);
log(`feishu[${accountId}]: starting WebSocket connection...`);
// Create LarkClient instance — manages SDK client, WS, and bot identity.
const lark = lark_client_1.LarkClient.fromAccount(account);
// Attach dedup instance so it is disposed together with the client.
lark.messageDedup = messageDedup;
/** Per-chat history maps (used for group-chat context window). */
const chatHistories = new Map();
const ctx = {
get cfg() {
return lark_client_1.LarkClient.runtime.config.loadConfig();
},
lark,
accountId,
chatHistories,
messageDedup,
runtime,
log,
error,
};
await lark.startWS({
handlers: {
'im.message.receive_v1': (data) => (0, event_handlers_1.handleMessageEvent)(ctx, data),
'im.message.message_read_v1': async () => { },
'im.message.reaction.created_v1': (data) => (0, event_handlers_1.handleReactionEvent)(ctx, data),
// These events are expected in normal usage but do not affect the
// plugin's current behavior. Register no-op handlers to avoid SDK
// warnings about missing handlers.
'im.message.reaction.deleted_v1': async () => { },
'im.chat.access_event.bot_p2p_chat_entered_v1': async () => { },
'im.chat.member.bot.added_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'added'),
'im.chat.member.bot.deleted_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'removed'),
'vc.bot.meeting_invited_v1': (data) => (0, event_handlers_1.handleVcMeetingInvitedEvent)(ctx, data),
// Drive comment event — fires when a user adds a comment or reply on a document.
'drive.notice.comment_add_v1': (data) => (0, event_handlers_1.handleCommentEvent)(ctx, data),
// 飞书 SDK EventDispatcher.register 不支持带返回值的处理器,此处 as any 是 SDK 类型限制的变通
'card.action.trigger': ((data) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(0, event_handlers_1.handleCardActionEvent)(ctx, data)),
},
abortSignal,
});
// startWS resolves when abortSignal fires — probe result is logged inside startWS.
log(`feishu[${accountId}]: bot open_id resolved: ${lark.botOpenId ?? 'unknown'}`);
log(`feishu[${accountId}]: WebSocket client started`);
mlog.info(`websocket started for account ${accountId}`);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Start monitoring for all enabled Feishu accounts (or a single
* account when `opts.accountId` is specified).
*/
async function monitorFeishuProvider(opts = {}) {
const cfg = opts.config;
if (!cfg) {
throw new Error('Config is required for Feishu monitor');
}
// Store the original global config so plugin commands (doctor, diagnose)
// can access cross-account information even when running inside an
// account-scoped config context.
lark_client_1.LarkClient.setGlobalConfig(cfg);
const log = opts.runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
// Single-account mode.
if (opts.accountId) {
const account = (0, accounts_1.getLarkAccount)(cfg, opts.accountId);
if (!account.enabled || !account.configured) {
throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
}
await monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
});
await (0, shutdown_hooks_1.drainShutdownHooks)({ log });
return;
}
// Multi-account mode: start all enabled accounts in parallel.
const accounts = (0, accounts_1.getEnabledLarkAccounts)(cfg);
if (accounts.length === 0) {
throw new Error('No enabled Feishu accounts configured');
}
log(`feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(', ')}`);
await Promise.all(accounts.map((account) => monitorSingleAccount({
cfg,
account,
runtime: opts.runtime,
abortSignal: opts.abortSignal,
})));
await (0, shutdown_hooks_1.drainShutdownHooks)({ log });
}
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding configuration mutation helpers.
*
* Pure functions that apply Feishu channel configuration changes
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
* in CLI commands and other configuration flows.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { DmPolicy } from 'openclaw/plugin-sdk/setup';
export declare function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig;
export declare function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig;
export declare function setFeishuGroupPolicy(cfg: ClawdbotConfig, groupPolicy: 'open' | 'allowlist' | 'disabled'): ClawdbotConfig;
export declare function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig;
export declare function setFeishuGroups(cfg: ClawdbotConfig, groups: Record<string, object>): ClawdbotConfig;
export declare function parseAllowFromInput(raw: string): string[];
@@ -0,0 +1,96 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding configuration mutation helpers.
*
* Pure functions that apply Feishu channel configuration changes
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
* in CLI commands and other configuration flows.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.setFeishuDmPolicy = setFeishuDmPolicy;
exports.setFeishuAllowFrom = setFeishuAllowFrom;
exports.setFeishuGroupPolicy = setFeishuGroupPolicy;
exports.setFeishuGroupAllowFrom = setFeishuGroupAllowFrom;
exports.setFeishuGroups = setFeishuGroups;
exports.parseAllowFromInput = parseAllowFromInput;
const setup_1 = require("openclaw/plugin-sdk/setup");
// ---------------------------------------------------------------------------
// Config mutation helpers
// ---------------------------------------------------------------------------
function setFeishuDmPolicy(cfg, dmPolicy) {
const allowFrom = dmPolicy === 'open'
? (0, setup_1.addWildcardAllowFrom)(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setFeishuAllowFrom(cfg, allowFrom) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
allowFrom,
},
},
};
}
function setFeishuGroupPolicy(cfg, groupPolicy) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
groupPolicy,
},
},
};
}
function setFeishuGroupAllowFrom(cfg, groupAllowFrom) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
}
function setFeishuGroups(cfg, groups) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groups,
},
},
};
}
// ---------------------------------------------------------------------------
// Input helpers
// ---------------------------------------------------------------------------
function parseAllowFromInput(raw) {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy groupAllowFrom migration for Feishu onboarding.
*
* Handles the migration of chat_id entries (oc_xxx) from
* groupAllowFrom to the groups config, preserving the original
* semantic of "allow this group for any sender".
*/
import type { ClawdbotConfig, WizardPrompter } from 'openclaw/plugin-sdk';
/**
* Detect and migrate legacy chat_id entries in groupAllowFrom.
*
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
* which groups could use the bot.
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
*
* This function prompts the user and, if confirmed, moves chat_ids
* to the groups config and keeps only sender IDs in groupAllowFrom.
*/
export declare function migrateLegacyGroupAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig>;
@@ -0,0 +1,70 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy groupAllowFrom migration for Feishu onboarding.
*
* Handles the migration of chat_id entries (oc_xxx) from
* groupAllowFrom to the groups config, preserving the original
* semantic of "allow this group for any sender".
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.migrateLegacyGroupAllowFrom = migrateLegacyGroupAllowFrom;
const onboarding_config_1 = require("./onboarding-config.js");
/**
* Detect and migrate legacy chat_id entries in groupAllowFrom.
*
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
* which groups could use the bot.
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
*
* This function prompts the user and, if confirmed, moves chat_ids
* to the groups config and keeps only sender IDs in groupAllowFrom.
*/
async function migrateLegacyGroupAllowFrom(params) {
let next = params.cfg;
const { prompter } = params;
const existingGroupAllowFrom = next.channels?.feishu?.groupAllowFrom ?? [];
const legacyChatIds = existingGroupAllowFrom.filter((e) => String(e).startsWith('oc_'));
const senderAllowFrom = existingGroupAllowFrom.filter((e) => !String(e).startsWith('oc_'));
if (legacyChatIds.length === 0) {
return next;
}
await prompter.note([
`⚠️ Detected legacy config: groupAllowFrom contains chat_ids (${legacyChatIds.join(', ')})`,
'',
'Old semantic: groupAllowFrom controlled which groups could use the bot.',
'New semantic: groupAllowFrom is for SENDER filtering (open_ids like ou_xxx).',
'',
'Recommended migration:',
` 1. Move chat_ids (oc_xxx) → channels.feishu.groups`,
` 2. Keep sender IDs (ou_xxx) in groupAllowFrom`,
].join('\n'), 'Legacy config detected');
const migrate = await prompter.confirm({
message: `Migrate ${legacyChatIds.length} chat_id(s) to groups config?`,
initialValue: true,
});
if (migrate) {
const existingGroups = next.channels?.feishu?.groups ?? {};
const migratedGroups = {
...existingGroups,
};
for (const chatId of legacyChatIds) {
if (!migratedGroups[String(chatId)]) {
migratedGroups[String(chatId)] = {
enabled: true,
groupPolicy: 'open',
};
}
}
next = (0, onboarding_config_1.setFeishuGroups)(next, migratedGroups);
next = (0, onboarding_config_1.setFeishuGroupAllowFrom)(next, senderAllowFrom);
await prompter.note(`✅ Migrated: ${legacyChatIds.length} chat_id(s) moved to groups, ` +
`${senderAllowFrom.length} sender(s) kept in groupAllowFrom`, 'Migration complete');
}
else {
await prompter.note('Skipped migration. Please update config manually to avoid issues.', 'Migration skipped');
}
return next;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
*
* Implements the ChannelOnboardingAdapter interface so the `openclaw
* setup` wizard can configure Feishu credentials, domain, group
* policies, and DM allowlists interactively.
*/
import type { ChannelSetupWizardAdapter } from 'openclaw/plugin-sdk/setup';
export declare const feishuOnboardingAdapter: ChannelSetupWizardAdapter;
@@ -0,0 +1,300 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
*
* Implements the ChannelOnboardingAdapter interface so the `openclaw
* setup` wizard can configure Feishu credentials, domain, group
* policies, and DM allowlists interactively.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.feishuOnboardingAdapter = void 0;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const setup_1 = require("openclaw/plugin-sdk/setup");
const accounts_1 = require("../core/accounts.js");
const probe_1 = require("./probe.js");
const onboarding_config_1 = require("./onboarding-config.js");
const onboarding_migrate_1 = require("./onboarding-migrate.js");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const channel = 'feishu';
// ---------------------------------------------------------------------------
// Prompter helpers
// ---------------------------------------------------------------------------
async function noteFeishuCredentialHelp(prompter) {
await prompter.note([
'1) Go to Feishu Open Platform (open.feishu.cn)',
'2) Create a self-built app',
'3) Get App ID and App Secret from Credentials page',
'4) Enable required permissions: im:message, im:chat, contact:user.base:readonly',
'5) Publish the app or add it to a test group',
'Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.',
`Docs: ${(0, setup_1.formatDocsLink)('/channels/feishu', 'feishu')}`,
].join('\n'), 'Feishu credentials');
}
async function promptFeishuAllowFrom(params) {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note([
'Allowlist Feishu DMs by open_id or user_id.',
'You can find user open_id in Feishu admin console or via API.',
'Examples:',
'- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
].join('\n'), 'Feishu allowlist');
while (true) {
const entry = await params.prompter.text({
message: 'Feishu allowFrom (user open_ids)',
placeholder: 'ou_xxxxx, ou_yyyyy',
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
});
const parts = (0, onboarding_config_1.parseAllowFromInput)(String(entry));
if (parts.length === 0) {
await params.prompter.note('Enter at least one user.', 'Feishu allowlist');
continue;
}
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts])];
return (0, onboarding_config_1.setFeishuAllowFrom)(params.cfg, unique);
}
}
// ---------------------------------------------------------------------------
// Credential acquisition
// ---------------------------------------------------------------------------
async function acquireCredentials(params) {
const { prompter, feishuCfg } = params;
let next = params.cfg;
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
const canUseEnv = Boolean(!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim());
let appId = null;
let appSecret = null;
if (canUseEnv) {
const keepEnv = await prompter.confirm({
message: 'FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?',
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
}
else {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
}
else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: 'Feishu credentials already configured. Keep them?',
initialValue: true,
});
if (!keep) {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
}
else {
appId = String(await prompter.text({
message: 'Enter Feishu App ID',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
appSecret = String(await prompter.text({
message: 'Enter Feishu App Secret',
validate: (value) => (value?.trim() ? undefined : 'Required'),
})).trim();
}
return { cfg: next, appId, appSecret };
}
// ---------------------------------------------------------------------------
// DM policy
// ---------------------------------------------------------------------------
const dmPolicy = {
label: 'Feishu',
channel,
policyKey: 'channels.feishu.dmPolicy',
allowFromKey: 'channels.feishu.allowFrom',
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? 'pairing',
setPolicy: (cfg, policy) => (0, onboarding_config_1.setFeishuDmPolicy)(cfg, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------
exports.feishuOnboardingAdapter = {
channel,
// -----------------------------------------------------------------------
// getStatus
// -----------------------------------------------------------------------
getStatus: async ({ cfg }) => {
const feishuCfg = cfg.channels?.feishu;
const configured = Boolean((0, accounts_1.getLarkCredentials)(feishuCfg));
// Attempt a live probe when credentials are present.
let probeResult = null;
if (configured && feishuCfg) {
try {
probeResult = await (0, probe_1.probeFeishu)(feishuCfg);
}
catch {
// Ignore probe errors -- status degrades gracefully.
}
}
const statusLines = [];
if (!configured) {
statusLines.push('Feishu: needs app credentials');
}
else if (probeResult?.ok) {
statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? 'bot'}`);
}
else {
statusLines.push('Feishu: configured (connection not verified)');
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? 'configured' : 'needs app creds',
quickstartScore: configured ? 2 : 0,
};
},
// -----------------------------------------------------------------------
// configure
// -----------------------------------------------------------------------
configure: async ({ cfg, prompter }) => {
const feishuCfg = cfg.channels?.feishu;
const resolved = (0, accounts_1.getLarkCredentials)(feishuCfg);
let next = cfg;
// Show credential help if nothing is configured yet.
if (!resolved) {
await noteFeishuCredentialHelp(prompter);
}
// --- Credential acquisition ---
const creds = await acquireCredentials({ cfg: next, prompter, feishuCfg });
next = creds.cfg;
// --- Persist and test credentials ---
if (creds.appId && creds.appSecret) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
enabled: true,
appId: creds.appId,
appSecret: creds.appSecret,
},
},
};
const testCfg = next.channels?.feishu;
try {
const probe = await (0, probe_1.probeFeishu)(testCfg);
if (probe.ok) {
await prompter.note(`Connected as ${probe.botName ?? probe.botOpenId ?? 'bot'}`, 'Feishu connection test');
}
else {
await prompter.note(`Connection failed: ${probe.error ?? 'unknown error'}`, 'Feishu connection test');
}
}
catch (err) {
await prompter.note(`Connection test failed: ${String(err)}`, 'Feishu connection test');
}
}
// --- Domain selection ---
const currentDomain = next.channels?.feishu?.domain ?? 'feishu';
const domain = await prompter.select({
message: 'Which Feishu domain?',
options: [
{ value: 'feishu', label: 'Feishu (feishu.cn) - China' },
{ value: 'lark', label: 'Lark (larksuite.com) - International' },
],
initialValue: currentDomain,
});
if (domain) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain,
},
},
};
}
// --- Legacy migration ---
next = await (0, onboarding_migrate_1.migrateLegacyGroupAllowFrom)({ cfg: next, prompter });
// --- Group policy ---
const groupPolicy = await prompter.select({
message: 'Group chat policy — which groups can interact with the bot?',
options: [
{
value: 'allowlist',
label: 'Allowlist — only groups listed in `groups` config (default)',
},
{
value: 'open',
label: 'Open — any group (requires @mention)',
},
{
value: 'disabled',
label: 'Disabled — no group interactions',
},
],
initialValue: next.channels?.feishu?.groupPolicy ?? 'allowlist',
});
if (groupPolicy) {
next = (0, onboarding_config_1.setFeishuGroupPolicy)(next, groupPolicy);
}
// --- Group sender allowlist ---
if (groupPolicy !== 'disabled') {
const existing = next.channels?.feishu?.groupAllowFrom ?? [];
const entry = await prompter.text({
message: 'Group sender allowlist — which users can trigger the bot in allowed groups? (user open_ids)',
placeholder: 'ou_xxxxx, ou_yyyyy',
initialValue: existing.length > 0 ? existing.map(String).join(', ') : undefined,
});
if (entry) {
const parts = (0, onboarding_config_1.parseAllowFromInput)(String(entry));
if (parts.length > 0) {
next = (0, onboarding_config_1.setFeishuGroupAllowFrom)(next, parts);
}
}
else if (groupPolicy === 'allowlist') {
await prompter.note('Empty sender list + allowlist = nobody can trigger. ' +
"Use groupPolicy 'open' if you want anyone in allowed groups to trigger.", 'Note');
}
}
return { cfg: next, accountId: account_id_1.DEFAULT_ACCOUNT_ID };
},
// -----------------------------------------------------------------------
// dmPolicy
// -----------------------------------------------------------------------
dmPolicy,
// -----------------------------------------------------------------------
// disable
// -----------------------------------------------------------------------
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ChannelPlugin interface implementation for the Lark/Feishu channel.
*
* This is the top-level entry point that the OpenClaw plugin system uses to
* discover capabilities, resolve accounts, obtain outbound adapters, and
* start the inbound event gateway.
*/
import type { ChannelPlugin } from 'openclaw/plugin-sdk';
import type { LarkAccount } from '../core/types';
export declare const feishuPlugin: ChannelPlugin<LarkAccount>;
@@ -0,0 +1,310 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* ChannelPlugin interface implementation for the Lark/Feishu channel.
*
* This is the top-level entry point that the OpenClaw plugin system uses to
* discover capabilities, resolve accounts, obtain outbound adapters, and
* start the inbound event gateway.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.feishuPlugin = void 0;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const channel_status_1 = require("openclaw/plugin-sdk/channel-status");
const accounts_1 = require("../core/accounts.js");
const outbound_1 = require("../messaging/outbound/outbound.js");
const actions_1 = require("../messaging/outbound/actions.js");
const policy_1 = require("../messaging/inbound/policy.js");
const lark_client_1 = require("../core/lark-client.js");
const send_1 = require("../messaging/outbound/send.js");
const targets_1 = require("../core/targets.js");
const onboarding_auth_1 = require("../tools/onboarding-auth.js");
const lark_logger_1 = require("../core/lark-logger.js");
const config_schema_1 = require("../core/config-schema.js");
const config_adapter_1 = require("./config-adapter.js");
const directory_1 = require("./directory.js");
const pluginLog = (0, lark_logger_1.larkLogger)('channel/plugin');
/** 状态轮询的探针结果缓存时长(5 分钟)。 */
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert nullable SDK params to optional params for directory functions. */
function adaptDirectoryParams(params) {
return {
cfg: params.cfg,
query: params.query ?? undefined,
limit: params.limit ?? undefined,
accountId: params.accountId ?? undefined,
};
}
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta = {
id: 'feishu',
label: 'Feishu',
selectionLabel: 'Lark/Feishu (\u98DE\u4E66)',
docsPath: '/channels/feishu',
docsLabel: 'feishu',
blurb: '\u98DE\u4E66/Lark enterprise messaging.',
aliases: ['lark'],
order: 70,
};
// ---------------------------------------------------------------------------
// Channel plugin definition
// ---------------------------------------------------------------------------
exports.feishuPlugin = {
id: 'feishu',
meta: {
...meta,
},
// -------------------------------------------------------------------------
// Pairing
// -------------------------------------------------------------------------
pairing: {
idLabel: 'feishuUserId',
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ''),
notifyApproval: async ({ cfg, id }) => {
const accountId = (0, accounts_1.getDefaultLarkAccountId)(cfg);
pluginLog.info('notifyApproval called', { id, accountId });
// 1. 发送配对成功消息(保持现有行为)
await (0, send_1.sendMessageFeishu)({
cfg,
to: id,
text: channel_status_1.PAIRING_APPROVED_MESSAGE,
accountId,
});
// 2. 触发 onboarding
try {
await (0, onboarding_auth_1.triggerOnboarding)({ cfg, userOpenId: id, accountId });
pluginLog.info('onboarding completed', { id });
}
catch (err) {
pluginLog.warn('onboarding failed', { id, error: String(err) });
}
},
},
// -------------------------------------------------------------------------
// Capabilities
// -------------------------------------------------------------------------
capabilities: {
chatTypes: ['direct', 'group'],
media: true,
reactions: true,
threads: true,
polls: false,
nativeCommands: true,
blockStreaming: true,
},
// -------------------------------------------------------------------------
// Agent prompt
// -------------------------------------------------------------------------
agentPrompt: {
messageToolHints: () => [
'- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.',
'- Feishu supports interactive cards for rich messages.',
'- Feishu reactions use UPPERCASE emoji type names (e.g. `OK`,`THUMBSUP`,`THANKS`,`MUSCLE`,`FINGERHEART`,`APPLAUSE`,`FISTBUMP`,`JIAYI`,`DONE`,`SMILE`,`BLUSH` ), not Unicode emoji characters.',
"- Feishu `action=delete`/`action=unsend` only deletes messages sent by the bot. When the user quotes a message and says 'delete this', use the **quoted message's** message_id, not the user's own message_id.",
],
},
// -------------------------------------------------------------------------
// Groups
// -------------------------------------------------------------------------
groups: {
resolveToolPolicy: policy_1.resolveFeishuGroupToolPolicy,
},
// -------------------------------------------------------------------------
// Reload
// -------------------------------------------------------------------------
reload: { configPrefixes: ['channels.feishu'] },
// -------------------------------------------------------------------------
// Config schema (JSON Schema)
// -------------------------------------------------------------------------
configSchema: {
schema: config_schema_1.FEISHU_CONFIG_JSON_SCHEMA,
},
// -------------------------------------------------------------------------
// Config adapter
// -------------------------------------------------------------------------
config: {
listAccountIds: (cfg) => (0, accounts_1.getLarkAccountIds)(cfg),
resolveAccount: (cfg, accountId) => (0, accounts_1.getLarkAccount)(cfg, accountId),
defaultAccountId: (cfg) => (0, accounts_1.getDefaultLarkAccountId)(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
return (0, config_adapter_1.setAccountEnabled)(cfg, accountId, enabled);
},
deleteAccount: ({ cfg, accountId }) => {
return (0, config_adapter_1.deleteAccount)(cfg, accountId);
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
brand: account.brand,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
},
// -------------------------------------------------------------------------
// Security
// -------------------------------------------------------------------------
security: {
collectWarnings: ({ cfg, accountId }) => (0, config_adapter_1.collectFeishuSecurityWarnings)({ cfg, accountId: accountId ?? account_id_1.DEFAULT_ACCOUNT_ID }),
},
// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
setup: {
resolveAccountId: () => account_id_1.DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
return (0, config_adapter_1.applyAccountConfig)(cfg, accountId, { enabled: true });
},
},
// -------------------------------------------------------------------------
// Messaging
// -------------------------------------------------------------------------
messaging: {
normalizeTarget: (raw) => (0, targets_1.normalizeFeishuTarget)(raw) ?? undefined,
targetResolver: {
looksLikeId: targets_1.looksLikeFeishuId,
hint: '<chatId|user:openId|chat:chatId>',
},
},
// -------------------------------------------------------------------------
// Directory
// -------------------------------------------------------------------------
directory: {
self: async () => null,
listPeers: async (p) => (0, directory_1.listFeishuDirectoryPeers)(adaptDirectoryParams(p)),
listGroups: async (p) => (0, directory_1.listFeishuDirectoryGroups)(adaptDirectoryParams(p)),
listPeersLive: async (p) => (0, directory_1.listFeishuDirectoryPeersLive)(adaptDirectoryParams(p)),
listGroupsLive: async (p) => (0, directory_1.listFeishuDirectoryGroupsLive)(adaptDirectoryParams(p)),
},
// -------------------------------------------------------------------------
// Outbound
// -------------------------------------------------------------------------
outbound: outbound_1.feishuOutbound,
// -------------------------------------------------------------------------
// Threading
// -------------------------------------------------------------------------
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: (0, targets_1.normalizeFeishuTarget)(context.To ?? '') ?? undefined,
currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
currentMessageId: context.CurrentMessageId,
hasRepliedRef,
}),
},
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
actions: actions_1.feishuMessageActions,
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
status: {
defaultRuntime: {
accountId: account_id_1.DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
port: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => {
return await lark_client_1.LarkClient.fromAccount(account).probe({ maxAgeMs: PROBE_CACHE_TTL_MS });
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
brand: account.brand,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
// -------------------------------------------------------------------------
// Gateway
// -------------------------------------------------------------------------
gateway: {
startAccount: async (ctx) => {
const { monitorFeishuProvider } = await Promise.resolve().then(() => __importStar(require('./monitor.js')));
const account = (0, accounts_1.getLarkAccount)(ctx.cfg, ctx.accountId);
const port = account.config?.webhookPort ?? null;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? 'websocket'})`);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
},
stopAccount: async (ctx) => {
ctx.log?.info(`stopping feishu[${ctx.accountId}]`);
await lark_client_1.LarkClient.clearCache(ctx.accountId);
ctx.log?.info(`stopped feishu[${ctx.accountId}]`);
},
},
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type LarkClientCredentials } from '../core/lark-client';
import type { FeishuProbeResult } from './types';
/**
* Probe the Feishu bot connection by calling the bot/v3/info API.
*
* Returns a result indicating whether the bot is reachable and its
* basic identity (name, open_id). Used by onboarding and status
* checks to verify credentials before committing them to config.
*/
export declare function probeFeishu(credentials?: LarkClientCredentials): Promise<FeishuProbeResult>;
@@ -0,0 +1,24 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.probeFeishu = probeFeishu;
const lark_client_1 = require("../core/lark-client.js");
/**
* Probe the Feishu bot connection by calling the bot/v3/info API.
*
* Returns a result indicating whether the bot is reachable and its
* basic identity (name, open_id). Used by onboarding and status
* checks to verify credentials before committing them to config.
*/
async function probeFeishu(credentials) {
if (!credentials?.appId || !credentials?.appSecret) {
return {
ok: false,
error: 'missing credentials (appId, appSecret)',
};
}
return lark_client_1.LarkClient.fromCredentials(credentials).probe();
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Channel type definitions for the Lark/Feishu channel plugin.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { LarkClient } from '../core/lark-client';
import type { MessageDedup } from '../messaging/inbound/dedup';
export type { FeishuProbeResult } from '../core/types';
export interface MonitorFeishuOpts {
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
accountId?: string;
}
export interface FeishuDirectoryPeer {
kind: 'user';
id: string;
name?: string;
}
export interface FeishuDirectoryGroup {
kind: 'group';
id: string;
name?: string;
}
export interface MonitorContext {
cfg: ClawdbotConfig;
lark: LarkClient;
accountId: string;
chatHistories: Map<string, HistoryEntry[]>;
messageDedup: MessageDedup;
runtime?: RuntimeEnv;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
@@ -0,0 +1,8 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Channel type definitions for the Lark/Feishu channel plugin.
*/
Object.defineProperty(exports, "__esModule", { value: true });
+21
View File
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* feishu_auth command — 飞书用户权限批量授权命令实现
*
* 直接复用 onboarding-auth.ts 的 triggerOnboarding() 函数。
* 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
*/
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
import type { FeishuLocale } from './locale';
/**
* 执行飞书用户权限批量授权命令
* 直接调用 triggerOnboarding(),包含 owner 检查
*/
export declare function runFeishuAuth(config: OpenClawConfig, locale?: FeishuLocale): Promise<string>;
/**
* 运行飞书授权命令,同时生成中英双语结果。
* 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
*/
export declare function runFeishuAuthI18n(config: OpenClawConfig): Promise<Record<FeishuLocale, string>>;
@@ -0,0 +1,165 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* feishu_auth command — 飞书用户权限批量授权命令实现
*
* 直接复用 onboarding-auth.ts 的 triggerOnboarding() 函数。
* 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runFeishuAuth = runFeishuAuth;
exports.runFeishuAuthI18n = runFeishuAuthI18n;
const onboarding_auth_1 = require("../tools/onboarding-auth.js");
const lark_ticket_1 = require("../core/lark-ticket.js");
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
const app_scope_checker_1 = require("../core/app-scope-checker.js");
const token_store_1 = require("../core/token-store.js");
const tool_scopes_1 = require("../core/tool-scopes.js");
const owner_policy_1 = require("../core/owner-policy.js");
const domains_1 = require("../core/domains.js");
// ---------------------------------------------------------------------------
// I18n text map
// ---------------------------------------------------------------------------
const T = {
zh_cn: {
noIdentity: '❌ 无法获取用户身份,请在飞书对话中使用此命令',
accountIncomplete: (accountId) => `❌ 账号 ${accountId} 配置不完整`,
missingSelfManage: (link) => `❌ 应用缺少核心权限 application:application:self_manage,无法查询可授权 scope 列表。\n\n请管理员在飞书开放平台开通此权限后重试:[申请权限](${link})`,
ownerOnly: '❌ 此命令仅限应用 owner 执行\n\n如需授权,请联系应用管理员。',
missingOfflineAccess: (link) => `❌ 应用缺少核心权限 offline_access,无法查询可授权 scope 列表。\n\n请管理员在飞书开放平台开通此权限后重试:[申请权限](${link})`,
noUserScopes: '当前应用未开通任何用户级权限,无需授权。',
allAuthorized: (count) => `✅ 您已授权所有可用权限(共 ${count} 个),无需重复授权。`,
authSent: '✅ 已发送授权请求',
},
en_us: {
noIdentity: '❌ Unable to identify user. Please use this command in a Feishu conversation.',
accountIncomplete: (accountId) => `❌ Account ${accountId} configuration is incomplete`,
missingSelfManage: (link) => `❌ App is missing the core permission application:application:self_manage and cannot query available scopes.\n\nPlease ask an admin to grant this permission on the Feishu Open Platform: [Apply](${link})`,
ownerOnly: '❌ This command is restricted to the app owner.\n\nPlease contact the app admin for authorization.',
missingOfflineAccess: (link) => `❌ App is missing the core permission offline_access and cannot query available scopes.\n\nPlease ask an admin to grant this permission on the Feishu Open Platform: [Apply](${link})`,
noUserScopes: 'No user-level permissions are enabled for this app. Authorization is not needed.',
allAuthorized: (count) => `✅ You have authorized all available permissions (${count} total). No re-authorization needed.`,
authSent: '✅ Authorization request sent',
},
};
/**
* Format an AuthResult into a locale-specific message string.
*/
function formatAuthResult(result, locale) {
const t = T[locale];
switch (result.kind) {
case 'no_identity':
return t.noIdentity;
case 'account_incomplete':
return t.accountIncomplete(result.accountId);
case 'missing_self_manage':
return t.missingSelfManage(result.link);
case 'owner_only':
return t.ownerOnly;
case 'missing_offline_access':
return t.missingOfflineAccess(result.link);
case 'no_user_scopes':
return t.noUserScopes;
case 'all_authorized':
return t.allAuthorized(result.count);
case 'auth_sent':
return t.authSent;
}
}
// ---------------------------------------------------------------------------
// Core logic (executes side-effects exactly once)
// ---------------------------------------------------------------------------
/**
* Execute the auth command logic, including side-effects (triggerOnboarding).
* Returns a discriminated result that can be formatted into any locale.
*/
async function executeFeishuAuth(config) {
const ticket = (0, lark_ticket_1.getTicket)();
const senderOpenId = ticket?.senderOpenId;
if (!senderOpenId) {
return { kind: 'no_identity' };
}
// 提前检查 owner 身份,给出明确提示
const acct = (0, accounts_1.getLarkAccount)(config, ticket.accountId);
if (!acct.configured) {
return { kind: 'account_incomplete', accountId: ticket.accountId };
}
const sdk = lark_client_1.LarkClient.fromAccount(acct).sdk;
const { appId } = acct;
const openDomain = (0, domains_1.openPlatformDomain)(acct.brand);
try {
await (0, app_scope_checker_1.getAppInfo)(sdk, appId);
}
catch {
const link = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
return { kind: 'missing_self_manage', link };
}
// Owner 检查(fail-close: 授权命令安全优先)
try {
await (0, owner_policy_1.assertOwnerAccessStrict)(acct, sdk, senderOpenId);
}
catch (err) {
if (err instanceof owner_policy_1.OwnerAccessDeniedError) {
return { kind: 'owner_only' };
}
throw err;
}
// 预检:是否还有未授权的 scope
let appScopes;
try {
appScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'user');
}
catch {
const link = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
return { kind: 'missing_self_manage', link };
}
// offline_access 预检 — OAuth 必须的前提权限
const allScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId);
if (allScopes.length > 0 && !allScopes.includes('offline_access')) {
const link = `${openDomain}/app/${appId}/auth?q=offline_access&op_from=feishu-openclaw&token_type=user`;
return { kind: 'missing_offline_access', link };
}
appScopes = (0, tool_scopes_1.filterSensitiveScopes)(appScopes);
if (appScopes.length === 0) {
return { kind: 'no_user_scopes' };
}
const existing = await (0, token_store_1.getStoredToken)(appId, senderOpenId);
const tokenValid = existing && (0, token_store_1.tokenStatus)(existing) !== 'expired';
const grantedScopes = new Set(tokenValid ? (existing.scope?.split(/\s+/).filter(Boolean) ?? []) : []);
const missingScopes = appScopes.filter((s) => !grantedScopes.has(s));
if (missingScopes.length === 0) {
return { kind: 'all_authorized', count: appScopes.length };
}
// 调用 triggerOnboarding 执行批量授权(副作用,只执行一次)
await (0, onboarding_auth_1.triggerOnboarding)({
cfg: config,
userOpenId: senderOpenId,
accountId: ticket.accountId,
});
return { kind: 'auth_sent' };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* 执行飞书用户权限批量授权命令
* 直接调用 triggerOnboarding(),包含 owner 检查
*/
async function runFeishuAuth(config, locale = 'zh_cn') {
const result = await executeFeishuAuth(config);
return formatAuthResult(result, locale);
}
/**
* 运行飞书授权命令,同时生成中英双语结果。
* 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
*/
async function runFeishuAuthI18n(config) {
const result = await executeFeishuAuth(config);
return {
zh_cn: formatAuthResult(result, 'zh_cn'),
en_us: formatAuthResult(result, 'en_us'),
};
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Diagnostic module for the Lark/Feishu plugin.
*
* Collects environment info, account configuration, API connectivity,
* app permissions, tool registration state, and recent error logs into
* a structured report that users can share with developers for
* remote troubleshooting.
*/
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
interface DiagLogger {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
interface DiagCheckResult {
name: string;
status: CheckStatus;
message: string;
details?: string;
}
interface AccountDiagResult {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
appId?: string;
brand: string;
checks: DiagCheckResult[];
}
interface DiagReport {
timestamp: string;
environment: {
nodeVersion: string;
platform: string;
arch: string;
pluginVersion: string;
};
accounts: AccountDiagResult[];
toolsRegistered: string[];
recentErrors: string[];
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
checks: DiagCheckResult[];
}
export declare function runDiagnosis(params: {
config: OpenClawConfig;
logger?: DiagLogger;
}): Promise<DiagReport>;
export declare function formatDiagReportText(report: DiagReport): string;
/**
* Extract all log lines tagged with a specific message_id from gateway.log.
*
* Scans the last 1MB of the log file for lines containing `[msg:{messageId}]`.
* Returns matching lines in chronological order.
*/
export declare function traceByMessageId(messageId: string): Promise<string[]>;
/**
* Format trace output for CLI display.
*/
export declare function formatTraceOutput(lines: string[], messageId: string): string;
/**
* Analyze trace log lines and produce a structured CLI report.
*/
export declare function analyzeTrace(lines: string[], _messageId: string): string;
export declare function formatDiagReportCli(report: DiagReport): string;
export {};
@@ -0,0 +1,848 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Diagnostic module for the Lark/Feishu plugin.
*
* Collects environment info, account configuration, API connectivity,
* app permissions, tool registration state, and recent error logs into
* a structured report that users can share with developers for
* remote troubleshooting.
*/
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.runDiagnosis = runDiagnosis;
exports.formatDiagReportText = formatDiagReportText;
exports.traceByMessageId = traceByMessageId;
exports.formatTraceOutput = formatTraceOutput;
exports.analyzeTrace = analyzeTrace;
exports.formatDiagReportCli = formatDiagReportCli;
const fs = __importStar(require("node:fs/promises"));
const path = __importStar(require("node:path"));
const os = __importStar(require("node:os"));
const probe_1 = require("../channel/probe.js");
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
/**
* Resolve the global config for cross-account operations.
* See doctor.ts for rationale.
*/
function resolveGlobalConfig(config) {
return lark_client_1.LarkClient.globalConfig ?? config;
}
const api_error_1 = require("../core/api-error.js");
const tools_config_1 = require("../core/tools-config.js");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PLUGIN_VERSION = '2026.2.10';
const LOG_READ_BYTES = 256 * 1024; // read last 256KB of log
const MAX_ERROR_LINES = 20;
/** Matches a timestamped log line: 2026-02-13T09:23:35.038Z [level]: ... */
const TIMESTAMPED_LINE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const ERROR_LEVEL_RE = /\[error\]|\[warn\]/i;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function maskSecret(secret) {
if (!secret)
return '(未设置)';
if (secret.length <= 4)
return '****';
return secret.slice(0, 4) + '****';
}
async function extractRecentErrors(logPath) {
try {
await fs.access(logPath);
}
catch {
return [];
}
try {
const stat = await fs.stat(logPath);
const readSize = Math.min(stat.size, LOG_READ_BYTES);
const fd = await fs.open(logPath, 'r');
try {
const buffer = Buffer.alloc(readSize);
await fd.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
const content = buffer.toString('utf-8');
const lines = content.split('\n').filter(Boolean);
// Only pick timestamped log entries at error/warn level,
// ignoring stack trace fragments and other noise.
const errorLines = lines.filter((line) => TIMESTAMPED_LINE_RE.test(line) && ERROR_LEVEL_RE.test(line));
return errorLines.slice(-MAX_ERROR_LINES);
}
finally {
await fd.close();
}
}
catch {
return [];
}
}
async function checkAppScopes(client) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await client.application.scope.list({});
(0, api_error_1.assertLarkOk)(res);
const scopes = res.data?.scopes ?? [];
const granted = scopes.filter((s) => s.grant_status === 1);
const pending = scopes.filter((s) => s.grant_status !== 1);
return {
granted: granted.length,
pending: pending.length,
summary: `${granted.length} 已授权, ${pending.length} 待授权`,
};
}
function detectRegisteredTools(config) {
const accounts = (0, accounts_1.getEnabledLarkAccounts)(config);
if (accounts.length === 0)
return [];
const toolsCfg = (0, tools_config_1.resolveAnyEnabledToolsConfig)(accounts);
const tools = [];
if (toolsCfg.doc)
tools.push('feishu_doc');
if (toolsCfg.scopes)
tools.push('feishu_app_scopes');
if (toolsCfg.wiki)
tools.push('feishu_wiki');
if (toolsCfg.drive)
tools.push('feishu_drive');
if (toolsCfg.perm)
tools.push('feishu_perm');
tools.push('feishu_bitable_get_meta', 'feishu_bitable_list_fields', 'feishu_bitable_list_records', 'feishu_bitable_get_record', 'feishu_bitable_create_record', 'feishu_bitable_update_record');
tools.push('feishu_task');
tools.push('feishu_calendar');
return tools;
}
async function diagnoseAccount(account) {
const checks = [];
const result = {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
appId: account.appId ?? '(未设置)',
brand: account.brand,
checks,
};
// A1: Credentials
checks.push({
name: '凭证完整性',
status: account.configured ? 'pass' : 'fail',
message: account.configured
? `appId: ${account.appId}, appSecret: ${maskSecret(account.appSecret)}`
: '缺少 appId 或 appSecret',
});
// A2: Enabled
checks.push({
name: '账户启用',
status: account.enabled ? 'pass' : 'warn',
message: account.enabled ? '已启用' : '已禁用',
});
if (!account.configured || !account.appId || !account.appSecret) {
checks.push({
name: 'API 连通性',
status: 'skip',
message: '凭证未配置,跳过',
});
return result;
}
// A3: API connectivity via probe
try {
const probeResult = await (0, probe_1.probeFeishu)({
accountId: account.accountId,
appId: account.appId,
appSecret: account.appSecret,
brand: account.brand,
});
checks.push({
name: 'API 连通性',
status: probeResult.ok ? 'pass' : 'fail',
message: probeResult.ok ? `连接成功` : `连接失败: ${probeResult.error}`,
});
// A4: Bot info
if (probeResult.ok) {
checks.push({
name: 'Bot 信息',
status: probeResult.botName ? 'pass' : 'warn',
message: probeResult.botName ? `${probeResult.botName} (${probeResult.botOpenId})` : '未获取到 Bot 名称',
});
}
}
catch (err) {
checks.push({
name: 'API 连通性',
status: 'fail',
message: `探测异常: ${err instanceof Error ? err.message : String(err)}`,
});
}
// A5: App scopes
try {
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
const scopesResult = await checkAppScopes(client);
checks.push({
name: '应用权限',
status: scopesResult.pending > 0 ? 'warn' : 'pass',
message: scopesResult.summary,
details: scopesResult.pending > 0 ? '存在未授权的权限,可能影响部分功能' : undefined,
});
}
catch (err) {
checks.push({
name: '应用权限',
status: 'warn',
message: `权限检查失败: ${(0, api_error_1.formatLarkError)(err)}`,
});
}
// A6: Brand
checks.push({
name: '品牌配置',
status: 'pass',
message: `brand: ${account.brand}`,
});
return result;
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
async function runDiagnosis(params) {
const { config } = params;
// Use the global config to enumerate all accounts — the passed-in
// config may be account-scoped (accounts map stripped).
const globalCfg = resolveGlobalConfig(config);
const globalChecks = [];
// -- Environment --
const nodeVer = parseInt(process.version.slice(1), 10);
globalChecks.push({
name: 'Node.js 版本',
status: nodeVer >= 18 ? 'pass' : 'warn',
message: process.version,
details: nodeVer < 18 ? '建议升级到 Node.js 18+' : undefined,
});
// -- Account count --
const accountIds = (0, accounts_1.getLarkAccountIds)(globalCfg);
globalChecks.push({
name: '飞书账户数量',
status: accountIds.length > 0 ? 'pass' : 'fail',
message: `${accountIds.length} 个账户`,
});
// -- Log file --
const logPath = path.join(os.homedir(), '.openclaw', 'logs', 'gateway.log');
let logExists = false;
try {
await fs.access(logPath);
logExists = true;
}
catch {
// noop
}
globalChecks.push({
name: '日志文件',
status: logExists ? 'pass' : 'warn',
message: logExists ? logPath : `未找到: ${logPath}`,
});
// -- Per-account diagnosis (sequential to avoid rate limits) --
const accountResults = [];
for (const id of accountIds) {
const account = (0, accounts_1.getLarkAccount)(globalCfg, id);
const result = await diagnoseAccount(account);
accountResults.push(result);
}
// -- Tools --
const tools = detectRegisteredTools(globalCfg);
// -- Recent errors --
const recentErrors = await extractRecentErrors(logPath);
globalChecks.push({
name: '最近错误日志',
status: recentErrors.length > 0 ? 'warn' : 'pass',
message: recentErrors.length > 0 ? `发现 ${recentErrors.length} 条错误` : '无最近错误',
});
// -- Overall status --
const allChecks = [...globalChecks, ...accountResults.flatMap((a) => a.checks)];
const hasFail = allChecks.some((c) => c.status === 'fail');
const hasWarn = allChecks.some((c) => c.status === 'warn');
return {
timestamp: new Date().toISOString(),
environment: {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
pluginVersion: PLUGIN_VERSION,
},
accounts: accountResults,
toolsRegistered: tools,
recentErrors,
overallStatus: hasFail ? 'unhealthy' : hasWarn ? 'degraded' : 'healthy',
checks: globalChecks,
};
}
// ---------------------------------------------------------------------------
// Formatting — plain text (chat command)
// ---------------------------------------------------------------------------
const STATUS_LABEL = {
pass: '[PASS]',
warn: '[WARN]',
fail: '[FAIL]',
skip: '[SKIP]',
};
function formatCheck(c) {
let line = ` ${STATUS_LABEL[c.status]} ${c.name}: ${c.message}`;
if (c.details) {
line += `\n ${c.details}`;
}
return line;
}
function formatDiagReportText(report) {
const lines = [];
const sep = '====================================';
lines.push(sep);
lines.push(' 飞书插件诊断报告');
lines.push(` ${report.timestamp}`);
lines.push(sep);
lines.push('');
// Environment
lines.push('【环境信息】');
lines.push(` Node.js: ${report.environment.nodeVersion}`);
lines.push(` 插件版本: ${report.environment.pluginVersion}`);
lines.push(` 系统: ${report.environment.platform} ${report.environment.arch}`);
lines.push('');
// Global checks
lines.push('【全局检查】');
for (const c of report.checks) {
lines.push(formatCheck(c));
}
lines.push('');
// Per-account
for (const acct of report.accounts) {
lines.push(`【账户: ${acct.accountId}`);
if (acct.name)
lines.push(` 名称: ${acct.name}`);
lines.push(` App ID: ${acct.appId}`);
lines.push(` 品牌: ${acct.brand}`);
lines.push('');
for (const c of acct.checks) {
lines.push(formatCheck(c));
}
lines.push('');
}
// Tools
lines.push('【工具注册】');
if (report.toolsRegistered.length > 0) {
lines.push(` ${report.toolsRegistered.join(', ')}`);
lines.push(`${report.toolsRegistered.length}`);
}
else {
lines.push(' 无工具注册(未找到已配置的账户)');
}
lines.push('');
// Recent errors
if (report.recentErrors.length > 0) {
lines.push(`【最近错误】(${report.recentErrors.length} 条)`);
for (let i = 0; i < report.recentErrors.length; i++) {
lines.push(` ${i + 1}. ${report.recentErrors[i]}`);
}
lines.push('');
}
// Overall
const statusMap = {
healthy: 'HEALTHY',
degraded: 'DEGRADED (存在警告)',
unhealthy: 'UNHEALTHY (存在失败项)',
};
lines.push(sep);
lines.push(` 总体状态: ${statusMap[report.overallStatus]}`);
lines.push(sep);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Formatting — ANSI colored (CLI)
// ---------------------------------------------------------------------------
const ANSI = {
reset: '\x1b[0m',
bold: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
gray: '\x1b[90m',
};
const STATUS_LABEL_CLI = {
pass: `${ANSI.green}[PASS]${ANSI.reset}`,
warn: `${ANSI.yellow}[WARN]${ANSI.reset}`,
fail: `${ANSI.red}[FAIL]${ANSI.reset}`,
skip: `${ANSI.gray}[SKIP]${ANSI.reset}`,
};
function formatCheckCli(c) {
let line = ` ${STATUS_LABEL_CLI[c.status]} ${c.name}: ${c.message}`;
if (c.details) {
line += `\n ${ANSI.gray}${c.details}${ANSI.reset}`;
}
return line;
}
// ---------------------------------------------------------------------------
// Trace by message_id
// ---------------------------------------------------------------------------
/**
* Extract all log lines tagged with a specific message_id from gateway.log.
*
* Scans the last 1MB of the log file for lines containing `[msg:{messageId}]`.
* Returns matching lines in chronological order.
*/
async function traceByMessageId(messageId) {
const logPath = path.join(os.homedir(), '.openclaw', 'logs', 'gateway.log');
try {
await fs.access(logPath);
}
catch {
return [];
}
const TRACE_READ_BYTES = 1024 * 1024; // 1MB — more than extractRecentErrors
try {
const stat = await fs.stat(logPath);
const readSize = Math.min(stat.size, TRACE_READ_BYTES);
const fd = await fs.open(logPath, 'r');
try {
const buffer = Buffer.alloc(readSize);
await fd.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
const content = buffer.toString('utf-8');
const needle = `[msg:${messageId}]`;
return content.split('\n').filter((line) => line.includes(needle));
}
finally {
await fd.close();
}
}
catch {
return [];
}
}
/**
* Format trace output for CLI display.
*/
function formatTraceOutput(lines, messageId) {
const sep = '────────────────────────────────';
if (lines.length === 0) {
return [
sep,
` 未找到 ${messageId} 的追踪日志`,
'',
' 可能原因:',
' 1. 该消息尚未被处理',
' 2. 日志已被轮转',
' 3. 追踪功能未启用(需要更新插件版本)',
sep,
].join('\n');
}
const header = `追踪 ${messageId} 的处理链路 (${lines.length} 条日志):`;
const output = [header, sep];
for (const line of lines) {
output.push(line);
}
output.push(sep);
return output.join('\n');
}
function classifyEvent(body) {
if (body.startsWith('received from'))
return 'received';
if (body.startsWith('sender resolved'))
return 'sender_resolved';
if (body.startsWith('rejected:'))
return 'rejected';
if (body.startsWith('dispatching to agent'))
return 'dispatching';
if (body.startsWith('dispatch complete'))
return 'dispatch_complete';
if (body.startsWith('card entity created'))
return 'card_created';
if (body.startsWith('card message sent'))
return 'card_sent';
if (body.startsWith('cardkit cardElement.content:'))
return 'card_stream';
if (body.startsWith('card stream update failed'))
return 'card_stream_fail';
if (body.startsWith('cardkit card.settings:'))
return 'card_settings';
if (body.startsWith('cardkit card.update:'))
return 'card_update';
if (body.startsWith('card creation failed'))
return 'card_fallback';
if (body.startsWith('reply completed'))
return 'reply_completed';
if (body.startsWith('reply error'))
return 'reply_error';
if (body.startsWith('tool call:'))
return 'tool_call';
if (body.startsWith('tool done:'))
return 'tool_done';
if (body.startsWith('tool fail:'))
return 'tool_fail';
return 'other';
}
const EVENT_LABEL = {
received: '消息接收',
sender_resolved: 'Sender 解析',
rejected: '消息拒绝',
dispatching: '分发到 Agent',
dispatch_complete: 'Agent 处理完成',
card_created: '卡片创建',
card_sent: '卡片消息发送',
card_stream: '流式更新',
card_stream_fail: '流式更新失败',
card_settings: '卡片设置',
card_update: '卡片最终更新',
card_fallback: '卡片降级',
reply_completed: '回复完成',
reply_error: '回复错误',
tool_call: '工具调用',
tool_done: '工具完成',
tool_fail: '工具失败',
};
/** Expected stages in a normal message processing flow. */
const EXPECTED_STAGES = [
{ kind: 'received', label: '消息接收 (received from)' },
{ kind: 'dispatching', label: '分发到 Agent (dispatching to agent)' },
{ kind: 'card_created', label: '卡片创建 (card entity created)' },
{ kind: 'card_sent', label: '卡片消息发送 (card message sent)' },
{ kind: 'card_stream', label: '流式输出 (cardElement.content)' },
{ kind: 'dispatch_complete', label: '处理完成 (dispatch complete)' },
{ kind: 'reply_completed', label: '回复收尾 (reply completed)' },
];
/** Time gap thresholds (ms) for performance warnings. */
const PERF_THRESHOLDS = [
{ from: 'received', to: 'dispatching', warnMs: 500, label: '消息接收 → 分发' },
{ from: 'dispatching', to: 'card_created', warnMs: 5000, label: '分发 → 卡片创建' },
{ from: 'card_created', to: 'card_stream', warnMs: 30000, label: '卡片创建 → 首次流式输出' },
];
function parseTraceLines(lines) {
const events = [];
// Match: 2026-02-13T12:42:04.682Z [feishu] feishu[...][msg:...]: <body>
const re = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s.*?\]:\s(.+)$/;
for (const line of lines) {
const m = line.match(re);
if (m) {
events.push({ timestamp: new Date(m[1]), raw: line, body: m[2] });
}
}
return events;
}
/**
* Analyze trace log lines and produce a structured CLI report.
*/
function analyzeTrace(lines, _messageId) {
const events = parseTraceLines(lines);
if (events.length === 0) {
return `无法解析日志行,请确认日志格式正确。`;
}
const out = [];
const sep = '────────────────────────────────';
const startTime = events[0].timestamp.getTime();
const totalMs = events[events.length - 1].timestamp.getTime() - startTime;
// ── Section 1: Timeline ──
out.push('');
out.push(`${ANSI.bold}【时间线】${ANSI.reset} (${events.length} 条日志,跨度 ${(totalMs / 1000).toFixed(1)}s)`);
out.push(sep);
let prevMs = startTime;
// Collapse consecutive card_stream events
let streamCount = 0;
let streamFirstSeq = '';
let streamLastSeq = '';
function flushStream() {
if (streamCount > 0) {
const label = streamCount === 1
? ` ${ANSI.gray}...${ANSI.reset} 流式更新 seq=${streamFirstSeq}`
: ` ${ANSI.gray}...${ANSI.reset} 流式更新 x${streamCount} (seq=${streamFirstSeq}~${streamLastSeq})`;
out.push(label);
streamCount = 0;
}
}
for (const ev of events) {
const kind = classifyEvent(ev.body);
const deltaMs = ev.timestamp.getTime() - prevMs;
prevMs = ev.timestamp.getTime();
const offsetMs = ev.timestamp.getTime() - startTime;
const offsetStr = `+${offsetMs}ms`.padStart(10);
// Collapse card_stream
if (kind === 'card_stream') {
const seqMatch = ev.body.match(/seq=(\d+)/);
const seq = seqMatch ? seqMatch[1] : '?';
if (streamCount === 0)
streamFirstSeq = seq;
streamLastSeq = seq;
streamCount++;
continue;
}
flushStream();
const label = EVENT_LABEL[kind] ?? kind;
const gapWarn = deltaMs > 5000 ? ` ${ANSI.yellow}${(deltaMs / 1000).toFixed(1)}s${ANSI.reset}` : '';
// Marker for errors
let marker = ' ';
if (kind === 'rejected' ||
kind === 'reply_error' ||
kind === 'tool_fail' ||
kind === 'card_stream_fail' ||
kind === 'card_fallback') {
marker = `${ANSI.red}${ANSI.reset}`;
}
else if (kind === 'tool_call') {
marker = '→ ';
}
// Extract key detail from body
let detail = '';
if (kind === 'received') {
const m = ev.body.match(/from (\S+) in (\S+) \((\w+)\)/);
if (m)
detail = `sender=${m[1]}, chat=${m[2]} (${m[3]})`;
}
else if (kind === 'dispatching') {
const m = ev.body.match(/session=(\S+)\)/);
if (m)
detail = `session=${m[1]}`;
}
else if (kind === 'dispatch_complete') {
const m = ev.body.match(/replies=(\d+), elapsed=(\d+)ms/);
if (m)
detail = `replies=${m[1]}, elapsed=${m[2]}ms`;
}
else if (kind === 'tool_call') {
const m = ev.body.match(/tool call: (\S+)/);
if (m)
detail = m[1];
}
else if (kind === 'tool_fail') {
detail = ev.body.replace('tool fail: ', '');
}
else if (kind === 'card_created') {
const m = ev.body.match(/card_id=(\S+)\)/);
if (m)
detail = `card_id=${m[1]}`;
}
else if (kind === 'reply_completed') {
const m = ev.body.match(/elapsed=(\d+)ms/);
if (m)
detail = `elapsed=${m[1]}ms`;
}
else if (kind === 'rejected') {
detail = ev.body.replace('rejected: ', '');
}
out.push(`${ANSI.gray}[${offsetStr}]${ANSI.reset} ${marker}${label}${detail ? `${detail}` : ''}${gapWarn}`);
}
flushStream();
out.push('');
// ── Section 2: Anomaly detection ──
const issues = [];
const kindSet = new Set(events.map((e) => classifyEvent(e.body)));
// 2.1 Missing stages
for (const stage of EXPECTED_STAGES) {
if (!kindSet.has(stage.kind)) {
// dispatch_complete 和 reply_completed 缺失仅在有 dispatching 时才告警
if ((stage.kind === 'dispatch_complete' || stage.kind === 'reply_completed') && !kindSet.has('dispatching'))
continue;
// card 相关阶段在有 rejected 时不告警
if ((stage.kind === 'card_created' || stage.kind === 'card_sent' || stage.kind === 'card_stream') &&
kindSet.has('rejected'))
continue;
issues.push(`缺失阶段: ${stage.label}`);
}
}
// 2.2 Errors
for (const ev of events) {
const kind = classifyEvent(ev.body);
if (kind === 'rejected')
issues.push(`消息被拒绝: ${ev.body.replace('rejected: ', '')}`);
if (kind === 'reply_error')
issues.push(`回复错误: ${ev.body}`);
if (kind === 'tool_fail')
issues.push(`工具失败: ${ev.body}`);
if (kind === 'card_stream_fail')
issues.push(`流式更新失败: ${ev.body}`);
if (kind === 'card_fallback')
issues.push(`卡片降级: ${ev.body}`);
// CardKit non-zero code
if (kind === 'card_stream' || kind === 'card_update' || kind === 'card_settings' || kind === 'card_created') {
const codeMatch = ev.body.match(/code=(\d+)/);
if (codeMatch && codeMatch[1] !== '0') {
issues.push(`API 返回错误码: code=${codeMatch[1]}${ev.body}`);
}
}
}
// 2.3 Performance thresholds
const firstByKind = new Map();
for (const ev of events) {
const kind = classifyEvent(ev.body);
if (!firstByKind.has(kind))
firstByKind.set(kind, ev);
}
for (const rule of PERF_THRESHOLDS) {
const from = firstByKind.get(rule.from);
const to = firstByKind.get(rule.to);
if (from && to) {
const gap = to.timestamp.getTime() - from.timestamp.getTime();
if (gap > rule.warnMs) {
issues.push(`性能警告: ${rule.label} 耗时 ${(gap / 1000).toFixed(1)}s(阈值 ${(rule.warnMs / 1000).toFixed(0)}s`);
}
}
}
// 2.4 Duplicate delivery
const receivedCount = events.filter((e) => classifyEvent(e.body) === 'received').length;
if (receivedCount > 1) {
issues.push(`重复投递: 同一消息被接收 ${receivedCount} 次(WebSocket 重投递)`);
}
// 2.5 Card stream continuity
const streamSeqs = [];
for (const ev of events) {
if (classifyEvent(ev.body) === 'card_stream') {
const m = ev.body.match(/seq=(\d+)/);
if (m)
streamSeqs.push(parseInt(m[1], 10));
}
}
if (streamSeqs.length > 1) {
for (let i = 1; i < streamSeqs.length; i++) {
if (streamSeqs[i] !== streamSeqs[i - 1] + 1) {
issues.push(`流式 seq 不连续: seq=${streamSeqs[i - 1]} → seq=${streamSeqs[i]}(跳过了 ${streamSeqs[i] - streamSeqs[i - 1] - 1} 个)`);
break;
}
}
}
out.push(`${ANSI.bold}【异常检测】${ANSI.reset}`);
out.push(sep);
if (issues.length === 0) {
out.push(` ${ANSI.green}未发现异常${ANSI.reset}`);
}
else {
for (let i = 0; i < issues.length; i++) {
const isError = issues[i].startsWith('工具失败') ||
issues[i].startsWith('回复错误') ||
issues[i].startsWith('API 返回错误码') ||
issues[i].startsWith('流式更新失败');
const color = isError ? ANSI.red : ANSI.yellow;
out.push(` ${color}${i + 1}. ${issues[i]}${ANSI.reset}`);
}
}
out.push('');
// ── Section 3: Diagnosis ──
out.push(`${ANSI.bold}【诊断总结】${ANSI.reset}`);
out.push(sep);
const hasError = issues.some((i) => i.startsWith('工具失败') ||
i.startsWith('回复错误') ||
i.startsWith('API 返回错误码') ||
i.startsWith('流式更新失败') ||
i.startsWith('缺失阶段'));
const hasWarn = issues.length > 0;
if (!hasWarn) {
out.push(` 状态: ${ANSI.green}✓ 正常${ANSI.reset}`);
out.push(` 消息处理链路完整,全程耗时 ${(totalMs / 1000).toFixed(1)}s。`);
// Break down time
const dispatchComplete = events.find((e) => classifyEvent(e.body) === 'dispatch_complete' && e.body.includes('replies=') && !e.body.includes('replies=0'));
if (dispatchComplete) {
const m = dispatchComplete.body.match(/elapsed=(\d+)ms/);
if (m) {
out.push(` 其中 Agent 处理耗时 ${(parseInt(m[1], 10) / 1000).toFixed(1)}s(含 AI 推理 + 工具调用)。`);
}
}
}
else if (hasError) {
out.push(` 状态: ${ANSI.red}✘ 异常${ANSI.reset}`);
out.push(` 发现 ${issues.length} 个问题,需要排查。`);
}
else {
out.push(` 状态: ${ANSI.yellow}⚠ 有警告${ANSI.reset}`);
out.push(` 发现 ${issues.length} 个警告,功能可用但需关注。`);
}
out.push('');
return out.join('\n');
}
function formatDiagReportCli(report) {
const lines = [];
const sep = '====================================';
lines.push(sep);
lines.push(` ${ANSI.bold}飞书插件诊断报告${ANSI.reset}`);
lines.push(` ${report.timestamp}`);
lines.push(sep);
lines.push('');
// Environment
lines.push(`${ANSI.bold}【环境信息】${ANSI.reset}`);
lines.push(` Node.js: ${report.environment.nodeVersion}`);
lines.push(` 插件版本: ${report.environment.pluginVersion}`);
lines.push(` 系统: ${report.environment.platform} ${report.environment.arch}`);
lines.push('');
// Global checks
lines.push(`${ANSI.bold}【全局检查】${ANSI.reset}`);
for (const c of report.checks) {
lines.push(formatCheckCli(c));
}
lines.push('');
// Per-account
for (const acct of report.accounts) {
lines.push(`${ANSI.bold}【账户: ${acct.accountId}${ANSI.reset}`);
if (acct.name)
lines.push(` 名称: ${acct.name}`);
lines.push(` App ID: ${acct.appId}`);
lines.push(` 品牌: ${acct.brand}`);
lines.push('');
for (const c of acct.checks) {
lines.push(formatCheckCli(c));
}
lines.push('');
}
// Tools
lines.push(`${ANSI.bold}【工具注册】${ANSI.reset}`);
if (report.toolsRegistered.length > 0) {
lines.push(` ${report.toolsRegistered.join(', ')}`);
lines.push(`${report.toolsRegistered.length}`);
}
else {
lines.push(' 无工具注册(未找到已配置的账户)');
}
lines.push('');
// Recent errors
if (report.recentErrors.length > 0) {
lines.push(`${ANSI.bold}【最近错误】${ANSI.reset}(${report.recentErrors.length} 条)`);
for (let i = 0; i < report.recentErrors.length; i++) {
lines.push(` ${ANSI.gray}${i + 1}. ${report.recentErrors[i]}${ANSI.reset}`);
}
lines.push('');
}
// Overall
const statusColorMap = {
healthy: `${ANSI.green}HEALTHY${ANSI.reset}`,
degraded: `${ANSI.yellow}DEGRADED (存在警告)${ANSI.reset}`,
unhealthy: `${ANSI.red}UNHEALTHY (存在失败项)${ANSI.reset}`,
};
lines.push(sep);
lines.push(` 总体状态: ${statusColorMap[report.overallStatus]}`);
lines.push(sep);
return lines.join('\n');
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* feishu-doctor 诊断报告 Markdown 格式化(完全重构版)
*
* 直接生成 Markdown 诊断报告,不依赖 diagnose.ts 的任何架构和代码。
* 按照 doctor_template.md 的格式规范实现。
*/
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
export type { FeishuLocale } from './locale';
import type { FeishuLocale } from './locale';
/** @deprecated Use FeishuLocale instead */
export type DoctorLocale = FeishuLocale;
/**
* 运行飞书插件诊断,生成 Markdown 格式报告。
*
* @param config - OpenClaw 配置
* @param currentAccountId - 当前发送命令的机器人账号 ID(若有则只诊断该账号)
* @param locale - 输出语言,默认 zh_cn
*/
export declare function runFeishuDoctor(config: OpenClawConfig, currentAccountId?: string, locale?: DoctorLocale): Promise<string>;
/**
* 运行飞书插件诊断,同时生成中英双语 Markdown 报告。
* 用于飞书 channel 的多语言 post 发送。
*/
export declare function runFeishuDoctorI18n(config: OpenClawConfig, currentAccountId?: string): Promise<Record<DoctorLocale, string>>;
@@ -0,0 +1,588 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* feishu-doctor 诊断报告 Markdown 格式化(完全重构版)
*
* 直接生成 Markdown 诊断报告,不依赖 diagnose.ts 的任何架构和代码。
* 按照 doctor_template.md 的格式规范实现。
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runFeishuDoctor = runFeishuDoctor;
exports.runFeishuDoctorI18n = runFeishuDoctorI18n;
const accounts_1 = require("../core/accounts.js");
const lark_client_1 = require("../core/lark-client.js");
/**
* Resolve the global config for cross-account operations.
*
* Plugin commands receive an account-scoped config where `channels.feishu`
* has been replaced with the merged per-account config (the `accounts` map
* is stripped by `baseConfig()`). Commands that enumerate all accounts
* need the original global config to see the full `accounts` map.
*/
function resolveGlobalConfig(config) {
return lark_client_1.LarkClient.globalConfig ?? config;
}
const app_scope_checker_1 = require("../core/app-scope-checker.js");
const app_owner_fallback_1 = require("../core/app-owner-fallback.js");
const token_store_1 = require("../core/token-store.js");
const tool_scopes_1 = require("../core/tool-scopes.js");
const probe_1 = require("../channel/probe.js");
const tool_client_1 = require("../core/tool-client.js");
const version_1 = require("../core/version.js");
const domains_1 = require("../core/domains.js");
// ---------------------------------------------------------------------------
// I18n text map
// ---------------------------------------------------------------------------
const T = {
zh_cn: {
notSet: '(未设置)',
legacyNotDisabled: '❌ **旧版插件**: 检测到旧版官方插件未禁用\n' +
'👉 请依次运行命令:\n' +
'```\n' +
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
'openclaw gateway restart\n' +
'```',
legacyRunCmds: '👉 请依次运行命令:',
legacyDisabled: '✅ **旧版插件**: 已禁用',
credentials: '✅ **凭证完整性**',
accountEnabled: '✅ **账户启用**: 已启用',
apiOk: '✅ **API 连通性**: 连接成功',
apiFail: '❌ **API 连通性**: 连接失败',
apiError: '❌ **API 连通性**: 探测异常',
toolsOk: '✅ 飞书工具加载暂未发现异常',
toolsWarnProfile: (profile) => `⚠️ **工具基础允许列表**: 当前为 \`${profile}\`,飞书工具可能无法加载。可以按需修改配置:`,
toolsDocRef: '📖 参考文档',
allPermsGranted: (count) => `全部 ${count} 个必需权限已开通`,
missingPermsPrefix: '缺少',
missingPermsSuffix: '个必需权限。需应用管理员申请开通',
cannotQueryPerms: '无法查询应用权限状态。原因:未开通 application:application:self_manage 权限',
cannotQueryPermsGeneric: '无法查询应用权限状态。',
suggestCheckPerm: '建议检查 application:application:self_manage 权限',
adminApply: '需应用管理员申请开通',
apply: '申请',
permTableHeader: '| 权限名称 | 应用已开通 | 用户已授权 |',
authStatusLabel: '**授权状态**',
userTotal: '共 1 个用户',
valid: '有效',
needRefresh: '需刷新',
expired: '已过期',
tokenRefreshLabel: '**Token 自动刷新**',
tokenRefreshOn: '✓ 已开启自动刷新 (1/1 个用户)',
tokenRefreshOff: '✗ 未开启自动刷新,Token 将在 2 小时后过期',
noUserAuth: '⚠️ **暂无用户授权**',
noUserAuthDesc: '尚未有用户通过 OAuth 授权。用户首次使用需以用户身份的功能时,会自动触发授权流程。',
permCompareLabel: '**权限对照**',
permInsufficient: '**用户身份权限不足**',
userCountLabel: '已授权',
noAuthLabel: '暂无授权',
appMissingUserPerms: (count) => `💡 应用缺少 ${count} 个用户身份权限。需应用管理员申请开通`,
permCompareSummary: (appCount, total, userPart) => `应用 **${appCount}/${total}** 已开通,用户 **${userPart}**`,
userReauth: '💡 用户需要重新授权以获得完整权限,可以向机器人发送消息 "**/feishu auth**"',
userNeedsOAuth: '💡 用户需要进行 OAuth 授权,可以向机器人发送消息 "**/feishu auth**"',
userPermFailed: '用户权限检查失败',
userPermFailedNoSelfManage: '用户权限检查失败:无法查询应用权限。原因:未开通 application:application:self_manage 权限',
reportTitle: '### 飞书插件诊断',
pluginVersionLabel: '插件版本',
diagTimeLabel: '诊断时间',
noAccounts: '❌ **错误**: 未找到已启用的飞书账户\n\n请在 OpenClaw 配置文件中配置飞书账户并启用。',
accountNotFoundPrefix: '❌ **错误**: 未找到账户',
enabledAccountsLabel: '当前已启用的账户',
toolsCheckPass: '#### ✅ 工具配置检查通过',
toolsCheckWarn: '#### ⚠️ 工具配置检查异常',
accountPrefix: '### 账户',
envCheckPass: '#### ✅ 环境信息检查通过',
envCheckFail: '#### ❌ 环境信息检查未通过',
appPermPass: '#### ✅ 应用身份权限检查通过',
appPermFail: '#### ❌ 应用身份权限检查未通过',
userPermPass: '#### ✅ 用户身份权限检查通过',
userPermFail: '#### ❌ 用户身份权限检查未通过',
},
en_us: {
notSet: '(not set)',
legacyNotDisabled: '❌ **Legacy Plugin**: Legacy official plugin is not disabled\n' +
'👉 Please run the following commands:\n' +
'```\n' +
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
'openclaw gateway restart\n' +
'```',
legacyRunCmds: '👉 Please run the following commands:',
legacyDisabled: '✅ **Legacy Plugin**: Disabled',
credentials: '✅ **Credentials**',
accountEnabled: '✅ **Account**: Enabled',
apiOk: '✅ **API Connectivity**: Connected',
apiFail: '❌ **API Connectivity**: Connection failed',
apiError: '❌ **API Connectivity**: Probe error',
toolsOk: '✅ Feishu tools loading: No issues found',
toolsWarnProfile: (profile) => `⚠️ **Tool Allowlist**: Currently set to \`${profile}\`. Feishu tools may not load properly. Update configuration as needed:`,
toolsDocRef: '📖 Documentation',
allPermsGranted: (count) => `All ${count} required permissions granted`,
missingPermsPrefix: 'Missing',
missingPermsSuffix: 'required permissions. Admin needs to apply',
cannotQueryPerms: 'Unable to query app permissions. Reason: Missing application:application:self_manage permission',
cannotQueryPermsGeneric: 'Unable to query app permissions.',
suggestCheckPerm: 'Please check application:application:self_manage permission',
adminApply: 'Admin needs to apply',
apply: 'Apply',
permTableHeader: '| Permission | App Granted | User Authorized |',
authStatusLabel: '**Auth Status**',
userTotal: '1 user total',
valid: 'Valid',
needRefresh: 'Needs refresh',
expired: 'Expired',
tokenRefreshLabel: '**Token Auto-Refresh**',
tokenRefreshOn: '✓ Auto-refresh enabled (1/1 users)',
tokenRefreshOff: '✗ Auto-refresh not enabled. Token will expire in 2 hours',
noUserAuth: '⚠️ **No User Authorization**',
noUserAuthDesc: 'No user has authorized via OAuth yet. The authorization flow will be triggered automatically when a user first uses a feature requiring user identity.',
permCompareLabel: '**Permission Comparison**',
permInsufficient: '**Insufficient User Permissions**',
userCountLabel: 'authorized',
noAuthLabel: 'not authorized',
appMissingUserPerms: (count) => `💡 App is missing ${count} user-identity permissions. Admin needs to apply`,
permCompareSummary: (appCount, total, userPart) => `App **${appCount}/${total}** granted, User **${userPart}**`,
userReauth: '💡 User needs to re-authorize for full permissions. Send message to bot: "**/feishu auth**"',
userNeedsOAuth: '💡 User needs OAuth authorization. Send message to bot: "**/feishu auth**"',
userPermFailed: 'User permission check failed',
userPermFailedNoSelfManage: 'User permission check failed: Unable to query app permissions. Reason: Missing application:application:self_manage permission',
reportTitle: '### Feishu Plugin Diagnostics',
pluginVersionLabel: 'Plugin version',
diagTimeLabel: 'Diagnosis time',
noAccounts: '❌ **Error**: No enabled Feishu accounts found\n\nPlease configure and enable a Feishu account in the OpenClaw configuration.',
accountNotFoundPrefix: '❌ **Error**: Account not found',
enabledAccountsLabel: 'Currently enabled accounts',
toolsCheckPass: '#### ✅ Tool Configuration Check Passed',
toolsCheckWarn: '#### ⚠️ Tool Configuration Check Warning',
accountPrefix: '### Account',
envCheckPass: '#### ✅ Environment Check Passed',
envCheckFail: '#### ❌ Environment Check Failed',
appPermPass: '#### ✅ App Permission Check Passed',
appPermFail: '#### ❌ App Permission Check Failed',
userPermPass: '#### ✅ User Permission Check Passed',
userPermFail: '#### ❌ User Permission Check Failed',
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* 格式化时间戳为 "YYYY-MM-DD HH:mm:ss"
*/
function formatTimestamp(date) {
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' ');
}
/**
* 获取所有工具动作需要的唯一 scope 列表(从 diagnose.ts 复制)
*/
function getAllToolScopes() {
const scopesSet = new Set();
for (const scopes of Object.values(tool_scopes_1.TOOL_SCOPES)) {
for (const scope of scopes) {
scopesSet.add(scope);
}
}
return Array.from(scopesSet).sort();
}
// ---------------------------------------------------------------------------
// 基础信息检查
// ---------------------------------------------------------------------------
/**
* 掩码敏感信息(appSecret
*/
function maskSecret(secret, locale) {
if (!secret)
return T[locale].notSet;
if (secret.length <= 4)
return '****';
return secret.slice(0, 4) + '****';
}
/**
* 检查基础信息和账号状态
*/
async function checkBasicInfo(account, config, locale) {
const t = T[locale];
const lines = [];
let status = 'pass';
// 旧版官方插件是否已禁用
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const feishuEntry = config.plugins?.entries?.feishu;
if (feishuEntry && feishuEntry.enabled !== false) {
status = 'fail';
lines.push(t.legacyNotDisabled);
}
else {
lines.push(t.legacyDisabled);
}
lines.push(`${t.credentials}: appId: ${account.appId}, appSecret: ${maskSecret(account.appSecret, locale)}`);
lines.push(t.accountEnabled);
// API 连通性
try {
const probeResult = await (0, probe_1.probeFeishu)({
accountId: account.accountId,
appId: account.appId,
appSecret: account.appSecret,
brand: account.brand,
});
if (probeResult.ok) {
lines.push(t.apiOk);
}
else {
status = 'fail';
lines.push(`${t.apiFail} - ${probeResult.error}`);
}
}
catch (err) {
status = 'fail';
lines.push(`${t.apiError} - ${err instanceof Error ? err.message : String(err)}`);
}
return {
status,
markdown: lines.join('\n'),
};
}
// ---------------------------------------------------------------------------
// 工具配置检查
// ---------------------------------------------------------------------------
const INCOMPLETE_PROFILES = new Set(['minimal', 'coding', 'messaging']);
function checkToolsProfile(config, locale) {
const t = T[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tools = config.tools;
const profile = tools?.profile;
if (!profile) {
return {
status: 'pass',
markdown: t.toolsOk,
};
}
if (INCOMPLETE_PROFILES.has(profile)) {
return {
status: 'warn',
markdown: `${t.toolsWarnProfile(profile)}\n` +
'```\n' +
'openclaw config set tools.profile "full"\n' +
'openclaw gateway restart\n' +
'```\n' +
`${t.toolsDocRef}: https://docs.openclaw.ai/zh-CN/tools`,
};
}
// profile === "full" 或其他未知值
return {
status: 'pass',
markdown: t.toolsOk,
};
}
// ---------------------------------------------------------------------------
// 应用权限检查
// ---------------------------------------------------------------------------
/**
* 检查应用权限状态
*/
async function checkAppPermissions(account, sdk, locale) {
const t = T[locale];
const { appId } = account;
const openDomain = (0, domains_1.openPlatformDomain)(account.brand);
try {
// 获取应用已开通的权限(tenant token
const grantedScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'tenant');
// 计算缺失的必需权限
const requiredMissing = (0, app_scope_checker_1.missingScopes)(grantedScopes, Array.from(tool_scopes_1.REQUIRED_APP_SCOPES));
if (requiredMissing.length === 0) {
// 全部权限已开通
return {
status: 'pass',
markdown: t.allPermsGranted(tool_scopes_1.REQUIRED_APP_SCOPES.length),
missingScopes: [],
};
}
// 缺少必需权限
const lines = [];
let applyUrl = `${openDomain}/app/${appId}/auth?op_from=feishu-openclaw&token_type=tenant`;
if (requiredMissing.length < 20) {
applyUrl = `${openDomain}/app/${appId}/auth?q=${encodeURIComponent(requiredMissing.join(','))}&op_from=feishu-openclaw&token_type=tenant`;
}
lines.push(`${t.missingPermsPrefix} ${requiredMissing.length} ${t.missingPermsSuffix} [${t.apply}](${applyUrl})`);
lines.push('');
for (const scope of requiredMissing) {
lines.push(`- ${scope}`);
}
return {
status: 'fail',
markdown: lines.join('\n'),
missingScopes: requiredMissing,
};
}
catch (err) {
// API 调用失败(通常是缺少 application:application:self_manage 权限)
const applyUrl = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
if (err instanceof tool_client_1.AppScopeCheckFailedError) {
return {
status: 'fail',
markdown: `${t.cannotQueryPerms}\n\n${t.adminApply} [${t.apply}](${applyUrl})`,
missingScopes: [],
};
}
return {
status: 'fail',
markdown: `${t.cannotQueryPermsGeneric}${err instanceof Error ? err.message : String(err)}\n\n${t.suggestCheckPerm} [${t.apply}](${applyUrl})`,
missingScopes: [],
};
}
}
// ---------------------------------------------------------------------------
// 用户权限检查
// ---------------------------------------------------------------------------
/**
* 生成权限对照表
*/
function generatePermissionTable(appGrantedScopes, userGrantedScopes, hasValidUser, locale) {
let allScopes = getAllToolScopes();
allScopes = (0, tool_scopes_1.filterSensitiveScopes)(allScopes);
const appSet = new Set(appGrantedScopes);
const userSet = new Set(userGrantedScopes);
const lines = [];
lines.push(T[locale].permTableHeader);
lines.push('|----------|-----------|-----------|');
for (const scope of allScopes) {
const appGranted = appSet.has(scope) ? '✅' : '❌';
// 如果没有有效用户,显示 ➖;否则根据授权情况显示 ✅ 或 ❌
const userGranted = !hasValidUser ? '' : userSet.has(scope) ? '✅' : '❌';
lines.push(`| ${scope} | ${appGranted} | ${userGranted} |`);
}
return lines.join('\n');
}
/**
* 检查用户权限状态
*/
async function checkUserPermissions(account, sdk, locale) {
const t = T[locale];
const { appId } = account;
const openDomain = (0, domains_1.openPlatformDomain)(account.brand);
const lines = [];
try {
// 1. 获取应用所有者
const ownerId = await (0, app_owner_fallback_1.getAppOwnerFallback)(account, sdk);
// 2. 读取 token
const token = ownerId ? await (0, token_store_1.getStoredToken)(appId, ownerId) : null;
// 判断是否有有效的用户授权
const hasUserAuth = !!token;
// 变量初始化
let authStatus = 'warn';
let refreshStatus = 'warn';
let validCount = 0;
let scopes = [];
let userTokenStatus = 'expired';
let userMissing = [];
// 获取应用开通的支持 user token 的权限
const appUserScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'user');
let allScopes = getAllToolScopes();
allScopes = (0, tool_scopes_1.filterSensitiveScopes)(allScopes);
const appGrantedCount = appUserScopes.filter((s) => allScopes.includes(s)).length;
if (hasUserAuth) {
// 有用户授权 - 检查授权状态
const status = (0, token_store_1.tokenStatus)(token);
userTokenStatus = status;
scopes = token.scope.split(' ').filter(Boolean);
validCount = status === 'valid' ? 1 : 0;
const needsRefreshCount = status === 'needs_refresh' ? 1 : 0;
const expiredCount = status === 'expired' ? 1 : 0;
authStatus = expiredCount > 0 ? 'warn' : validCount === 1 ? 'pass' : 'warn';
const authEmoji = authStatus === 'pass' ? '✅' : '⚠️';
lines.push(`${authEmoji} ${t.authStatusLabel}: ${t.userTotal} | ✓ ${t.valid}: ${validCount}, ⟳ ${t.needRefresh}: ${needsRefreshCount}, ✗ ${t.expired}: ${expiredCount}`);
// Token 自动刷新检查
const hasOfflineAccess = scopes.includes('offline_access');
refreshStatus = hasOfflineAccess ? 'pass' : 'warn';
const refreshEmoji = refreshStatus === 'pass' ? '✅' : '⚠️';
lines.push(`${refreshEmoji} ${t.tokenRefreshLabel}: ${hasOfflineAccess ? t.tokenRefreshOn : t.tokenRefreshOff}`);
}
else {
// 没有用户授权
lines.push(t.noUserAuth);
lines.push('');
lines.push(t.noUserAuthDesc);
lines.push('');
}
// 计算用户已授权权限数
const userGrantedCount = validCount === 1 ? scopes.filter((s) => allScopes.includes(s)).length : 0;
// 计算用户缺失的权限
if (hasUserAuth && validCount === 1) {
const scopeSet = new Set(scopes);
userMissing = allScopes.filter((s) => !scopeSet.has(s));
}
// 权限对照统计
const tableStatus = appGrantedCount < allScopes.length || userGrantedCount < allScopes.length
? appGrantedCount < allScopes.length
? 'fail'
: 'warn'
: 'pass';
const tableEmoji = tableStatus === 'pass' ? '✅' : tableStatus === 'warn' ? '⚠️' : '❌';
if (validCount === 0) {
lines.push(`${t.permCompareLabel}: ${t.permCompareSummary(appGrantedCount, allScopes.length, t.noAuthLabel)}`);
}
else if (userGrantedCount < allScopes.length) {
lines.push(`${tableEmoji} ${t.permInsufficient}: ${t.permCompareSummary(appGrantedCount, allScopes.length, `${userGrantedCount}/${allScopes.length} ${t.userCountLabel}`)}`);
}
else {
lines.push(`${tableEmoji} ${t.permCompareLabel}: ${t.permCompareSummary(appGrantedCount, allScopes.length, `${userGrantedCount}/${allScopes.length} ${t.userCountLabel}`)}`);
}
lines.push('');
// 添加指引信息
if (appGrantedCount < allScopes.length) {
// 计算缺失的应用权限
const appMissingScopes = allScopes.filter((s) => !appUserScopes.includes(s));
let appApplyUrl = `${openDomain}/app/${appId}/auth?op_from=feishu-openclaw&token_type=user`;
if (appMissingScopes.length < 20) {
appApplyUrl = `${openDomain}/app/${appId}/auth?q=${encodeURIComponent(appMissingScopes.join(','))}&op_from=feishu-openclaw&token_type=user`;
}
lines.push(`${t.appMissingUserPerms(appMissingScopes.length)} [${t.apply}](${appApplyUrl})`);
}
if (userGrantedCount < allScopes.length && validCount > 0) {
lines.push(t.userReauth);
lines.push('');
}
else if (!hasUserAuth) {
lines.push(t.userNeedsOAuth);
lines.push('');
}
// 生成详细权限对照表
const table = generatePermissionTable(appUserScopes, validCount === 1 ? scopes : [], validCount === 1, locale);
lines.push(table);
// 计算总体状态
const overallStatus = tableStatus === 'fail'
? 'fail'
: authStatus === 'warn' || refreshStatus === 'warn' || tableStatus === 'warn'
? 'warn'
: 'pass';
return {
status: overallStatus,
markdown: lines.join('\n'),
hasAuth: hasUserAuth,
tokenExpired: userTokenStatus === 'expired',
missingUserScopes: userMissing,
};
}
catch (err) {
const applyUrl = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
if (err instanceof tool_client_1.AppScopeCheckFailedError) {
return {
status: 'warn',
markdown: `${t.userPermFailedNoSelfManage}\n\n${t.adminApply} [${t.apply}](${applyUrl})`,
hasAuth: false,
tokenExpired: false,
missingUserScopes: [],
};
}
return {
status: 'warn',
markdown: `${t.userPermFailed}: ${err instanceof Error ? err.message : String(err)}`,
hasAuth: false,
tokenExpired: false,
missingUserScopes: [],
};
}
}
// ---------------------------------------------------------------------------
// 主函数
// ---------------------------------------------------------------------------
/**
* 运行飞书插件诊断,生成 Markdown 格式报告。
*
* @param config - OpenClaw 配置
* @param currentAccountId - 当前发送命令的机器人账号 ID(若有则只诊断该账号)
* @param locale - 输出语言,默认 zh_cn
*/
async function runFeishuDoctor(config, currentAccountId, locale = 'zh_cn') {
const t = T[locale];
const lines = [];
// 1. 获取目标账户
// Use the global config to enumerate all accounts — the passed-in
// config may be account-scoped (accounts map stripped).
const globalCfg = resolveGlobalConfig(config);
const allAccounts = (0, accounts_1.getEnabledLarkAccounts)(globalCfg);
if (allAccounts.length === 0) {
return t.noAccounts;
}
// 若指定了 accountId,只诊断该账号
const accounts = currentAccountId ? allAccounts.filter((a) => a.accountId === currentAccountId) : allAccounts;
if (accounts.length === 0) {
return `${t.accountNotFoundPrefix} "${currentAccountId}"\n\n${t.enabledAccountsLabel}: ${allAccounts.map((a) => a.accountId).join(', ')}`;
}
// 2. 生成报告头部
lines.push(t.reportTitle);
lines.push('');
lines.push(`${t.pluginVersionLabel}: ${(0, version_1.getPluginVersion)()} | ${t.diagTimeLabel}: ${formatTimestamp(new Date())}`);
lines.push('');
lines.push('---');
lines.push('');
// 3. 工具配置(全局,不区分账户)
const toolsResult = checkToolsProfile(config, locale);
const toolsTitle = toolsResult.status === 'pass' ? t.toolsCheckPass : t.toolsCheckWarn;
lines.push(toolsTitle);
lines.push('');
lines.push(toolsResult.markdown);
lines.push('');
lines.push('---');
lines.push('');
// 3.5 多账号隔离检查(全局问题,始终展示)
// TODO: 暂时注释掉,等产品策略明确后再放开
// const isolationStatus = checkMultiAccountIsolation(config);
// const isolationWarning = formatIsolationWarning(isolationStatus, config);
// if (isolationWarning) {
// lines.push(isolationWarning);
// lines.push("");
// lines.push("---");
// lines.push("");
// }
// 4. 逐账户诊断(仅目标账户)
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
const sdk = lark_client_1.LarkClient.fromAccount(account).sdk;
const accountLabel = account.accountId || account.appId;
if (accounts.length > 1) {
lines.push(`${t.accountPrefix} ${i + 1}: ${accountLabel}`);
lines.push('');
}
// 4a. 环境信息
const basicInfoResult = await checkBasicInfo(account, config, locale);
const basicTitle = basicInfoResult.status === 'pass' ? t.envCheckPass : t.envCheckFail;
lines.push(basicTitle);
lines.push('');
lines.push(basicInfoResult.markdown);
lines.push('');
lines.push('---');
lines.push('');
// 4b. 应用权限
const appResult = await checkAppPermissions(account, sdk, locale);
const appTitle = appResult.status === 'pass' ? t.appPermPass : t.appPermFail;
lines.push(appTitle);
lines.push('');
lines.push(appResult.markdown);
lines.push('');
lines.push('---');
lines.push('');
// 4c. 用户权限
const userResult = await checkUserPermissions(account, sdk, locale);
const userTitle = userResult.status === 'pass' ? t.userPermPass : t.userPermFail;
lines.push(userTitle);
lines.push('');
lines.push(userResult.markdown);
lines.push('');
if (i < accounts.length - 1) {
lines.push('---');
lines.push('');
}
}
return lines.join('\n');
}
/**
* 运行飞书插件诊断,同时生成中英双语 Markdown 报告。
* 用于飞书 channel 的多语言 post 发送。
*/
async function runFeishuDoctorI18n(config, currentAccountId) {
const [zh_cn, en_us] = await Promise.all([
runFeishuDoctor(config, currentAccountId, 'zh_cn'),
runFeishuDoctor(config, currentAccountId, 'en_us'),
]);
return { zh_cn, en_us };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Register all chat commands (/feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu).
*/
import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
import type { FeishuLocale } from './locale';
/**
* 运行 /feishu start 校验,返回 Markdown 格式结果。
*/
export declare function runFeishuStart(config: OpenClawConfig, locale?: FeishuLocale): string;
/**
* 运行 /feishu start,同时生成中英双语结果。
*/
export declare function runFeishuStartI18n(config: OpenClawConfig): Record<FeishuLocale, string>;
/**
* 生成 /feishu help 帮助文本。
*/
export declare function getFeishuHelp(locale?: FeishuLocale): string;
/**
* 生成 /feishu help,同时生成中英双语结果。
*/
export declare function getFeishuHelpI18n(): Record<FeishuLocale, string>;
export declare function registerCommands(api: OpenClawPluginApi): void;
@@ -0,0 +1,219 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Register all chat commands (/feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runFeishuStart = runFeishuStart;
exports.runFeishuStartI18n = runFeishuStartI18n;
exports.getFeishuHelp = getFeishuHelp;
exports.getFeishuHelpI18n = getFeishuHelpI18n;
exports.registerCommands = registerCommands;
const version_1 = require("../core/version.js");
const diagnose_1 = require("./diagnose.js");
const doctor_1 = require("./doctor.js");
const auth_1 = require("./auth.js");
// ---------------------------------------------------------------------------
// I18n text map for /feishu start, help, and error messages
// ---------------------------------------------------------------------------
const T = {
zh_cn: {
legacyNotDisabled: '❌ 检测到旧版插件未禁用。\n' +
'👉 请依次运行命令:\n' +
'```\n' +
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
'openclaw gateway restart\n' +
'```',
toolsProfileWarn: (profile) => `⚠️ 工具 Profile 当前为 \`${profile}\`,飞书工具可能无法加载。请检查配置是否正确。\n`,
startFailed: (details) => `❌ 飞书 OpenClaw 插件启动失败:\n\n${details}`,
startWithWarnings: (version, details) => `⚠️ 飞书 OpenClaw 插件已启动 v${version}(存在警告)\n\n${details}`,
startOk: (version) => `✅ 飞书 OpenClaw 插件已启动 v${version}`,
helpTitle: (version) => `飞书OpenClaw插件 v${version}`,
helpUsage: '用法:',
helpStart: '/feishu start - 校验插件配置',
helpAuth: '/feishu auth - 批量授权用户权限',
helpDoctor: '/feishu doctor - 运行诊断',
helpHelp: '/feishu help - 显示此帮助',
diagFailed: (msg) => `诊断执行失败: ${msg}`,
authFailed: (msg) => `授权执行失败: ${msg}`,
execFailed: (msg) => `执行失败: ${msg}`,
},
en_us: {
legacyNotDisabled: '❌ Legacy plugin is not disabled.\n' +
'👉 Please run the following commands:\n' +
'```\n' +
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
'openclaw gateway restart\n' +
'```',
toolsProfileWarn: (profile) => `⚠️ Tools profile is currently set to \`${profile}\`. Feishu tools may not load properly. Please check your configuration.\n`,
startFailed: (details) => `❌ Feishu OpenClaw plugin failed to start:\n\n${details}`,
startWithWarnings: (version, details) => `⚠️ Feishu OpenClaw plugin started v${version} (with warnings)\n\n${details}`,
startOk: (version) => `✅ Feishu OpenClaw plugin started v${version}`,
helpTitle: (version) => `Feishu OpenClaw Plugin v${version}`,
helpUsage: 'Usage:',
helpStart: '/feishu start - Validate plugin configuration',
helpAuth: '/feishu auth - Batch authorize user permissions',
helpDoctor: '/feishu doctor - Run diagnostics',
helpHelp: '/feishu help - Show this help',
diagFailed: (msg) => `Diagnostics failed: ${msg}`,
authFailed: (msg) => `Authorization failed: ${msg}`,
execFailed: (msg) => `Execution failed: ${msg}`,
},
};
// ---------------------------------------------------------------------------
// Exported i18n functions
// ---------------------------------------------------------------------------
/**
* 运行 /feishu start 校验,返回 Markdown 格式结果。
*/
function runFeishuStart(config, locale = 'zh_cn') {
const t = T[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cfg = config;
const errors = [];
const warnings = [];
// 检查旧版插件是否已禁用 (error)
const feishuEntry = cfg.plugins?.entries?.feishu;
if (feishuEntry && feishuEntry.enabled !== false) {
errors.push(t.legacyNotDisabled);
}
// 检查 tools.profile (warning)
const profile = cfg.tools?.profile;
const incompleteProfiles = new Set(['minimal', 'coding', 'messaging']);
if (profile && incompleteProfiles.has(profile)) {
warnings.push(t.toolsProfileWarn(profile));
}
if (errors.length > 0) {
const all = [...errors, ...warnings];
return t.startFailed(all.join('\n\n'));
}
if (warnings.length > 0) {
return t.startWithWarnings((0, version_1.getPluginVersion)(), warnings.join('\n\n'));
}
return t.startOk((0, version_1.getPluginVersion)());
}
/**
* 运行 /feishu start,同时生成中英双语结果。
*/
function runFeishuStartI18n(config) {
return {
zh_cn: runFeishuStart(config, 'zh_cn'),
en_us: runFeishuStart(config, 'en_us'),
};
}
/**
* 生成 /feishu help 帮助文本。
*/
function getFeishuHelp(locale = 'zh_cn') {
const t = T[locale];
return (`${t.helpTitle((0, version_1.getPluginVersion)())}\n\n` +
`${t.helpUsage}\n` +
` ${t.helpStart}\n` +
` ${t.helpAuth}\n` +
` ${t.helpDoctor}\n` +
` ${t.helpHelp}`);
}
/**
* 生成 /feishu help,同时生成中英双语结果。
*/
function getFeishuHelpI18n() {
return {
zh_cn: getFeishuHelp('zh_cn'),
en_us: getFeishuHelp('en_us'),
};
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
function registerCommands(api) {
// /feishu_diagnose
api.registerCommand({
name: 'feishu_diagnose',
description: 'Run Feishu plugin diagnostics to check config, connectivity, and permissions',
acceptsArgs: false,
requireAuth: true,
async handler(ctx) {
try {
const report = await (0, diagnose_1.runDiagnosis)({ config: ctx.config });
return { text: (0, diagnose_1.formatDiagReportText)(report) };
}
catch (err) {
return {
text: T.zh_cn.diagFailed(err instanceof Error ? err.message : String(err)),
};
}
},
});
// /feishu_doctor
api.registerCommand({
name: 'feishu_doctor',
description: 'Run Feishu plugin diagnostics',
acceptsArgs: false,
requireAuth: true,
async handler(ctx) {
try {
const markdown = await (0, doctor_1.runFeishuDoctor)(ctx.config, ctx.accountId);
return { text: markdown };
}
catch (err) {
return {
text: T.zh_cn.diagFailed(err instanceof Error ? err.message : String(err)),
};
}
},
});
// /feishu_auth
api.registerCommand({
name: 'feishu_auth',
description: 'Batch authorize user permissions for Feishu',
acceptsArgs: false,
requireAuth: true,
async handler(ctx) {
try {
const result = await (0, auth_1.runFeishuAuth)(ctx.config);
return { text: result };
}
catch (err) {
return {
text: T.zh_cn.authFailed(err instanceof Error ? err.message : String(err)),
};
}
},
});
// /feishu (统一入口,支持子命令)
api.registerCommand({
name: 'feishu',
description: 'Feishu plugin commands (subcommands: auth, doctor, start)',
acceptsArgs: true,
requireAuth: true,
async handler(ctx) {
const args = ctx.args?.trim().split(/\s+/) || [];
const subcommand = args[0]?.toLowerCase();
try {
// /feishu auth 或 /feishu onboarding
if (subcommand === 'auth' || subcommand === 'onboarding') {
const result = await (0, auth_1.runFeishuAuth)(ctx.config);
return { text: result };
}
// /feishu doctor
if (subcommand === 'doctor') {
const markdown = await (0, doctor_1.runFeishuDoctor)(ctx.config, ctx.accountId);
return { text: markdown };
}
// /feishu start
if (subcommand === 'start') {
return { text: runFeishuStart(ctx.config) };
}
// /feishu help 或无效子命令或无参数
return { text: getFeishuHelp() };
}
catch (err) {
return {
text: T.zh_cn.execFailed(err instanceof Error ? err.message : String(err)),
};
}
},
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared locale type for feishu command i18n.
*/
export type FeishuLocale = 'zh_cn' | 'en_us';
@@ -0,0 +1,8 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared locale type for feishu command i18n.
*/
Object.defineProperty(exports, "__esModule", { value: true });
+51
View File
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Lark multi-account management.
*
* Account overrides live under `cfg.channels.feishu.accounts`.
* Each account may override any top-level Feishu config field;
* unset fields fall back to the top-level defaults.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { ConfiguredLarkAccount, FeishuConfig, LarkAccount, LarkCredentials } from './types';
/**
* List all account IDs defined in the Lark config.
*
* Returns `[DEFAULT_ACCOUNT_ID]` when no explicit accounts exist.
*/
export declare function getLarkAccountIds(cfg: ClawdbotConfig): string[];
/** Return the first (default) account ID. */
export declare function getDefaultLarkAccountId(cfg: ClawdbotConfig): string;
/**
* Resolve a single account by merging the top-level config with
* account-level overrides. Account fields take precedence.
*
* Falls back to the default account when `accountId` is omitted or `null`.
*/
export declare function getLarkAccount(cfg: ClawdbotConfig, accountId?: string | null): LarkAccount;
/**
* Build an account-scoped config view for downstream helpers that read from
* `cfg.channels.feishu`.
*
* In multi-account mode, many runtime helpers expect the merged account config
* to already be exposed at `cfg.channels.feishu`. This mirrors the inbound
* path behavior so outbound/tooling code resolves per-account settings
* consistently.
*
* @param cfg - Original top-level plugin config
* @param accountId - Optional target account ID
* @returns Config with `channels.feishu` replaced by the merged account config
*/
export declare function createAccountScopedConfig(cfg: ClawdbotConfig, accountId?: string | null): ClawdbotConfig;
/** Return all accounts that are both configured and enabled. */
export declare function getEnabledLarkAccounts(cfg: ClawdbotConfig): LarkAccount[];
/**
* Extract API credentials from a Feishu config fragment.
*
* Returns `null` when `appId` or `appSecret` is missing.
*/
export declare function getLarkCredentials(feishuCfg?: FeishuConfig): LarkCredentials | null;
/** Type guard: narrow `LarkAccount` to `ConfiguredLarkAccount`. */
export declare function isConfigured(account: LarkAccount): account is ConfiguredLarkAccount;
@@ -0,0 +1,219 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Lark multi-account management.
*
* Account overrides live under `cfg.channels.feishu.accounts`.
* Each account may override any top-level Feishu config field;
* unset fields fall back to the top-level defaults.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getLarkAccountIds = getLarkAccountIds;
exports.getDefaultLarkAccountId = getDefaultLarkAccountId;
exports.getLarkAccount = getLarkAccount;
exports.createAccountScopedConfig = createAccountScopedConfig;
exports.getEnabledLarkAccounts = getEnabledLarkAccounts;
exports.getLarkCredentials = getLarkCredentials;
exports.isConfigured = isConfigured;
const account_id_1 = require("openclaw/plugin-sdk/account-id");
const normalizeAccountId = typeof account_id_1.normalizeAccountId === 'function'
? account_id_1.normalizeAccountId
: (id) => id?.trim().toLowerCase() || undefined;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Extract the `channels.feishu` section from the top-level config. */
function getLarkConfig(cfg) {
return cfg?.channels?.feishu;
}
/** Return the per-account override map, if present. */
function getAccountMap(section) {
return section.accounts;
}
/** Strip the `accounts` key and return the remaining top-level config. */
function baseConfig(section) {
const { accounts: _ignored, ...rest } = section;
return rest;
}
/** Merge base config with account override (account fields take precedence).
* Performs a one-level deep merge for plain-object fields so that partial
* account overrides (e.g. `footer: { model: false }`) are merged with
* the base instead of replacing the entire object. */
function mergeAccountConfig(base, override) {
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined)
continue;
const baseVal = base[key];
// Deep-merge plain objects one level (footer, tools, heartbeat, etc.)
if (value &&
typeof value === 'object' &&
!Array.isArray(value) &&
baseVal &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal)) {
result[key] = { ...baseVal, ...value };
}
else {
result[key] = value;
}
}
return result;
}
/** Coerce a domain string to `LarkBrand`, defaulting to `"feishu"`. */
function toBrand(domain) {
return domain ?? 'feishu';
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* List all account IDs defined in the Lark config.
*
* Returns `[DEFAULT_ACCOUNT_ID]` when no explicit accounts exist.
*/
function getLarkAccountIds(cfg) {
const section = getLarkConfig(cfg);
if (!section)
return [account_id_1.DEFAULT_ACCOUNT_ID];
const accountMap = getAccountMap(section);
if (!accountMap || Object.keys(accountMap).length === 0) {
return [account_id_1.DEFAULT_ACCOUNT_ID];
}
const accountIds = Object.keys(accountMap);
// 当 accounts 存在时,如果顶层也配置了 appId/appSecret(即默认机器人),
// 将 DEFAULT_ACCOUNT_ID 加入列表,确保顶层机器人不会被忽略。
// 但如果 accountMap 已经包含 default,则不重复添加。
const hasDefault = accountIds.some((id) => id.trim().toLowerCase() === account_id_1.DEFAULT_ACCOUNT_ID);
if (!hasDefault) {
const base = baseConfig(section);
if (base.appId && base.appSecret) {
return [account_id_1.DEFAULT_ACCOUNT_ID, ...accountIds];
}
}
return accountIds;
}
/** Return the first (default) account ID. */
function getDefaultLarkAccountId(cfg) {
return getLarkAccountIds(cfg)[0];
}
/**
* Resolve a single account by merging the top-level config with
* account-level overrides. Account fields take precedence.
*
* Falls back to the default account when `accountId` is omitted or `null`.
*/
function getLarkAccount(cfg, accountId) {
const requestedId = accountId ? (normalizeAccountId(accountId) ?? account_id_1.DEFAULT_ACCOUNT_ID) : account_id_1.DEFAULT_ACCOUNT_ID;
const section = getLarkConfig(cfg);
if (!section) {
return {
accountId: requestedId,
enabled: false,
configured: false,
brand: 'feishu',
config: {},
};
}
const base = baseConfig(section);
const accountMap = getAccountMap(section);
const accountOverride = accountMap && requestedId !== account_id_1.DEFAULT_ACCOUNT_ID
? accountMap[requestedId]
: undefined;
const merged = accountOverride
? mergeAccountConfig(base, accountOverride)
: { ...base };
const appId = merged.appId;
const appSecret = merged.appSecret;
const configured = !!(appId && appSecret);
// Respect explicit `enabled` when set; otherwise derive from `configured`.
const enabled = !!(merged.enabled ?? configured);
const brand = toBrand(merged.domain);
if (configured) {
return {
accountId: requestedId,
enabled,
configured: true,
name: merged.name ?? undefined,
appId: appId,
appSecret: appSecret,
encryptKey: merged.encryptKey ?? undefined,
verificationToken: merged.verificationToken ?? undefined,
brand,
config: merged,
};
}
return {
accountId: requestedId,
enabled,
configured: false,
name: merged.name ?? undefined,
appId: appId ?? undefined,
appSecret: appSecret ?? undefined,
encryptKey: merged.encryptKey ?? undefined,
verificationToken: merged.verificationToken ?? undefined,
brand,
config: merged,
};
}
/**
* Build an account-scoped config view for downstream helpers that read from
* `cfg.channels.feishu`.
*
* In multi-account mode, many runtime helpers expect the merged account config
* to already be exposed at `cfg.channels.feishu`. This mirrors the inbound
* path behavior so outbound/tooling code resolves per-account settings
* consistently.
*
* @param cfg - Original top-level plugin config
* @param accountId - Optional target account ID
* @returns Config with `channels.feishu` replaced by the merged account config
*/
function createAccountScopedConfig(cfg, accountId) {
const account = getLarkAccount(cfg, accountId);
return {
...cfg,
channels: {
...cfg.channels,
feishu: account.config,
},
};
}
/** Return all accounts that are both configured and enabled. */
function getEnabledLarkAccounts(cfg) {
const ids = getLarkAccountIds(cfg);
const results = [];
for (const id of ids) {
const account = getLarkAccount(cfg, id);
if (account.enabled && account.configured) {
results.push(account);
}
}
return results;
}
/**
* Extract API credentials from a Feishu config fragment.
*
* Returns `null` when `appId` or `appSecret` is missing.
*/
function getLarkCredentials(feishuCfg) {
if (!feishuCfg)
return null;
const appId = feishuCfg.appId;
const appSecret = feishuCfg.appSecret;
if (!appId || !appSecret)
return null;
return {
appId,
appSecret,
encryptKey: feishuCfg.encryptKey ?? undefined,
verificationToken: feishuCfg.verificationToken ?? undefined,
brand: toBrand(feishuCfg.domain),
};
}
/** Type guard: narrow `LarkAccount` to `ConfiguredLarkAccount`. */
function isConfigured(account) {
return account.configured;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Agent configuration helpers for the Lark/Feishu channel plugin.
*
* Reads agent-level configuration (identity, skills, tools, subagents)
* from the top-level `agents.list` in OpenClawConfig. These helpers
* bridge the gap between the SDK's agent infrastructure and the Feishu
* plugin's dispatch/reply layers.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
/** Minimal agent identity fields used by the Feishu plugin. */
interface AgentIdentity {
name?: string;
emoji?: string;
avatar?: string;
}
/** Minimal agent tools policy fields. */
interface AgentToolsPolicy {
allow?: string[];
deny?: string[];
}
/** Shape of an agent entry in `config.agents.list`. */
interface AgentEntry {
id: string;
name?: string;
skills?: string[];
identity?: AgentIdentity;
tools?: AgentToolsPolicy & Record<string, unknown>;
subagents?: {
allowAgents?: string[];
};
}
/**
* Retrieve the full list of configured agents from config.
*
* @param cfg - The top-level application config.
* @returns Array of agent entries, or empty array if none configured.
*/
export declare function listConfiguredAgents(cfg: ClawdbotConfig): AgentEntry[];
/**
* Look up a specific agent's configuration by its ID.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID to search for.
* @returns The matching agent entry, or `undefined` if not found.
*/
export declare function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined;
/**
* Resolve a human-readable display name for an agent.
*
* Priority: `identity.name` > `name` > `undefined`.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns The display name, or `undefined` if none configured.
*/
export declare function getAgentDisplayName(cfg: ClawdbotConfig, agentId: string): string | undefined;
/**
* Resolve the per-agent skills filter.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns Skill allowlist, or `undefined` if no agent-level filter.
*/
export declare function getAgentSkillsFilter(cfg: ClawdbotConfig, agentId: string): string[] | undefined;
/**
* Resolve the per-agent tools policy (allow/deny lists).
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns Tools policy object, or `undefined` if none configured.
*/
export declare function getAgentToolsPolicy(cfg: ClawdbotConfig, agentId: string): AgentToolsPolicy | undefined;
/**
* Merge agent-level and group-level skill filters.
*
* When both are present, the effective filter is the intersection:
* a skill must appear in both lists to be included. When only one
* is present, that list is used as-is.
*
* @param agentSkills - Per-agent skill allowlist (from AgentConfig.skills).
* @param groupSkills - Per-group skill allowlist (from FeishuGroupConfig.skills).
* @returns Merged skill filter, or `undefined` if neither is set.
*/
export declare function mergeSkillFilters(agentSkills: string[] | undefined, groupSkills: string[] | undefined): string[] | undefined;
/**
* Check whether a tool name is permitted by an agent's tool policy.
*
* Evaluation order:
* 1. If `deny` list exists and tool matches → denied.
* 2. If `allow` list exists and tool does NOT match → denied.
* 3. Otherwise → allowed.
*
* Supports glob-like patterns with trailing `*` (e.g. `feishu_calendar_*`).
*
* @param toolName - The tool name being invoked.
* @param policy - The agent's tool policy.
* @returns `true` if the tool is allowed, `false` if denied.
*/
export declare function isToolAllowedByPolicy(toolName: string, policy: AgentToolsPolicy | undefined): boolean;
export {};
@@ -0,0 +1,148 @@
"use strict";
// SPDX-License-Identifier: MIT
/**
* Agent configuration helpers for the Lark/Feishu channel plugin.
*
* Reads agent-level configuration (identity, skills, tools, subagents)
* from the top-level `agents.list` in OpenClawConfig. These helpers
* bridge the gap between the SDK's agent infrastructure and the Feishu
* plugin's dispatch/reply layers.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.listConfiguredAgents = listConfiguredAgents;
exports.resolveAgentEntry = resolveAgentEntry;
exports.getAgentDisplayName = getAgentDisplayName;
exports.getAgentSkillsFilter = getAgentSkillsFilter;
exports.getAgentToolsPolicy = getAgentToolsPolicy;
exports.mergeSkillFilters = mergeSkillFilters;
exports.isToolAllowedByPolicy = isToolAllowedByPolicy;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Retrieve the full list of configured agents from config.
*
* @param cfg - The top-level application config.
* @returns Array of agent entries, or empty array if none configured.
*/
function listConfiguredAgents(cfg) {
const agents = cfg.agents;
return agents?.list ?? [];
}
/**
* Look up a specific agent's configuration by its ID.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID to search for.
* @returns The matching agent entry, or `undefined` if not found.
*/
function resolveAgentEntry(cfg, agentId) {
return listConfiguredAgents(cfg).find((a) => a.id === agentId);
}
/**
* Resolve a human-readable display name for an agent.
*
* Priority: `identity.name` > `name` > `undefined`.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns The display name, or `undefined` if none configured.
*/
function getAgentDisplayName(cfg, agentId) {
const entry = resolveAgentEntry(cfg, agentId);
if (!entry)
return undefined;
return entry.identity?.name ?? entry.name;
}
/**
* Resolve the per-agent skills filter.
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns Skill allowlist, or `undefined` if no agent-level filter.
*/
function getAgentSkillsFilter(cfg, agentId) {
return resolveAgentEntry(cfg, agentId)?.skills;
}
/**
* Resolve the per-agent tools policy (allow/deny lists).
*
* @param cfg - The top-level application config.
* @param agentId - The agent ID.
* @returns Tools policy object, or `undefined` if none configured.
*/
function getAgentToolsPolicy(cfg, agentId) {
const entry = resolveAgentEntry(cfg, agentId);
if (!entry?.tools)
return undefined;
const { allow, deny } = entry.tools;
if (!allow && !deny)
return undefined;
return { allow, deny };
}
/**
* Merge agent-level and group-level skill filters.
*
* When both are present, the effective filter is the intersection:
* a skill must appear in both lists to be included. When only one
* is present, that list is used as-is.
*
* @param agentSkills - Per-agent skill allowlist (from AgentConfig.skills).
* @param groupSkills - Per-group skill allowlist (from FeishuGroupConfig.skills).
* @returns Merged skill filter, or `undefined` if neither is set.
*/
function mergeSkillFilters(agentSkills, groupSkills) {
if (!agentSkills && !groupSkills)
return undefined;
if (!agentSkills)
return groupSkills;
if (!groupSkills)
return agentSkills;
// Intersection: group filter narrows the agent filter.
const agentSet = new Set(agentSkills);
return groupSkills.filter((s) => agentSet.has(s));
}
/**
* Check whether a tool name is permitted by an agent's tool policy.
*
* Evaluation order:
* 1. If `deny` list exists and tool matches → denied.
* 2. If `allow` list exists and tool does NOT match → denied.
* 3. Otherwise → allowed.
*
* Supports glob-like patterns with trailing `*` (e.g. `feishu_calendar_*`).
*
* @param toolName - The tool name being invoked.
* @param policy - The agent's tool policy.
* @returns `true` if the tool is allowed, `false` if denied.
*/
function isToolAllowedByPolicy(toolName, policy) {
if (!policy)
return true;
if (policy.deny && policy.deny.length > 0) {
if (matchesAnyPattern(toolName, policy.deny))
return false;
}
if (policy.allow && policy.allow.length > 0) {
return matchesAnyPattern(toolName, policy.allow);
}
return true;
}
/**
* Check whether a string matches any of the given patterns.
* Supports trailing `*` as a simple wildcard.
*/
function matchesAnyPattern(value, patterns) {
for (const pattern of patterns) {
if (pattern === '*')
return true;
if (pattern.endsWith('*')) {
if (value.startsWith(pattern.slice(0, -1)))
return true;
}
else if (value === pattern) {
return true;
}
}
return false;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared Lark API error handling utilities.
*
* Provides unified error handling for two distinct error paths:
*
* 1. **Response-level errors** — The SDK returns a response object with a
* non-zero `code`. Handled by {@link assertLarkOk}.
*
* 2. **Thrown exceptions** — The SDK throws an Axios-style error (HTTP 4xx)
* whose properties include the Feishu error `code` and `msg`.
* Handled by {@link formatLarkError}.
*
* Both paths intercept well-known codes (e.g. LARK_ERROR.APP_SCOPE_MISSING (99991672) — missing API scopes)
* and produce user-friendly messages with actionable authorization links.
*/
/**
* 从 Lark SDK 抛错对象中提取飞书 API code。
*
* 支持三种常见结构:
* - `{ code }` — SDK 直接挂载
* - `{ data: { code } }` — 响应体嵌套
* - `{ response: { data: { code } } }` — Axios 风格
*/
export declare function extractLarkApiCode(err: unknown): number | undefined;
/**
* Assert that a Lark SDK response is successful (code === 0).
*
* For permission errors (code LARK_ERROR.APP_SCOPE_MISSING (99991672)), the thrown error includes the
* required scope names and a direct authorization URL so the AI can
* present it to the end user.
*/
export declare function assertLarkOk(res: {
code?: number;
msg?: string;
}): void;
/**
* Extract a meaningful error message from a thrown Lark SDK / Axios error.
*
* The Lark SDK throws Axios errors whose object carries Feishu-specific
* fields (`code`, `msg`) alongside the standard `message`. For permission
* errors (LARK_ERROR.APP_SCOPE_MISSING (99991672)) we format a user-friendly string with scopes + auth URL.
* For all other errors we try `err.msg` first (the Feishu detail) and fall
* back to `err.message` (the generic Axios text).
*/
export declare function formatLarkError(err: unknown): string;
@@ -0,0 +1,117 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared Lark API error handling utilities.
*
* Provides unified error handling for two distinct error paths:
*
* 1. **Response-level errors** — The SDK returns a response object with a
* non-zero `code`. Handled by {@link assertLarkOk}.
*
* 2. **Thrown exceptions** — The SDK throws an Axios-style error (HTTP 4xx)
* whose properties include the Feishu error `code` and `msg`.
* Handled by {@link formatLarkError}.
*
* Both paths intercept well-known codes (e.g. LARK_ERROR.APP_SCOPE_MISSING (99991672) — missing API scopes)
* and produce user-friendly messages with actionable authorization links.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractLarkApiCode = extractLarkApiCode;
exports.assertLarkOk = assertLarkOk;
exports.formatLarkError = formatLarkError;
const permission_url_1 = require("./permission-url.js");
const auth_errors_1 = require("./auth-errors.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Given a Feishu error code and msg, format a user-friendly permission
* error string if the code is LARK_ERROR.APP_SCOPE_MISSING (99991672). Returns `null` for other codes.
*/
function formatPermissionError(code, msg) {
if (code !== auth_errors_1.LARK_ERROR.APP_SCOPE_MISSING)
return null;
const authUrl = (0, permission_url_1.extractPermissionGrantUrl)(msg);
const scopes = (0, permission_url_1.extractPermissionScopes)(msg);
return `权限不足:应用缺少 [${scopes}] 权限。\n` + `请管理员点击以下链接申请并开通权限:\n${authUrl}`;
}
// ---------------------------------------------------------------------------
// Code extraction
// ---------------------------------------------------------------------------
function coerceCode(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed))
return parsed;
}
return undefined;
}
/**
* 从 Lark SDK 抛错对象中提取飞书 API code。
*
* 支持三种常见结构:
* - `{ code }` — SDK 直接挂载
* - `{ data: { code } }` — 响应体嵌套
* - `{ response: { data: { code } } }` — Axios 风格
*/
function extractLarkApiCode(err) {
if (!err || typeof err !== 'object')
return undefined;
const e = err;
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Assert that a Lark SDK response is successful (code === 0).
*
* For permission errors (code LARK_ERROR.APP_SCOPE_MISSING (99991672)), the thrown error includes the
* required scope names and a direct authorization URL so the AI can
* present it to the end user.
*/
function assertLarkOk(res) {
if (!res.code || res.code === 0)
return;
const permMsg = formatPermissionError(res.code, res.msg ?? '');
if (permMsg)
throw new Error(permMsg);
throw new Error(res.msg ?? `Feishu API error (code: ${res.code})`);
}
/**
* Extract a meaningful error message from a thrown Lark SDK / Axios error.
*
* The Lark SDK throws Axios errors whose object carries Feishu-specific
* fields (`code`, `msg`) alongside the standard `message`. For permission
* errors (LARK_ERROR.APP_SCOPE_MISSING (99991672)) we format a user-friendly string with scopes + auth URL.
* For all other errors we try `err.msg` first (the Feishu detail) and fall
* back to `err.message` (the generic Axios text).
*/
function formatLarkError(err) {
if (!err || typeof err !== 'object') {
return String(err);
}
const e = err;
// Path 1: Lark SDK merges Feishu fields onto the thrown error object.
if (typeof e.code === 'number' && e.msg) {
const permMsg = formatPermissionError(e.code, e.msg);
if (permMsg)
return permMsg;
return e.msg;
}
// Path 2: Standard Axios error — dig into response.data.
const data = e.response?.data;
if (data && typeof data.code === 'number' && data.msg) {
const permMsg = formatPermissionError(data.code, data.msg);
if (permMsg)
return permMsg;
return data.msg;
}
// Fallback.
return e.message ?? String(err);
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* 应用所有者查询 — 复用 app-scope-checker 的 API 调用和统一 owner 定义。
*
* 所有 owner 判定统一使用 {@link getAppInfo} 返回的 `effectiveOwnerOpenId`。
* 不维护独立缓存,完全依赖 app-scope-checker 的 30s 缓存。
*/
import type * as Lark from '@larksuiteoapi/node-sdk';
import type { ConfiguredLarkAccount } from './types';
/**
* 获取应用的 effectiveOwnerOpenId。
*
* 复用 app-scope-checker 的 API 调用、缓存和统一 owner 定义(effectiveOwnerOpenId)。
* 查询失败时返回 undefinedfail-open)。
*
* @param account - 已配置的飞书账号信息
* @param sdk - 飞书 SDK 实例(必须已初始化 TAT)
* @returns 应用所有者的 open_id,如果查询失败则返回 undefined
*/
export declare function getAppOwnerFallback(account: ConfiguredLarkAccount, sdk: Lark.Client): Promise<string | undefined>;
@@ -0,0 +1,39 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* 应用所有者查询 — 复用 app-scope-checker 的 API 调用和统一 owner 定义。
*
* 所有 owner 判定统一使用 {@link getAppInfo} 返回的 `effectiveOwnerOpenId`。
* 不维护独立缓存,完全依赖 app-scope-checker 的 30s 缓存。
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAppOwnerFallback = getAppOwnerFallback;
const app_scope_checker_1 = require("./app-scope-checker.js");
const lark_logger_1 = require("./lark-logger.js");
const log = (0, lark_logger_1.larkLogger)('core/app-owner-fallback');
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* 获取应用的 effectiveOwnerOpenId。
*
* 复用 app-scope-checker 的 API 调用、缓存和统一 owner 定义(effectiveOwnerOpenId)。
* 查询失败时返回 undefinedfail-open)。
*
* @param account - 已配置的飞书账号信息
* @param sdk - 飞书 SDK 实例(必须已初始化 TAT)
* @returns 应用所有者的 open_id,如果查询失败则返回 undefined
*/
async function getAppOwnerFallback(account, sdk) {
const { appId } = account;
try {
const appInfo = await (0, app_scope_checker_1.getAppInfo)(sdk, appId);
return appInfo.effectiveOwnerOpenId;
}
catch (err) {
log.warn(`failed to get owner for ${appId}: ${err instanceof Error ? err.message : err}`);
return undefined; // fail-open: 获取失败不阻塞业务
}
}
@@ -0,0 +1,87 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* App Scope Checker — 查询应用已开通的 scope 列表。
*
* 通过 `GET /open-apis/application/v6/applications/:app_id` (TAT) 获取
* 应用信息,从 `app.scopes` 中提取已开通的 scope 字符串列表。
*
* 结果带 30 秒内存缓存,避免每次 invoke() 都调远程 API。
* scope 检查失败后可调 {@link invalidateAppScopeCache} 清缓存重查。
*/
import type * as Lark from '@larksuiteoapi/node-sdk';
export interface AppInfo {
appId: string;
creatorId?: string;
ownerOpenId?: string;
ownerType?: number;
/**
* 统一的 owner 判定结果。所有需要判定"谁是应用 owner"的场景都应使用此字段。
*
* 规则:owner_type=2(企业内成员)时取 owner_id,否则回退 creator_id。
* 兼容 owner.owner_type 和 owner.type 两种字段名。
*/
effectiveOwnerOpenId?: string;
scopes: Array<{
scope: string;
token_types?: string[];
}>;
}
/** 清除指定 appId 的缓存。 */
export declare function invalidateAppScopeCache(appId: string): void;
/**
* 获取应用已开通的 scope 列表。
*
* 需要应用自身有 `application:application:self_manage` 权限。
* `appId` 可传 `"me"` 查自己。
*
* @param sdk - Lark SDK 实例
* @param appId - 应用 ID
* @param tokenType - token 类型,用于过滤只支持特定 token 类型的 scope
* @returns scope 字符串数组,如 `["calendar:calendar", "task:task:write"]`
*/
export declare function getAppGrantedScopes(sdk: Lark.Client, appId: string, tokenType?: 'user' | 'tenant'): Promise<string[]>;
/**
* 获取应用信息,包括 owner 信息。
*
* 复用 getAppGrantedScopes 的 API 调用和缓存。
* 如果缓存中已有数据且未过期,直接从缓存提取。
*
* @param sdk - Lark SDK 实例
* @param appId - 应用 ID(可传 "me"
*/
export declare function getAppInfo(sdk: Lark.Client, appId: string): Promise<AppInfo>;
/**
* 计算 APP 已有 ∩ OAPI 需要 的交集。
*
* 用于传给 OAuth 的 scope 参数 — 只请求 APP 已开通且 API 需要的 scope。
*
* @param appGranted - 应用已开通的 scope 列表
* @param apiRequired - OAPI 要求的 scope 列表
* @returns 交集 scope 列表
*/
export declare function intersectScopes(appGranted: string[], apiRequired: string[]): string[];
/**
* 计算 OAPI 需要但 APP 未开通的 scope(差集)。
*
* 用于 AppScopeMissingError 的 missingScopes。
*
* @param appGranted - 应用已开通的 scope 列表
* @param apiRequired - OAPI 要求的 scope 列表
* @returns 缺失的 scope 列表
*/
export declare function missingScopes(appGranted: string[], apiRequired: string[]): string[];
/**
* 校验应用已开通的 scope 是否满足要求。
*
* 与 tool-client.ts invoke() 的 scope 校验逻辑完全一致,作为唯一真值来源:
* - `scopeNeedType === "all"`: appScopes 必须包含 requiredScopes 的全部项
* - 其他(默认 "one": appScopes 与 requiredScopes 的交集非空即可
* - appScopes 为空: 视为满足(API 查询失败,退回服务端判断)
*
* @param appScopes - 应用已开通的 scope 列表(由 getAppGrantedScopes 返回)
* @param requiredScopes - 需要的 scope 列表
* @param scopeNeedType - "all" 表示全部必须,undefined/"one" 表示任一即可
*/
export declare function isAppScopeSatisfied(appScopes: string[], requiredScopes: string[], scopeNeedType?: 'one' | 'all'): boolean;
@@ -0,0 +1,198 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* App Scope Checker — 查询应用已开通的 scope 列表。
*
* 通过 `GET /open-apis/application/v6/applications/:app_id` (TAT) 获取
* 应用信息,从 `app.scopes` 中提取已开通的 scope 字符串列表。
*
* 结果带 30 秒内存缓存,避免每次 invoke() 都调远程 API。
* scope 检查失败后可调 {@link invalidateAppScopeCache} 清缓存重查。
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
Object.defineProperty(exports, "__esModule", { value: true });
exports.invalidateAppScopeCache = invalidateAppScopeCache;
exports.getAppGrantedScopes = getAppGrantedScopes;
exports.getAppInfo = getAppInfo;
exports.intersectScopes = intersectScopes;
exports.missingScopes = missingScopes;
exports.isAppScopeSatisfied = isAppScopeSatisfied;
const lark_logger_1 = require("./lark-logger.js");
const log = (0, lark_logger_1.larkLogger)('core/app-scope-checker');
const auth_errors_1 = require("./auth-errors.js");
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
const cache = new Map();
const CACHE_TTL_MS = 30 * 1000; // 30 秒
/** 清除指定 appId 的缓存。 */
function invalidateAppScopeCache(appId) {
cache.delete(appId);
}
// ---------------------------------------------------------------------------
// Fetch
// ---------------------------------------------------------------------------
/**
* 获取应用已开通的 scope 列表。
*
* 需要应用自身有 `application:application:self_manage` 权限。
* `appId` 可传 `"me"` 查自己。
*
* @param sdk - Lark SDK 实例
* @param appId - 应用 ID
* @param tokenType - token 类型,用于过滤只支持特定 token 类型的 scope
* @returns scope 字符串数组,如 `["calendar:calendar", "task:task:write"]`
*/
async function getAppGrantedScopes(sdk, appId, tokenType) {
// 1. 检查缓存
const cached = cache.get(appId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
// 从缓存中过滤出支持当前 token 类型的 scope
return cached.rawScopes
.filter((s) => {
if (tokenType && s.token_types && Array.isArray(s.token_types)) {
return s.token_types.includes(tokenType);
}
return true;
})
.map((s) => s.scope);
}
// 2. 调用 API
try {
const res = await sdk.request({
method: 'GET',
url: `/open-apis/application/v6/applications/${appId}`,
params: { lang: 'zh_cn' },
});
if (res.code !== 0) {
// 任何 API 错误都认为是应用缺少 application:application:self_manage 权限
throw new auth_errors_1.AppScopeCheckFailedError(appId);
}
// 响应结构: res.data.app.scopes → [{ scope: "xxx", description, level, token_types?: string[] }]
// 或者从 app_version 中获取 scopes
const app = res.data?.app ?? res.app ?? res.data;
const rawScopes = app?.scopes ?? app?.online_version?.scopes ?? [];
// 提取并验证 scope 字符串
const validScopes = rawScopes
.filter((s) => typeof s.scope === 'string' && s.scope.length > 0)
.map((s) => ({ scope: s.scope, token_types: s.token_types }));
// 3. 写缓存(缓存完整数据,包含 token_types 和原始 app 对象)
cache.set(appId, { rawScopes: validScopes, rawApp: app, fetchedAt: Date.now() });
log.info(`fetched ${validScopes.length} scopes for app ${appId}`);
// 4. 根据 tokenType 过滤
const scopes = validScopes
.filter((s) => {
if (tokenType && s.token_types && Array.isArray(s.token_types)) {
return s.token_types.includes(tokenType);
}
return true;
})
.map((s) => s.scope);
log.info(`returning ${scopes.length} scopes${tokenType ? ` for ${tokenType} token` : ''}`);
return scopes;
}
catch (err) {
// 如果是 AppScopeCheckFailedError,重新抛出(不吞掉)
if (err instanceof auth_errors_1.AppScopeCheckFailedError) {
throw err;
}
// 检查是否是权限相关的 HTTP 错误(400/403
// axios/SDK 异常对象通常包含 response.status 或 status 字段
const statusCode = err?.response?.status || err?.status || err?.statusCode;
const isPermissionError = statusCode === 400 ||
statusCode === 403 ||
(err instanceof Error && (err.message.includes('status code 400') || err.message.includes('status code 403')));
if (isPermissionError) {
throw new auth_errors_1.AppScopeCheckFailedError(appId);
}
log.warn(`failed to fetch scopes for ${appId}: ${err instanceof Error ? err.message : err}`);
// 其他查询失败不阻塞调用,返回空数组(后续 API 调用如果缺 scope 会被服务端拒绝)
return [];
}
}
// ---------------------------------------------------------------------------
// App info
// ---------------------------------------------------------------------------
/**
* 获取应用信息,包括 owner 信息。
*
* 复用 getAppGrantedScopes 的 API 调用和缓存。
* 如果缓存中已有数据且未过期,直接从缓存提取。
*
* @param sdk - Lark SDK 实例
* @param appId - 应用 ID(可传 "me"
*/
async function getAppInfo(sdk, appId) {
// 先确保缓存已填充(调一次 getAppGrantedScopes 来触发 API + 缓存)
await getAppGrantedScopes(sdk, appId);
const cached = cache.get(appId);
const rawApp = cached?.rawApp;
// 提取 owner 信息
const owner = rawApp?.owner;
const creatorId = rawApp?.creator_id;
// 统一 owner 定义:type=2(企业内成员)用 owner_id,否则回退 creator_id
// 兼容两种字段名(owner_type 和 type
const ownerTypeValue = owner?.owner_type ?? owner?.type;
const effectiveOwnerOpenId = ownerTypeValue === 2 && owner?.owner_id ? owner.owner_id : (creatorId ?? owner?.owner_id);
return {
appId,
creatorId,
ownerOpenId: owner?.owner_id,
ownerType: owner?.owner_type,
effectiveOwnerOpenId,
scopes: cached?.rawScopes ?? [],
};
}
// ---------------------------------------------------------------------------
// Scope intersection
// ---------------------------------------------------------------------------
/**
* 计算 APP 已有 ∩ OAPI 需要 的交集。
*
* 用于传给 OAuth 的 scope 参数 — 只请求 APP 已开通且 API 需要的 scope。
*
* @param appGranted - 应用已开通的 scope 列表
* @param apiRequired - OAPI 要求的 scope 列表
* @returns 交集 scope 列表
*/
function intersectScopes(appGranted, apiRequired) {
const grantedSet = new Set(appGranted);
return apiRequired.filter((s) => grantedSet.has(s));
}
/**
* 计算 OAPI 需要但 APP 未开通的 scope(差集)。
*
* 用于 AppScopeMissingError 的 missingScopes。
*
* @param appGranted - 应用已开通的 scope 列表
* @param apiRequired - OAPI 要求的 scope 列表
* @returns 缺失的 scope 列表
*/
function missingScopes(appGranted, apiRequired) {
const grantedSet = new Set(appGranted);
return apiRequired.filter((s) => !grantedSet.has(s));
}
/**
* 校验应用已开通的 scope 是否满足要求。
*
* 与 tool-client.ts invoke() 的 scope 校验逻辑完全一致,作为唯一真值来源:
* - `scopeNeedType === "all"`: appScopes 必须包含 requiredScopes 的全部项
* - 其他(默认 "one": appScopes 与 requiredScopes 的交集非空即可
* - appScopes 为空: 视为满足(API 查询失败,退回服务端判断)
*
* @param appScopes - 应用已开通的 scope 列表(由 getAppGrantedScopes 返回)
* @param requiredScopes - 需要的 scope 列表
* @param scopeNeedType - "all" 表示全部必须,undefined/"one" 表示任一即可
*/
function isAppScopeSatisfied(appScopes, requiredScopes, scopeNeedType) {
if (appScopes.length === 0)
return true; // API 查询失败 → 退回服务端判断
if (requiredScopes.length === 0)
return true;
if (scopeNeedType === 'all') {
return missingScopes(appScopes, requiredScopes).length === 0;
}
return intersectScopes(appScopes, requiredScopes).length > 0;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* auth-errors.ts — 统一错误类型定义。
*
* 所有与认证/授权/scope 相关的错误类型集中在此文件,
* 解除 tool-client ↔ app-scope-checker 循环依赖。
*
* 其他模块应直接 import 此文件,或通过 tool-client / uat-client 的 re-export 使用。
*/
/** 飞书 OAPI 错误码常量,替代各处硬编码的 magic number。 */
export declare const LARK_ERROR: {
/** 应用 scope 不足(租户维度) */
readonly APP_SCOPE_MISSING: 99991672;
/** 用户 token scope 不足 */
readonly USER_SCOPE_INSUFFICIENT: 99991679;
/** access_token 无效 */
readonly TOKEN_INVALID: 99991668;
/** access_token 已过期 */
readonly TOKEN_EXPIRED: 99991677;
/** refresh_token 本身无效(格式非法或来自 v1 API) */
readonly REFRESH_TOKEN_INVALID: 20026;
/** refresh_token 已过期(超过 365 天) */
readonly REFRESH_TOKEN_EXPIRED: 20037;
/** refresh_token 已被吊销 */
readonly REFRESH_TOKEN_REVOKED: 20064;
/** refresh_token 已被使用(单次消费,rotation 场景) */
readonly REFRESH_TOKEN_ALREADY_USED: 20073;
/** refresh token 端点服务端内部错误,可重试 */
readonly REFRESH_SERVER_ERROR: 20050;
/** 消息已被撤回 */
readonly MESSAGE_RECALLED: 230011;
/** 消息已被删除 */
readonly MESSAGE_DELETED: 231003;
};
/** refresh token 端点可重试的错误码集合(服务端瞬时故障)。遇到后重试一次,仍失败则清 token。 */
export declare const REFRESH_TOKEN_RETRYABLE: ReadonlySet<number>;
/** 消息终止错误码集合(撤回/删除),遇到后应停止对该消息的后续操作。 */
export declare const MESSAGE_TERMINAL_CODES: ReadonlySet<number>;
/** access_token 失效相关的错误码集合,遇到后可尝试刷新重试。 */
export declare const TOKEN_RETRY_CODES: ReadonlySet<number>;
/** invoke() 错误共享的 scope 信息。 */
export interface ScopeErrorInfo {
apiName: string;
scopes: string[];
/** 应用 scope 是否已验证通过。false 表示 app scope 检查失败,scope 信息可能不准确。 */
appScopeVerified?: boolean;
/** 应用 ID,用于生成开放平台权限管理链接。 */
appId?: string;
}
/** OAuth 授权提示信息,与 handleInvokeError 返回的结构一致。 */
export interface AuthHint {
error: string;
api: string;
required_scope: string;
user_open_id: string;
message: string;
next_tool_call: {
tool: 'feishu_oauth';
params: {
action: 'authorize';
scope: string;
};
};
}
/** tryInvoke 返回值的判别联合体。 */
export type TryInvokeResult<T> = {
ok: true;
data: T;
} | {
ok: false;
error: string;
authHint: AuthHint;
} | {
ok: false;
error: string;
authHint?: undefined;
};
/**
* Thrown when no valid UAT exists and the user needs to (re-)authorise.
* Callers should catch this and trigger the OAuth flow.
*/
export declare class NeedAuthorizationError extends Error {
readonly userOpenId: string;
constructor(userOpenId: string);
}
/**
* 应用缺少 application:application:self_manage 权限,无法查询应用权限配置。
*
* 需要管理员在飞书开放平台开通 application:application:self_manage 权限。
*/
export declare class AppScopeCheckFailedError extends Error {
/** 应用 ID,用于生成开放平台权限管理链接。 */
readonly appId?: string;
constructor(appId?: string);
}
/**
* 应用未开通 OAPI 所需 scope。
*
* 需要管理员在飞书开放平台开通权限。
*/
export declare class AppScopeMissingError extends Error {
readonly apiName: string;
/** OAPI 需要但 APP 未开通的 scope 列表。 */
readonly missingScopes: string[];
/** 工具的全部所需 scope(含已开通的),用于应用权限完成后一次性发起用户授权。 */
readonly allRequiredScopes?: string[];
/** 应用 ID,用于生成开放平台权限管理链接。 */
readonly appId?: string;
readonly scopeNeedType?: 'one' | 'all';
/** 触发此错误时使用的 token 类型,用于保持 card action 二次校验一致。 */
readonly tokenType?: 'user' | 'tenant';
constructor(info: ScopeErrorInfo, scopeNeedType?: 'one' | 'all', tokenType?: 'user' | 'tenant', allRequiredScopes?: string[]);
}
/**
* 用户未授权或 scope 不足,需要发起 OAuth 授权。
*
* `requiredScopes` 为 APP∩OAPI 的有效 scope,可直接传给
* `feishu_oauth authorize --scope`。
*/
export declare class UserAuthRequiredError extends Error {
readonly userOpenId: string;
readonly apiName: string;
/** APP∩OAPI 交集 scope,传给 OAuth authorize。 */
readonly requiredScopes: string[];
/** 应用 scope 是否已验证通过。false 时 requiredScopes 可能不准确。 */
readonly appScopeVerified: boolean;
/** 应用 ID,用于生成开放平台权限管理链接。 */
readonly appId?: string;
constructor(userOpenId: string, info: ScopeErrorInfo);
}
/**
* 服务端报 99991679 — 用户 token 的 scope 不足。
*
* 需要增量授权:用缺失的 scope 发起新 Device Flow。
*/
export declare class UserScopeInsufficientError extends Error {
readonly userOpenId: string;
readonly apiName: string;
/** 缺失的 scope 列表。 */
readonly missingScopes: string[];
constructor(userOpenId: string, info: ScopeErrorInfo);
}
@@ -0,0 +1,160 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* auth-errors.ts — 统一错误类型定义。
*
* 所有与认证/授权/scope 相关的错误类型集中在此文件,
* 解除 tool-client ↔ app-scope-checker 循环依赖。
*
* 其他模块应直接 import 此文件,或通过 tool-client / uat-client 的 re-export 使用。
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.UserScopeInsufficientError = exports.UserAuthRequiredError = exports.AppScopeMissingError = exports.AppScopeCheckFailedError = exports.NeedAuthorizationError = exports.TOKEN_RETRY_CODES = exports.MESSAGE_TERMINAL_CODES = exports.REFRESH_TOKEN_RETRYABLE = exports.LARK_ERROR = void 0;
// ---------------------------------------------------------------------------
// Feishu error code constants
// ---------------------------------------------------------------------------
/** 飞书 OAPI 错误码常量,替代各处硬编码的 magic number。 */
exports.LARK_ERROR = {
/** 应用 scope 不足(租户维度) */
APP_SCOPE_MISSING: 99991672,
/** 用户 token scope 不足 */
USER_SCOPE_INSUFFICIENT: 99991679,
/** access_token 无效 */
TOKEN_INVALID: 99991668,
/** access_token 已过期 */
TOKEN_EXPIRED: 99991677,
/** refresh_token 本身无效(格式非法或来自 v1 API) */
REFRESH_TOKEN_INVALID: 20026,
/** refresh_token 已过期(超过 365 天) */
REFRESH_TOKEN_EXPIRED: 20037,
/** refresh_token 已被吊销 */
REFRESH_TOKEN_REVOKED: 20064,
/** refresh_token 已被使用(单次消费,rotation 场景) */
REFRESH_TOKEN_ALREADY_USED: 20073,
/** refresh token 端点服务端内部错误,可重试 */
REFRESH_SERVER_ERROR: 20050,
/** 消息已被撤回 */
MESSAGE_RECALLED: 230011,
/** 消息已被删除 */
MESSAGE_DELETED: 231003,
};
/** refresh token 端点可重试的错误码集合(服务端瞬时故障)。遇到后重试一次,仍失败则清 token。 */
exports.REFRESH_TOKEN_RETRYABLE = new Set([exports.LARK_ERROR.REFRESH_SERVER_ERROR]);
/** 消息终止错误码集合(撤回/删除),遇到后应停止对该消息的后续操作。 */
exports.MESSAGE_TERMINAL_CODES = new Set([
exports.LARK_ERROR.MESSAGE_RECALLED,
exports.LARK_ERROR.MESSAGE_DELETED,
]);
/** access_token 失效相关的错误码集合,遇到后可尝试刷新重试。 */
exports.TOKEN_RETRY_CODES = new Set([exports.LARK_ERROR.TOKEN_INVALID, exports.LARK_ERROR.TOKEN_EXPIRED]);
// ---------------------------------------------------------------------------
// Error classes
// ---------------------------------------------------------------------------
/**
* Thrown when no valid UAT exists and the user needs to (re-)authorise.
* Callers should catch this and trigger the OAuth flow.
*/
class NeedAuthorizationError extends Error {
userOpenId;
constructor(userOpenId) {
super('need_user_authorization');
this.name = 'NeedAuthorizationError';
this.userOpenId = userOpenId;
}
}
exports.NeedAuthorizationError = NeedAuthorizationError;
/**
* 应用缺少 application:application:self_manage 权限,无法查询应用权限配置。
*
* 需要管理员在飞书开放平台开通 application:application:self_manage 权限。
*/
class AppScopeCheckFailedError extends Error {
/** 应用 ID,用于生成开放平台权限管理链接。 */
appId;
constructor(appId) {
super('应用缺少 application:application:self_manage 权限,无法查询应用权限配置。请管理员在开放平台开通该权限。');
this.name = 'AppScopeCheckFailedError';
this.appId = appId;
}
}
exports.AppScopeCheckFailedError = AppScopeCheckFailedError;
/**
* 应用未开通 OAPI 所需 scope。
*
* 需要管理员在飞书开放平台开通权限。
*/
class AppScopeMissingError extends Error {
apiName;
/** OAPI 需要但 APP 未开通的 scope 列表。 */
missingScopes;
/** 工具的全部所需 scope(含已开通的),用于应用权限完成后一次性发起用户授权。 */
allRequiredScopes;
/** 应用 ID,用于生成开放平台权限管理链接。 */
appId;
scopeNeedType;
/** 触发此错误时使用的 token 类型,用于保持 card action 二次校验一致。 */
tokenType;
constructor(info, scopeNeedType, tokenType, allRequiredScopes) {
if (scopeNeedType === 'one') {
super(`应用缺少权限 [${info.scopes.join(', ')}](开启任一权限即可),请管理员在开放平台开通。`);
}
else {
super(`应用缺少权限 [${info.scopes.join(', ')}],请管理员在开放平台开通。`);
}
this.name = 'AppScopeMissingError';
this.apiName = info.apiName;
this.missingScopes = info.scopes;
this.allRequiredScopes = allRequiredScopes;
this.appId = info.appId;
this.scopeNeedType = scopeNeedType;
this.tokenType = tokenType;
}
}
exports.AppScopeMissingError = AppScopeMissingError;
/**
* 用户未授权或 scope 不足,需要发起 OAuth 授权。
*
* `requiredScopes` 为 APP∩OAPI 的有效 scope,可直接传给
* `feishu_oauth authorize --scope`。
*/
class UserAuthRequiredError extends Error {
userOpenId;
apiName;
/** APP∩OAPI 交集 scope,传给 OAuth authorize。 */
requiredScopes;
/** 应用 scope 是否已验证通过。false 时 requiredScopes 可能不准确。 */
appScopeVerified;
/** 应用 ID,用于生成开放平台权限管理链接。 */
appId;
constructor(userOpenId, info) {
super('need_user_authorization');
this.name = 'UserAuthRequiredError';
this.userOpenId = userOpenId;
this.apiName = info.apiName;
this.requiredScopes = info.scopes;
this.appId = info.appId;
this.appScopeVerified = info.appScopeVerified ?? true;
}
}
exports.UserAuthRequiredError = UserAuthRequiredError;
/**
* 服务端报 99991679 — 用户 token 的 scope 不足。
*
* 需要增量授权:用缺失的 scope 发起新 Device Flow。
*/
class UserScopeInsufficientError extends Error {
userOpenId;
apiName;
/** 缺失的 scope 列表。 */
missingScopes;
constructor(userOpenId, info) {
super('user_scope_insufficient');
this.name = 'UserScopeInsufficientError';
this.userOpenId = userOpenId;
this.apiName = info.apiName;
this.missingScopes = info.scopes;
}
}
exports.UserScopeInsufficientError = UserScopeInsufficientError;
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Card callback operator identity extraction.
*
* Feishu Schema 2 card callbacks may carry the operator identity under
* either `operator.open_id` (Schema 1 / default) or `operator.user_id`
* (Schema 2 when the user has no open_id in the app's tenant).
*
* This helper provides a single, consistent extraction point so that
* every card callback handler resolves the operator identity the same
* way. See openclaw/openclaw#71670 for the upstream Schema 2 change.
*/
/**
* Minimal shape of the `operator` object in a Feishu card callback event.
* Both fields are optional because Schema 2 may omit `open_id` entirely.
*/
export interface CardCallbackOperator {
open_id?: string;
user_id?: string;
}
/**
* Extract the operator's identity from a Feishu card callback event.
*
* Prefers `open_id` (the stable per-app user identifier) and falls back
* to `user_id` when `open_id` is absent or empty — this is the Schema 2 path.
*
* @param operator - The `operator` field from the card callback payload.
* @returns The resolved operator identifier, or `undefined` when neither
* field is present.
*/
export declare function resolveCardCallbackOperatorId(operator: CardCallbackOperator | undefined): string | undefined;
@@ -0,0 +1,30 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Card callback operator identity extraction.
*
* Feishu Schema 2 card callbacks may carry the operator identity under
* either `operator.open_id` (Schema 1 / default) or `operator.user_id`
* (Schema 2 when the user has no open_id in the app's tenant).
*
* This helper provides a single, consistent extraction point so that
* every card callback handler resolves the operator identity the same
* way. See openclaw/openclaw#71670 for the upstream Schema 2 change.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveCardCallbackOperatorId = resolveCardCallbackOperatorId;
/**
* Extract the operator's identity from a Feishu card callback event.
*
* Prefers `open_id` (the stable per-app user identifier) and falls back
* to `user_id` when `open_id` is absent or empty — this is the Schema 2 path.
*
* @param operator - The `operator` field from the card callback payload.
* @returns The resolved operator identifier, or `undefined` when neither
* field is present.
*/
function resolveCardCallbackOperatorId(operator) {
return operator?.open_id || operator?.user_id;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Account-scoped LRU cache for Feishu group/chat metadata.
*
* Caches the result of `im.chat.get` (chat_mode, group_message_type, etc.)
* to avoid repeated OAPI calls for every inbound message.
*
* Key fields cached:
* - `chat_mode`: "group" | "topic" | "p2p"
* - `group_message_type`: "chat" | "thread" (only for chat_mode=group)
*/
import type * as Lark from '@larksuiteoapi/node-sdk';
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
/** Minimal structural type for LarkClient class (avoids circular import). */
interface LarkClientStatic {
fromCfg(cfg: ClawdbotConfig, accountId?: string): {
sdk: Lark.Client;
};
}
/** @internal Called by lark-client.ts at module init time. */
export declare function injectLarkClient(cls: LarkClientStatic): void;
export interface ChatInfo {
chatMode: 'group' | 'topic' | 'p2p';
groupMessageType?: 'chat' | 'thread';
}
/** Clear chat-info caches (called from LarkClient.clearCache). */
export declare function clearChatInfoCache(accountId?: string): void;
/**
* Determine whether a group supports thread sessions.
*
* Returns `true` when the group is a topic group (`chat_mode=topic`) or
* a normal group with thread message mode (`group_message_type=thread`).
*
* Results are cached per-account with a 1-hour TTL to minimise OAPI calls.
*/
export declare function isThreadCapableGroup(params: {
cfg: ClawdbotConfig;
chatId: string;
accountId?: string;
}): Promise<boolean>;
/**
* Fetch (or read from cache) the chat metadata for a given chat ID.
*
* Returns `undefined` when the API call fails (best-effort).
*/
export declare function getChatInfo(params: {
cfg: ClawdbotConfig;
chatId: string;
accountId?: string;
}): Promise<ChatInfo | undefined>;
/**
* Determine the chat type (p2p or group) for a given chat ID.
*
* Delegates to the shared {@link getChatInfo} cache (account-scoped LRU with
* 1-hour TTL) so that chat metadata is fetched at most once across all
* call-sites (dispatch, reaction handler, etc.).
*
* Falls back to "p2p" if the API call fails.
*/
export declare function getChatTypeFeishu(params: {
cfg: ClawdbotConfig;
chatId: string;
accountId?: string;
}): Promise<'p2p' | 'group'>;
export {};
@@ -0,0 +1,165 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Account-scoped LRU cache for Feishu group/chat metadata.
*
* Caches the result of `im.chat.get` (chat_mode, group_message_type, etc.)
* to avoid repeated OAPI calls for every inbound message.
*
* Key fields cached:
* - `chat_mode`: "group" | "topic" | "p2p"
* - `group_message_type`: "chat" | "thread" (only for chat_mode=group)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.injectLarkClient = injectLarkClient;
exports.clearChatInfoCache = clearChatInfoCache;
exports.isThreadCapableGroup = isThreadCapableGroup;
exports.getChatInfo = getChatInfo;
exports.getChatTypeFeishu = getChatTypeFeishu;
const lark_logger_1 = require("./lark-logger.js");
let _LarkClient = null;
/** @internal Called by lark-client.ts at module init time. */
function injectLarkClient(cls) {
_LarkClient = cls;
}
const log = (0, lark_logger_1.larkLogger)('core/chat-info-cache');
// ---------------------------------------------------------------------------
// Cache implementation
// ---------------------------------------------------------------------------
const DEFAULT_MAX_SIZE = 500;
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
class ChatInfoCache {
map = new Map();
maxSize;
ttlMs;
constructor(maxSize = DEFAULT_MAX_SIZE, ttlMs = DEFAULT_TTL_MS) {
this.maxSize = maxSize;
this.ttlMs = ttlMs;
}
get(chatId) {
const entry = this.map.get(chatId);
if (!entry)
return undefined;
if (entry.expireAt <= Date.now()) {
this.map.delete(chatId);
return undefined;
}
// LRU refresh
this.map.delete(chatId);
this.map.set(chatId, entry);
return entry.info;
}
set(chatId, info) {
this.map.delete(chatId);
this.map.set(chatId, { info, expireAt: Date.now() + this.ttlMs });
this.evict();
}
clear() {
this.map.clear();
}
evict() {
while (this.map.size > this.maxSize) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined)
this.map.delete(oldest);
}
}
}
// ---------------------------------------------------------------------------
// Account-scoped singleton registry
// ---------------------------------------------------------------------------
const registry = new Map();
function getChatInfoCache(accountId) {
let c = registry.get(accountId);
if (!c) {
c = new ChatInfoCache();
registry.set(accountId, c);
}
return c;
}
/** Clear chat-info caches (called from LarkClient.clearCache). */
function clearChatInfoCache(accountId) {
if (accountId !== undefined) {
registry.get(accountId)?.clear();
registry.delete(accountId);
}
else {
for (const c of registry.values())
c.clear();
registry.clear();
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Determine whether a group supports thread sessions.
*
* Returns `true` when the group is a topic group (`chat_mode=topic`) or
* a normal group with thread message mode (`group_message_type=thread`).
*
* Results are cached per-account with a 1-hour TTL to minimise OAPI calls.
*/
async function isThreadCapableGroup(params) {
const { cfg, chatId, accountId } = params;
const info = await getChatInfo({ cfg, chatId, accountId });
if (!info)
return false;
return info.chatMode === 'topic' || info.groupMessageType === 'thread';
}
/**
* Fetch (or read from cache) the chat metadata for a given chat ID.
*
* Returns `undefined` when the API call fails (best-effort).
*/
async function getChatInfo(params) {
const { cfg, chatId, accountId } = params;
const effectiveAccountId = accountId ?? 'default';
const cache = getChatInfoCache(effectiveAccountId);
const cached = cache.get(chatId);
if (cached)
return cached;
try {
if (!_LarkClient)
throw new Error('LarkClient not injected — circular dependency broken?');
const sdk = _LarkClient.fromCfg(cfg, accountId).sdk;
const response = await sdk.im.chat.get({
path: { chat_id: chatId },
});
const data = response?.data;
const chatMode = data?.chat_mode ?? 'group';
const groupMessageType = data?.group_message_type;
const info = {
chatMode: chatMode,
groupMessageType: groupMessageType,
};
cache.set(chatId, info);
log.info(`resolved ${chatId} → chat_mode=${chatMode}, group_message_type=${groupMessageType ?? 'N/A'}`);
return info;
}
catch (err) {
log.error(`failed to get chat info for ${chatId}: ${String(err)}`);
return undefined;
}
}
// ---------------------------------------------------------------------------
// getChatTypeFeishu
// ---------------------------------------------------------------------------
/**
* Determine the chat type (p2p or group) for a given chat ID.
*
* Delegates to the shared {@link getChatInfo} cache (account-scoped LRU with
* 1-hour TTL) so that chat metadata is fetched at most once across all
* call-sites (dispatch, reaction handler, etc.).
*
* Falls back to "p2p" if the API call fails.
*/
async function getChatTypeFeishu(params) {
const { cfg, chatId, accountId } = params;
const info = await getChatInfo({ cfg, chatId, accountId });
if (!info)
return 'p2p';
return info.chatMode === 'group' || info.chatMode === 'topic' ? 'group' : 'p2p';
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu Drive comment target ID parsing and formatting utilities.
*
* Comment targets use one of these formats:
* - `comment:<fileType>:<fileToken>:<commentId>` (legacy/default reply mode)
* - `comment:<deliveryMode>:<fileType>:<fileToken>:<commentId>`
*
* This enables the outbound routing layer to distinguish comment-thread
* replies from normal IM messages and route them through the Drive
* comment API instead.
*/
/** Document types that support Drive comments. */
export type CommentFileType = 'doc' | 'docx' | 'file' | 'sheet' | 'slides';
/** Delivery mode for a comment target. */
export type CommentDeliveryMode = 'reply' | 'create_whole';
/** Parsed comment target components. */
export interface CommentTarget {
deliveryMode: CommentDeliveryMode;
fileType: CommentFileType;
fileToken: string;
commentId: string;
}
/**
* Construct a comment target string from its components.
*
* @example
* ```ts
* buildFeishuCommentTarget({ fileType: 'docx', fileToken: 'abc123', commentId: '789' })
* // => 'comment:docx:abc123:789'
*
* buildFeishuCommentTarget({ deliveryMode: 'create_whole', fileType: 'docx', fileToken: 'abc123', commentId: '789' })
* // => 'comment:create_whole:docx:abc123:789'
* ```
*/
export declare function buildFeishuCommentTarget(params: {
deliveryMode?: CommentDeliveryMode;
fileType: CommentFileType;
fileToken: string;
commentId: string;
}): string;
/**
* Parse a comment target string into its components.
*
* Returns `null` when the string is not a valid comment target.
*
* @example
* ```ts
* parseFeishuCommentTarget('comment:docx:abc123:789')
* // => { deliveryMode: 'reply', fileType: 'docx', fileToken: 'abc123', commentId: '789' }
*
* parseFeishuCommentTarget('comment:create_whole:docx:abc123:789')
* // => { deliveryMode: 'create_whole', fileType: 'docx', fileToken: 'abc123', commentId: '789' }
*
* parseFeishuCommentTarget('oc_xxx')
* // => null
* ```
*/
export declare function parseFeishuCommentTarget(target: string): CommentTarget | null;
/**
* Return `true` when a target string looks like a comment target.
*/
export declare function isCommentTarget(target: string): boolean;
@@ -0,0 +1,100 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu Drive comment target ID parsing and formatting utilities.
*
* Comment targets use one of these formats:
* - `comment:<fileType>:<fileToken>:<commentId>` (legacy/default reply mode)
* - `comment:<deliveryMode>:<fileType>:<fileToken>:<commentId>`
*
* This enables the outbound routing layer to distinguish comment-thread
* replies from normal IM messages and route them through the Drive
* comment API instead.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildFeishuCommentTarget = buildFeishuCommentTarget;
exports.parseFeishuCommentTarget = parseFeishuCommentTarget;
exports.isCommentTarget = isCommentTarget;
const VALID_FILE_TYPES = new Set(['doc', 'docx', 'file', 'sheet', 'slides']);
const VALID_DELIVERY_MODES = new Set(['reply', 'create_whole']);
const COMMENT_PREFIX = 'comment:';
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
/**
* Construct a comment target string from its components.
*
* @example
* ```ts
* buildFeishuCommentTarget({ fileType: 'docx', fileToken: 'abc123', commentId: '789' })
* // => 'comment:docx:abc123:789'
*
* buildFeishuCommentTarget({ deliveryMode: 'create_whole', fileType: 'docx', fileToken: 'abc123', commentId: '789' })
* // => 'comment:create_whole:docx:abc123:789'
* ```
*/
function buildFeishuCommentTarget(params) {
const deliveryMode = params.deliveryMode ?? 'reply';
if (deliveryMode === 'reply') {
return `${COMMENT_PREFIX}${params.fileType}:${params.fileToken}:${params.commentId}`;
}
return `${COMMENT_PREFIX}${deliveryMode}:${params.fileType}:${params.fileToken}:${params.commentId}`;
}
// ---------------------------------------------------------------------------
// Parse
// ---------------------------------------------------------------------------
/**
* Parse a comment target string into its components.
*
* Returns `null` when the string is not a valid comment target.
*
* @example
* ```ts
* parseFeishuCommentTarget('comment:docx:abc123:789')
* // => { deliveryMode: 'reply', fileType: 'docx', fileToken: 'abc123', commentId: '789' }
*
* parseFeishuCommentTarget('comment:create_whole:docx:abc123:789')
* // => { deliveryMode: 'create_whole', fileType: 'docx', fileToken: 'abc123', commentId: '789' }
*
* parseFeishuCommentTarget('oc_xxx')
* // => null
* ```
*/
function parseFeishuCommentTarget(target) {
if (!target || !target.startsWith(COMMENT_PREFIX))
return null;
const rest = target.slice(COMMENT_PREFIX.length);
const parts = rest.split(':');
let deliveryMode = 'reply';
let fileType;
let fileToken;
let commentId;
if (parts.length === 3) {
[fileType, fileToken, commentId] = parts;
}
else if (parts.length === 4 && VALID_DELIVERY_MODES.has(parts[0])) {
[deliveryMode, fileType, fileToken, commentId] = parts;
}
else {
return null;
}
if (!VALID_FILE_TYPES.has(fileType) || !fileToken || !commentId)
return null;
return {
deliveryMode,
fileType: fileType,
fileToken,
commentId,
};
}
// ---------------------------------------------------------------------------
// Detection
// ---------------------------------------------------------------------------
/**
* Return `true` when a target string looks like a comment target.
*/
function isCommentTarget(target) {
return Boolean(target && target.startsWith(COMMENT_PREFIX));
}
+490
View File
@@ -0,0 +1,490 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Zod-based configuration schema for the OpenClaw Lark/Feishu channel plugin.
*
* Provides runtime validation, sensible defaults, and cross-field refinements
* so that every consuming module can rely on well-typed configuration objects.
*/
import { z } from 'zod';
export { z };
export declare const UATConfigSchema: z.ZodOptional<z.ZodObject<{
enabled: z.ZodOptional<z.ZodBoolean>;
allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
blockedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
export declare const FeishuGroupSchema: z.ZodObject<{
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
tools: z.ZodOptional<z.ZodObject<{
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
enabled: z.ZodOptional<z.ZodBoolean>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
systemPrompt: z.ZodOptional<z.ZodString>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
export declare const FeishuAccountConfigSchema: z.ZodObject<{
appId: z.ZodOptional<z.ZodString>;
appSecret: z.ZodOptional<z.ZodString>;
encryptKey: z.ZodOptional<z.ZodString>;
verificationToken: z.ZodOptional<z.ZodString>;
name: z.ZodOptional<z.ZodString>;
enabled: z.ZodOptional<z.ZodBoolean>;
domain: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"feishu">, z.ZodLiteral<"lark">, z.ZodString]>>;
connectionMode: z.ZodOptional<z.ZodEnum<{
websocket: "websocket";
webhook: "webhook";
}>>;
webhookPath: z.ZodOptional<z.ZodString>;
webhookPort: z.ZodOptional<z.ZodNumber>;
dmPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
pairing: "pairing";
allowlist: "allowlist";
disabled: "disabled";
}>>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
groupAllowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
tools: z.ZodOptional<z.ZodObject<{
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
enabled: z.ZodOptional<z.ZodBoolean>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
systemPrompt: z.ZodOptional<z.ZodString>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>>;
historyLimit: z.ZodOptional<z.ZodNumber>;
dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
dms: z.ZodOptional<z.ZodObject<{
historyLimit: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
textChunkLimit: z.ZodOptional<z.ZodNumber>;
chunkMode: z.ZodOptional<z.ZodEnum<{
newline: "newline";
paragraph: "paragraph";
none: "none";
}>>;
blockStreamingCoalesce: z.ZodOptional<z.ZodObject<{
minChars: z.ZodOptional<z.ZodNumber>;
maxChars: z.ZodOptional<z.ZodNumber>;
idleMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
mediaMaxMb: z.ZodOptional<z.ZodNumber>;
heartbeat: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
activeHours: z.ZodOptional<z.ZodObject<{
start: z.ZodOptional<z.ZodString>;
end: z.ZodOptional<z.ZodString>;
timezone: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
target: z.ZodOptional<z.ZodString>;
to: z.ZodOptional<z.ZodString>;
prompt: z.ZodOptional<z.ZodString>;
accountId: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
replyMode: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>, z.ZodObject<{
default: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
group: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
direct: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
}, z.core.$strip>]>>;
streaming: z.ZodOptional<z.ZodBoolean>;
blockStreaming: z.ZodOptional<z.ZodBoolean>;
toolUseDisplay: z.ZodOptional<z.ZodObject<{
showFullPaths: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
tools: z.ZodOptional<z.ZodObject<{
doc: z.ZodOptional<z.ZodBoolean>;
wiki: z.ZodOptional<z.ZodBoolean>;
drive: z.ZodOptional<z.ZodBoolean>;
perm: z.ZodOptional<z.ZodBoolean>;
scopes: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
footer: z.ZodOptional<z.ZodObject<{
status: z.ZodOptional<z.ZodBoolean>;
elapsed: z.ZodOptional<z.ZodBoolean>;
tokens: z.ZodOptional<z.ZodBoolean>;
cache: z.ZodOptional<z.ZodBoolean>;
context: z.ZodOptional<z.ZodBoolean>;
model: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
markdown: z.ZodOptional<z.ZodObject<{
tables: z.ZodOptional<z.ZodEnum<{
off: "off";
bullets: "bullets";
code: "code";
}>>;
}, z.core.$strip>>;
configWrites: z.ZodOptional<z.ZodBoolean>;
capabilities: z.ZodOptional<z.ZodObject<{
image: z.ZodOptional<z.ZodBoolean>;
audio: z.ZodOptional<z.ZodBoolean>;
video: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
dedup: z.ZodOptional<z.ZodObject<{
ttlMs: z.ZodOptional<z.ZodNumber>;
maxEntries: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
reactionNotifications: z.ZodOptional<z.ZodEnum<{
off: "off";
own: "own";
all: "all";
}>>;
threadSession: z.ZodOptional<z.ZodBoolean>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
uat: z.ZodOptional<z.ZodObject<{
enabled: z.ZodOptional<z.ZodBoolean>;
allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
blockedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>;
export declare const FeishuConfigSchema: z.ZodObject<{
appId: z.ZodOptional<z.ZodString>;
appSecret: z.ZodOptional<z.ZodString>;
encryptKey: z.ZodOptional<z.ZodString>;
verificationToken: z.ZodOptional<z.ZodString>;
name: z.ZodOptional<z.ZodString>;
enabled: z.ZodOptional<z.ZodBoolean>;
domain: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"feishu">, z.ZodLiteral<"lark">, z.ZodString]>>;
connectionMode: z.ZodOptional<z.ZodEnum<{
websocket: "websocket";
webhook: "webhook";
}>>;
webhookPath: z.ZodOptional<z.ZodString>;
webhookPort: z.ZodOptional<z.ZodNumber>;
dmPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
pairing: "pairing";
allowlist: "allowlist";
disabled: "disabled";
}>>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
groupAllowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
tools: z.ZodOptional<z.ZodObject<{
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
enabled: z.ZodOptional<z.ZodBoolean>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
systemPrompt: z.ZodOptional<z.ZodString>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>>;
historyLimit: z.ZodOptional<z.ZodNumber>;
dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
dms: z.ZodOptional<z.ZodObject<{
historyLimit: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
textChunkLimit: z.ZodOptional<z.ZodNumber>;
chunkMode: z.ZodOptional<z.ZodEnum<{
newline: "newline";
paragraph: "paragraph";
none: "none";
}>>;
blockStreamingCoalesce: z.ZodOptional<z.ZodObject<{
minChars: z.ZodOptional<z.ZodNumber>;
maxChars: z.ZodOptional<z.ZodNumber>;
idleMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
mediaMaxMb: z.ZodOptional<z.ZodNumber>;
heartbeat: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
activeHours: z.ZodOptional<z.ZodObject<{
start: z.ZodOptional<z.ZodString>;
end: z.ZodOptional<z.ZodString>;
timezone: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
target: z.ZodOptional<z.ZodString>;
to: z.ZodOptional<z.ZodString>;
prompt: z.ZodOptional<z.ZodString>;
accountId: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
replyMode: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>, z.ZodObject<{
default: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
group: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
direct: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
}, z.core.$strip>]>>;
streaming: z.ZodOptional<z.ZodBoolean>;
blockStreaming: z.ZodOptional<z.ZodBoolean>;
toolUseDisplay: z.ZodOptional<z.ZodObject<{
showFullPaths: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
tools: z.ZodOptional<z.ZodObject<{
doc: z.ZodOptional<z.ZodBoolean>;
wiki: z.ZodOptional<z.ZodBoolean>;
drive: z.ZodOptional<z.ZodBoolean>;
perm: z.ZodOptional<z.ZodBoolean>;
scopes: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
footer: z.ZodOptional<z.ZodObject<{
status: z.ZodOptional<z.ZodBoolean>;
elapsed: z.ZodOptional<z.ZodBoolean>;
tokens: z.ZodOptional<z.ZodBoolean>;
cache: z.ZodOptional<z.ZodBoolean>;
context: z.ZodOptional<z.ZodBoolean>;
model: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
markdown: z.ZodOptional<z.ZodObject<{
tables: z.ZodOptional<z.ZodEnum<{
off: "off";
bullets: "bullets";
code: "code";
}>>;
}, z.core.$strip>>;
configWrites: z.ZodOptional<z.ZodBoolean>;
capabilities: z.ZodOptional<z.ZodObject<{
image: z.ZodOptional<z.ZodBoolean>;
audio: z.ZodOptional<z.ZodBoolean>;
video: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
dedup: z.ZodOptional<z.ZodObject<{
ttlMs: z.ZodOptional<z.ZodNumber>;
maxEntries: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
reactionNotifications: z.ZodOptional<z.ZodEnum<{
off: "off";
own: "own";
all: "all";
}>>;
threadSession: z.ZodOptional<z.ZodBoolean>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
uat: z.ZodOptional<z.ZodObject<{
enabled: z.ZodOptional<z.ZodBoolean>;
allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
blockedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
accounts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
appId: z.ZodOptional<z.ZodString>;
appSecret: z.ZodOptional<z.ZodString>;
encryptKey: z.ZodOptional<z.ZodString>;
verificationToken: z.ZodOptional<z.ZodString>;
name: z.ZodOptional<z.ZodString>;
enabled: z.ZodOptional<z.ZodBoolean>;
domain: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"feishu">, z.ZodLiteral<"lark">, z.ZodString]>>;
connectionMode: z.ZodOptional<z.ZodEnum<{
websocket: "websocket";
webhook: "webhook";
}>>;
webhookPath: z.ZodOptional<z.ZodString>;
webhookPort: z.ZodOptional<z.ZodNumber>;
dmPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
pairing: "pairing";
allowlist: "allowlist";
disabled: "disabled";
}>>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
groupAllowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
groupPolicy: z.ZodOptional<z.ZodEnum<{
open: "open";
allowlist: "allowlist";
disabled: "disabled";
}>>;
requireMention: z.ZodOptional<z.ZodBoolean>;
respondToMentionAll: z.ZodOptional<z.ZodBoolean>;
tools: z.ZodOptional<z.ZodObject<{
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
deny: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
enabled: z.ZodOptional<z.ZodBoolean>;
allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
systemPrompt: z.ZodOptional<z.ZodString>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>>;
historyLimit: z.ZodOptional<z.ZodNumber>;
dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
dms: z.ZodOptional<z.ZodObject<{
historyLimit: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
textChunkLimit: z.ZodOptional<z.ZodNumber>;
chunkMode: z.ZodOptional<z.ZodEnum<{
newline: "newline";
paragraph: "paragraph";
none: "none";
}>>;
blockStreamingCoalesce: z.ZodOptional<z.ZodObject<{
minChars: z.ZodOptional<z.ZodNumber>;
maxChars: z.ZodOptional<z.ZodNumber>;
idleMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
mediaMaxMb: z.ZodOptional<z.ZodNumber>;
heartbeat: z.ZodOptional<z.ZodObject<{
every: z.ZodOptional<z.ZodString>;
activeHours: z.ZodOptional<z.ZodObject<{
start: z.ZodOptional<z.ZodString>;
end: z.ZodOptional<z.ZodString>;
timezone: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
target: z.ZodOptional<z.ZodString>;
to: z.ZodOptional<z.ZodString>;
prompt: z.ZodOptional<z.ZodString>;
accountId: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
replyMode: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>, z.ZodObject<{
default: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
group: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
direct: z.ZodOptional<z.ZodEnum<{
auto: "auto";
static: "static";
streaming: "streaming";
}>>;
}, z.core.$strip>]>>;
streaming: z.ZodOptional<z.ZodBoolean>;
blockStreaming: z.ZodOptional<z.ZodBoolean>;
toolUseDisplay: z.ZodOptional<z.ZodObject<{
showFullPaths: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
tools: z.ZodOptional<z.ZodObject<{
doc: z.ZodOptional<z.ZodBoolean>;
wiki: z.ZodOptional<z.ZodBoolean>;
drive: z.ZodOptional<z.ZodBoolean>;
perm: z.ZodOptional<z.ZodBoolean>;
scopes: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
footer: z.ZodOptional<z.ZodObject<{
status: z.ZodOptional<z.ZodBoolean>;
elapsed: z.ZodOptional<z.ZodBoolean>;
tokens: z.ZodOptional<z.ZodBoolean>;
cache: z.ZodOptional<z.ZodBoolean>;
context: z.ZodOptional<z.ZodBoolean>;
model: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
markdown: z.ZodOptional<z.ZodObject<{
tables: z.ZodOptional<z.ZodEnum<{
off: "off";
bullets: "bullets";
code: "code";
}>>;
}, z.core.$strip>>;
configWrites: z.ZodOptional<z.ZodBoolean>;
capabilities: z.ZodOptional<z.ZodObject<{
image: z.ZodOptional<z.ZodBoolean>;
audio: z.ZodOptional<z.ZodBoolean>;
video: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
dedup: z.ZodOptional<z.ZodObject<{
ttlMs: z.ZodOptional<z.ZodNumber>;
maxEntries: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
reactionNotifications: z.ZodOptional<z.ZodEnum<{
off: "off";
own: "own";
all: "all";
}>>;
threadSession: z.ZodOptional<z.ZodBoolean>;
allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
replyInThread: z.ZodOptional<z.ZodBoolean>;
uat: z.ZodOptional<z.ZodObject<{
enabled: z.ZodOptional<z.ZodBoolean>;
allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
blockedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
/**
* JSON Schema derived from FeishuConfigSchema.
*
* - `io: "input"` exposes the input type for `.transform()` schemas (e.g. AllowFromSchema).
* - `unrepresentable: "any"` degrades `.superRefine()` constraints to `{}`.
* - `target: "draft-07"` matches the plugin system's expected JSON Schema version.
*/
export declare const FEISHU_CONFIG_JSON_SCHEMA: Record<string, unknown>;
@@ -0,0 +1,223 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Zod-based configuration schema for the OpenClaw Lark/Feishu channel plugin.
*
* Provides runtime validation, sensible defaults, and cross-field refinements
* so that every consuming module can rely on well-typed configuration objects.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.FEISHU_CONFIG_JSON_SCHEMA = exports.FeishuConfigSchema = exports.FeishuAccountConfigSchema = exports.FeishuGroupSchema = exports.UATConfigSchema = exports.z = void 0;
const zod_1 = require("zod");
Object.defineProperty(exports, "z", { enumerable: true, get: function () { return zod_1.z; } });
// ---------------------------------------------------------------------------
// Shared micro-schemas
// ---------------------------------------------------------------------------
const DmPolicyEnum = zod_1.z.enum(['open', 'pairing', 'allowlist', 'disabled']);
const GroupPolicyEnum = zod_1.z.enum(['open', 'allowlist', 'disabled']);
const ConnectionModeEnum = zod_1.z.enum(['websocket', 'webhook']);
const ReplyModeValue = zod_1.z.enum(['auto', 'static', 'streaming']);
const ReplyModeSchema = zod_1.z
.union([
ReplyModeValue,
zod_1.z.object({
default: ReplyModeValue.optional(),
group: ReplyModeValue.optional(),
direct: ReplyModeValue.optional(),
}),
])
.optional();
const ChunkModeEnum = zod_1.z.enum(['newline', 'paragraph', 'none']);
const DomainSchema = zod_1.z.union([zod_1.z.literal('feishu'), zod_1.z.literal('lark'), zod_1.z.string().regex(/^https:\/\//)]).optional();
const AllowFromSchema = zod_1.z
.union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())])
.optional()
.transform((v) => {
if (v === undefined || v == null)
return undefined;
return Array.isArray(v) ? v : [v];
});
const ToolPolicySchema = zod_1.z
.object({
allow: zod_1.z.array(zod_1.z.string()).optional(),
deny: zod_1.z.array(zod_1.z.string()).optional(),
})
.optional();
const FeishuToolsFlagSchema = zod_1.z
.object({
doc: zod_1.z.boolean().optional(),
wiki: zod_1.z.boolean().optional(),
drive: zod_1.z.boolean().optional(),
perm: zod_1.z.boolean().optional(),
scopes: zod_1.z.boolean().optional(),
})
.optional();
const FeishuFooterSchema = zod_1.z
.object({
status: zod_1.z.boolean().optional(),
elapsed: zod_1.z.boolean().optional(),
tokens: zod_1.z.boolean().optional(),
cache: zod_1.z.boolean().optional(),
context: zod_1.z.boolean().optional(),
model: zod_1.z.boolean().optional(),
})
.optional();
const BlockStreamingCoalesceSchema = zod_1.z
.object({
minChars: zod_1.z.number().optional(),
maxChars: zod_1.z.number().optional(),
idleMs: zod_1.z.number().optional(),
})
.optional();
const MarkdownConfigSchema = zod_1.z
.object({
tables: zod_1.z.enum(['off', 'bullets', 'code']).optional(),
})
.optional();
const HeartbeatSchema = zod_1.z
.object({
every: zod_1.z.string().optional(),
activeHours: zod_1.z
.object({
start: zod_1.z.string().optional(),
end: zod_1.z.string().optional(),
timezone: zod_1.z.string().optional(),
})
.optional(),
target: zod_1.z.string().optional(),
to: zod_1.z.string().optional(),
prompt: zod_1.z.string().optional(),
accountId: zod_1.z.string().optional(),
})
.optional();
const CapabilitiesSchema = zod_1.z
.object({
image: zod_1.z.boolean().optional(),
audio: zod_1.z.boolean().optional(),
video: zod_1.z.boolean().optional(),
})
.optional();
const DedupSchema = zod_1.z
.object({
ttlMs: zod_1.z.number().optional(), // default 43200000 (12h)
maxEntries: zod_1.z.number().optional(), // default 5000
})
.optional();
const AllowBotsSchema = zod_1.z.union([zod_1.z.boolean(), zod_1.z.literal('mentions')]).optional();
const ReactionNotificationModeSchema = zod_1.z.enum(['off', 'own', 'all']).optional();
exports.UATConfigSchema = zod_1.z
.object({
enabled: zod_1.z.boolean().optional(),
allowedScopes: zod_1.z.array(zod_1.z.string()).optional(),
blockedScopes: zod_1.z.array(zod_1.z.string()).optional(),
})
.optional();
const DmConfigSchema = zod_1.z
.object({
historyLimit: zod_1.z.number().optional(),
})
.optional();
// ---------------------------------------------------------------------------
// Group schema
// ---------------------------------------------------------------------------
exports.FeishuGroupSchema = zod_1.z.object({
groupPolicy: GroupPolicyEnum.optional(),
requireMention: zod_1.z.boolean().optional(),
respondToMentionAll: zod_1.z.boolean().optional(),
tools: ToolPolicySchema,
skills: zod_1.z.array(zod_1.z.string()).optional(),
enabled: zod_1.z.boolean().optional(),
allowFrom: AllowFromSchema,
systemPrompt: zod_1.z.string().optional(),
allowBots: AllowBotsSchema,
// When true, bot-to-bot replies are allowed to stay inside a thread/topic
// instead of being forced to the main chat (relaxes the #32980 guard).
replyInThread: zod_1.z.boolean().optional(),
});
// ---------------------------------------------------------------------------
// Account config schema (same shape as top-level minus `accounts`)
// ---------------------------------------------------------------------------
exports.FeishuAccountConfigSchema = zod_1.z.object({
appId: zod_1.z.string().optional(),
appSecret: zod_1.z.string().optional(),
encryptKey: zod_1.z.string().optional(),
verificationToken: zod_1.z.string().optional(),
name: zod_1.z.string().optional(),
enabled: zod_1.z.boolean().optional(),
domain: DomainSchema,
connectionMode: ConnectionModeEnum.optional(),
webhookPath: zod_1.z.string().optional(),
webhookPort: zod_1.z.number().optional(),
dmPolicy: DmPolicyEnum.optional(),
allowFrom: AllowFromSchema,
groupPolicy: GroupPolicyEnum.optional(),
groupAllowFrom: AllowFromSchema,
requireMention: zod_1.z.boolean().optional(),
respondToMentionAll: zod_1.z.boolean().optional(),
groups: zod_1.z.record(zod_1.z.string(), exports.FeishuGroupSchema).optional(),
historyLimit: zod_1.z.number().optional(),
dmHistoryLimit: zod_1.z.number().optional(),
dms: DmConfigSchema,
textChunkLimit: zod_1.z.number().optional(),
chunkMode: ChunkModeEnum.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: zod_1.z.number().optional(),
heartbeat: HeartbeatSchema,
replyMode: ReplyModeSchema,
streaming: zod_1.z.boolean().optional(),
blockStreaming: zod_1.z.boolean().optional(),
toolUseDisplay: zod_1.z
.object({
showFullPaths: zod_1.z.boolean().optional(),
})
.optional(),
tools: FeishuToolsFlagSchema,
footer: FeishuFooterSchema,
markdown: MarkdownConfigSchema,
configWrites: zod_1.z.boolean().optional(),
capabilities: CapabilitiesSchema,
dedup: DedupSchema,
reactionNotifications: ReactionNotificationModeSchema,
threadSession: zod_1.z.boolean().optional(),
allowBots: AllowBotsSchema,
// Account-level default for letting bot-to-bot replies stay in a thread
// (per-group `replyInThread` overrides this).
replyInThread: zod_1.z.boolean().optional(),
uat: exports.UATConfigSchema,
});
// ---------------------------------------------------------------------------
// Top-level Feishu config schema
// ---------------------------------------------------------------------------
exports.FeishuConfigSchema = exports.FeishuAccountConfigSchema.extend({
accounts: zod_1.z.record(zod_1.z.string(), exports.FeishuAccountConfigSchema).optional(),
}).superRefine((data, ctx) => {
// When dmPolicy is "open", allowFrom must contain the wildcard "*".
if (data.dmPolicy === 'open') {
const list = data.allowFrom;
const hasWildcard = Array.isArray(list) && list.includes('*');
if (!hasWildcard) {
ctx.addIssue({
code: zod_1.z.ZodIssueCode.custom,
path: ['allowFrom'],
message: 'When dmPolicy is "open", allowFrom must include "*" to permit all senders.',
});
}
}
});
// ---------------------------------------------------------------------------
// Auto-generated JSON Schema (single source of truth)
// ---------------------------------------------------------------------------
/**
* JSON Schema derived from FeishuConfigSchema.
*
* - `io: "input"` exposes the input type for `.transform()` schemas (e.g. AllowFromSchema).
* - `unrepresentable: "any"` degrades `.superRefine()` constraints to `{}`.
* - `target: "draft-07"` matches the plugin system's expected JSON Schema version.
*/
exports.FEISHU_CONFIG_JSON_SCHEMA = (0, zod_1.toJSONSchema)(exports.FeishuConfigSchema, {
target: 'draft-07',
io: 'input',
unrepresentable: 'any',
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* OAuth 2.0 Device Authorization Grant (RFC 8628) for Lark/Feishu.
*
* Two-step flow:
* 1. `requestDeviceAuthorization` obtains device_code + user_code.
* 2. `pollDeviceToken` polls the token endpoint until the user authorises,
* rejects, or the code expires.
*
* All HTTP calls use the built-in `fetch` (Node 18+). The Lark SDK is not
* used here because these OAuth endpoints are outside the SDK's scope.
*/
import type { LarkBrand } from './types';
export interface DeviceAuthResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
expiresIn: number;
interval: number;
}
export interface DeviceFlowTokenData {
accessToken: string;
refreshToken: string;
expiresIn: number;
refreshExpiresIn: number;
scope: string;
}
export type DeviceFlowResult = {
ok: true;
token: DeviceFlowTokenData;
} | {
ok: false;
error: DeviceFlowError;
message: string;
};
export type DeviceFlowError = 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token';
/**
* Resolve the two OAuth endpoint URLs based on the configured brand.
*/
export declare function resolveOAuthEndpoints(brand: LarkBrand): {
deviceAuthorization: string;
token: string;
};
/**
* Request a device authorisation code from the Feishu OAuth server.
*
* Uses Confidential Client authentication (HTTP Basic with appId:appSecret).
* The `offline_access` scope is automatically appended so that the token
* response includes a refresh_token.
*/
export declare function requestDeviceAuthorization(params: {
appId: string;
appSecret: string;
brand: LarkBrand;
scope?: string;
}): Promise<DeviceAuthResponse>;
/**
* Poll the token endpoint until the user authorises, rejects, or the code
* expires.
*
* Handles `authorization_pending` (keep polling), `slow_down` (back off by
* +5 s), `access_denied` and `expired_token` (terminal errors).
*
* Pass an `AbortSignal` to cancel polling from the outside.
*/
export declare function pollDeviceToken(params: {
appId: string;
appSecret: string;
brand: LarkBrand;
deviceCode: string;
interval: number;
expiresIn: number;
signal?: AbortSignal;
}): Promise<DeviceFlowResult>;
@@ -0,0 +1,217 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* OAuth 2.0 Device Authorization Grant (RFC 8628) for Lark/Feishu.
*
* Two-step flow:
* 1. `requestDeviceAuthorization` obtains device_code + user_code.
* 2. `pollDeviceToken` polls the token endpoint until the user authorises,
* rejects, or the code expires.
*
* All HTTP calls use the built-in `fetch` (Node 18+). The Lark SDK is not
* used here because these OAuth endpoints are outside the SDK's scope.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveOAuthEndpoints = resolveOAuthEndpoints;
exports.requestDeviceAuthorization = requestDeviceAuthorization;
exports.pollDeviceToken = pollDeviceToken;
const lark_logger_1 = require("./lark-logger.js");
const log = (0, lark_logger_1.larkLogger)('core/device-flow');
const feishu_fetch_1 = require("./feishu-fetch.js");
// ---------------------------------------------------------------------------
// Endpoint resolution
// ---------------------------------------------------------------------------
/**
* Resolve the two OAuth endpoint URLs based on the configured brand.
*/
function resolveOAuthEndpoints(brand) {
if (!brand || brand === 'feishu') {
return {
deviceAuthorization: 'https://accounts.feishu.cn/oauth/v1/device_authorization',
token: 'https://open.feishu.cn/open-apis/authen/v2/oauth/token',
};
}
if (brand === 'lark') {
return {
deviceAuthorization: 'https://accounts.larksuite.com/oauth/v1/device_authorization',
token: 'https://open.larksuite.com/open-apis/authen/v2/oauth/token',
};
}
// Custom domain derive paths by convention.
// Smart derivation: open.X → accounts.X for the device authorization endpoint.
const base = brand.replace(/\/+$/, '');
let accountsBase = base;
try {
const parsed = new URL(base);
if (parsed.hostname.startsWith('open.')) {
accountsBase = `${parsed.protocol}//${parsed.hostname.replace(/^open\./, 'accounts.')}`;
}
}
catch {
/* fallback to base */
}
return {
deviceAuthorization: `${accountsBase}/oauth/v1/device_authorization`,
token: `${base}/open-apis/authen/v2/oauth/token`,
};
}
// ---------------------------------------------------------------------------
// Step 1 Device Authorization Request
// ---------------------------------------------------------------------------
/**
* Request a device authorisation code from the Feishu OAuth server.
*
* Uses Confidential Client authentication (HTTP Basic with appId:appSecret).
* The `offline_access` scope is automatically appended so that the token
* response includes a refresh_token.
*/
async function requestDeviceAuthorization(params) {
const { appId, appSecret, brand } = params;
const endpoints = resolveOAuthEndpoints(brand);
// Ensure offline_access is always requested.
let scope = params.scope ?? '';
if (!scope.includes('offline_access')) {
scope = scope ? `${scope} offline_access` : 'offline_access';
}
const basicAuth = Buffer.from(`${appId}:${appSecret}`).toString('base64');
const body = new URLSearchParams();
body.set('client_id', appId);
body.set('scope', scope);
log.info(`requesting device authorization (scope="${scope}") url=${endpoints.deviceAuthorization} token_url=${endpoints.token}`);
const resp = await (0, feishu_fetch_1.feishuFetch)(endpoints.deviceAuthorization, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: body.toString(),
});
const text = await resp.text();
log.info(`response status=${resp.status} body=${text.slice(0, 500)}`);
let data;
try {
data = JSON.parse(text);
}
catch {
throw new Error(`Device authorization failed: HTTP ${resp.status} ${text.slice(0, 200)}`);
}
if (!resp.ok || data.error) {
const msg = data.error_description ?? data.error ?? 'Unknown error';
throw new Error(`Device authorization failed: ${msg}`);
}
const expiresIn = data.expires_in ?? 240;
const interval = data.interval ?? 5;
log.info(`device_code obtained, expires_in=${expiresIn}s (${Math.round(expiresIn / 60)}min), interval=${interval}s`);
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
verificationUriComplete: data.verification_uri_complete ?? data.verification_uri,
expiresIn,
interval,
};
}
// ---------------------------------------------------------------------------
// Step 2 Poll Token Endpoint
// ---------------------------------------------------------------------------
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
/**
* Poll the token endpoint until the user authorises, rejects, or the code
* expires.
*
* Handles `authorization_pending` (keep polling), `slow_down` (back off by
* +5 s), `access_denied` and `expired_token` (terminal errors).
*
* Pass an `AbortSignal` to cancel polling from the outside.
*/
async function pollDeviceToken(params) {
const MAX_POLL_INTERVAL = 60; // slow_down 最大间隔 60 秒
const MAX_POLL_ATTEMPTS = 200; // 安全上限(远超设备码有效期)
const { appId, appSecret, brand, deviceCode, expiresIn, signal } = params;
let interval = params.interval;
const endpoints = resolveOAuthEndpoints(brand);
const deadline = Date.now() + expiresIn * 1000;
let attempts = 0;
while (Date.now() < deadline && attempts < MAX_POLL_ATTEMPTS) {
attempts++;
if (signal?.aborted) {
return { ok: false, error: 'expired_token', message: 'Polling was cancelled' };
}
await sleep(interval * 1000, signal);
let data;
try {
const resp = await (0, feishu_fetch_1.feishuFetch)(endpoints.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode,
client_id: appId,
client_secret: appSecret,
}).toString(),
});
data = (await resp.json());
}
catch (err) {
log.warn(`poll network error: ${err}`);
interval = Math.min(interval + 1, MAX_POLL_INTERVAL);
continue;
}
const error = data.error;
if (!error && data.access_token) {
log.info('token obtained successfully');
const refreshToken = data.refresh_token ?? '';
const expiresIn = data.expires_in ?? 7200;
let refreshExpiresIn = data.refresh_token_expires_in ?? 604800;
if (!refreshToken) {
log.warn('no refresh_token in response, token will not be refreshable');
refreshExpiresIn = expiresIn;
}
return {
ok: true,
token: {
accessToken: data.access_token,
refreshToken,
expiresIn,
refreshExpiresIn,
scope: data.scope ?? '',
},
};
}
if (error === 'authorization_pending') {
log.debug('authorization_pending, retrying...');
continue;
}
if (error === 'slow_down') {
interval = Math.min(interval + 5, MAX_POLL_INTERVAL);
log.info(`slow_down, interval increased to ${interval}s`);
continue;
}
if (error === 'access_denied') {
log.info('user denied authorization');
return { ok: false, error: 'access_denied', message: '用户拒绝了授权' };
}
if (error === 'expired_token' || error === 'invalid_grant') {
log.info(`device code expired/invalid (error=${error})`);
return { ok: false, error: 'expired_token', message: '授权码已过期,请重新发起' };
}
// Unknown error treat as terminal.
const desc = data.error_description ?? error ?? 'Unknown error';
log.warn(`unexpected error: error=${error}, desc=${desc}`);
return { ok: false, error: 'expired_token', message: desc };
}
if (attempts >= MAX_POLL_ATTEMPTS) {
log.warn(`max poll attempts (${MAX_POLL_ATTEMPTS}) reached`);
}
return { ok: false, error: 'expired_token', message: '授权超时,请重新发起' };
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Centralized domain helpers for Feishu / Lark brand-aware URL generation.
*
* All runtime code that needs to construct platform URLs should use these
* helpers instead of hardcoding domain strings.
*/
import type { LarkBrand } from './types';
/** 开放平台域名 (API & 权限管理页面) */
export declare function openPlatformDomain(brand?: LarkBrand): string;
/** Applink 域名 */
export declare function applinkDomain(brand?: LarkBrand): string;
/** 主站域名 (文档、表格等用户可见链接) */
export declare function wwwDomain(brand?: LarkBrand): string;
/** MCP 服务域名 */
export declare function mcpDomain(brand?: LarkBrand): string;
@@ -0,0 +1,34 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Centralized domain helpers for Feishu / Lark brand-aware URL generation.
*
* All runtime code that needs to construct platform URLs should use these
* helpers instead of hardcoding domain strings.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.openPlatformDomain = openPlatformDomain;
exports.applinkDomain = applinkDomain;
exports.wwwDomain = wwwDomain;
exports.mcpDomain = mcpDomain;
// ---------------------------------------------------------------------------
// Domain helpers
// ---------------------------------------------------------------------------
/** 开放平台域名 (API & 权限管理页面) */
function openPlatformDomain(brand) {
return brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
}
/** Applink 域名 */
function applinkDomain(brand) {
return brand === 'lark' ? 'https://applink.larksuite.com' : 'https://applink.feishu.cn';
}
/** 主站域名 (文档、表格等用户可见链接) */
function wwwDomain(brand) {
return brand === 'lark' ? 'https://www.larksuite.com' : 'https://www.feishu.cn';
}
/** MCP 服务域名 */
function mcpDomain(brand) {
return brand === 'lark' ? 'https://mcp.larksuite.com' : 'https://mcp.feishu.cn';
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Header-aware fetch for Feishu API calls.
*
* Drop-in replacement for `fetch()` that automatically injects
* the User-Agent header.
*/
/**
* Drop-in replacement for `fetch()` that automatically injects
* the User-Agent header.
*
* Used by `device-flow.ts` and `uat-client.ts` so that the custom
* User-Agent is transparently applied without changing every
* call-site's signature.
*/
export declare function feishuFetch(url: string | URL | Request, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,28 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Header-aware fetch for Feishu API calls.
*
* Drop-in replacement for `fetch()` that automatically injects
* the User-Agent header.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.feishuFetch = feishuFetch;
const version_1 = require("./version.js");
/**
* Drop-in replacement for `fetch()` that automatically injects
* the User-Agent header.
*
* Used by `device-flow.ts` and `uat-client.ts` so that the custom
* User-Agent is transparently applied without changing every
* call-site's signature.
*/
function feishuFetch(url, init) {
const headers = {
...init?.headers,
'User-Agent': (0, version_1.getUserAgent)(),
};
return fetch(url, { ...init, headers });
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Default values and resolution logic for the Feishu card footer configuration.
*
* Each boolean flag controls whether a particular metadata item is displayed
* in the card footer (e.g. elapsed time, model name).
*/
import type { FeishuFooterConfig } from './types';
/**
* The default footer configuration.
*
* By default all metadata items are hidden — neither status text
* ("已完成" / "出错" / "已停止") nor elapsed time are shown.
*/
export declare const DEFAULT_FOOTER_CONFIG: Required<FeishuFooterConfig>;
/**
* Merge a partial footer configuration with `DEFAULT_FOOTER_CONFIG`.
*
* Fields present in the input take precedence; anything absent falls back
* to the default value.
*/
export declare function resolveFooterConfig(cfg?: FeishuFooterConfig): Required<FeishuFooterConfig>;
@@ -0,0 +1,51 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Default values and resolution logic for the Feishu card footer configuration.
*
* Each boolean flag controls whether a particular metadata item is displayed
* in the card footer (e.g. elapsed time, model name).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_FOOTER_CONFIG = void 0;
exports.resolveFooterConfig = resolveFooterConfig;
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
/**
* The default footer configuration.
*
* By default all metadata items are hidden — neither status text
* ("已完成" / "出错" / "已停止") nor elapsed time are shown.
*/
exports.DEFAULT_FOOTER_CONFIG = {
status: false,
elapsed: false,
tokens: false,
cache: false,
context: false,
model: false,
};
// ---------------------------------------------------------------------------
// Resolver
// ---------------------------------------------------------------------------
/**
* Merge a partial footer configuration with `DEFAULT_FOOTER_CONFIG`.
*
* Fields present in the input take precedence; anything absent falls back
* to the default value.
*/
function resolveFooterConfig(cfg) {
if (!cfg)
return { ...exports.DEFAULT_FOOTER_CONFIG };
return {
status: cfg.status ?? exports.DEFAULT_FOOTER_CONFIG.status,
elapsed: cfg.elapsed ?? exports.DEFAULT_FOOTER_CONFIG.elapsed,
tokens: cfg.tokens ?? exports.DEFAULT_FOOTER_CONFIG.tokens,
cache: cfg.cache ?? exports.DEFAULT_FOOTER_CONFIG.cache,
context: cfg.context ?? exports.DEFAULT_FOOTER_CONFIG.context,
model: cfg.model ?? exports.DEFAULT_FOOTER_CONFIG.model,
};
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu / Lark SDK client management.
*
* Provides `LarkClient` — a unified manager for Lark SDK client instances,
* WebSocket connections, EventDispatcher lifecycle, and bot identity.
*
* Consumers obtain instances via factory methods:
* - `LarkClient.fromCfg(cfg, accountId)` — resolve account from config
* - `LarkClient.fromAccount(account)` — from a pre-resolved account
* - `LarkClient.fromCredentials(credentials)` — ephemeral instance (not cached)
*/
import * as Lark from '@larksuiteoapi/node-sdk';
import type { ClawdbotConfig, PluginRuntime } from 'openclaw/plugin-sdk';
import type { MessageDedup } from '../messaging/inbound/dedup';
import type { FeishuProbeResult, LarkAccount, LarkBrand } from './types';
/** Credential set accepted by the ephemeral `fromCredentials` factory. */
export interface LarkClientCredentials {
accountId?: string;
appId?: string;
appSecret?: string;
brand?: LarkBrand;
}
export declare class LarkClient {
readonly account: LarkAccount;
private _sdk;
private _wsClient;
private _botOpenId;
private _botName;
private _lastProbeResult;
private _lastProbeAt;
/** Attached message deduplicator — disposed together with the client. */
messageDedup: MessageDedup | null;
/** Persist the runtime instance for later retrieval (activate 阶段调用一次). */
static setRuntime(runtime: PluginRuntime): void;
/** Retrieve the stored runtime instance. Throws if not yet initialised. */
static get runtime(): PluginRuntime;
private static _globalConfig;
/** Store the original global config (called during monitor startup). */
static setGlobalConfig(cfg: ClawdbotConfig): void;
/** Retrieve the stored global config, or `null` if not yet set. */
static get globalConfig(): ClawdbotConfig | null;
private constructor();
/** Shorthand for `this.account.accountId`. */
get accountId(): string;
/** Resolve account from config and return a cached `LarkClient`. */
static fromCfg(cfg: ClawdbotConfig, accountId?: string): LarkClient;
/**
* Get (or create) a cached `LarkClient` for the given account.
* If the cached instance has stale credentials it is replaced.
*/
static fromAccount(account: LarkAccount): LarkClient;
/**
* Create an ephemeral `LarkClient` from bare credentials.
* The instance is **not** added to the global cache — suitable for
* one-off probe / diagnose calls that should not pollute account state.
*/
static fromCredentials(credentials: LarkClientCredentials): LarkClient;
/** Look up a cached instance by accountId. */
static get(accountId: string): LarkClient | null;
/**
* Dispose one or all cached instances.
* With `accountId` — dispose that single instance.
* Without — dispose every cached instance and clear the cache.
*/
static clearCache(accountId?: string): Promise<void>;
/** Lazily-created Lark SDK client. */
get sdk(): Lark.Client;
/**
* Probe bot identity via the `bot/v1/openclaw_bot/ping` API.
* Results are cached on the instance for subsequent access via
* `botOpenId` / `botName`.
*/
probe(opts?: {
maxAgeMs?: number;
needBotInfo?: boolean;
}): Promise<FeishuProbeResult>;
/** Cached bot open_id (available after `probe()` or `startWS()`). */
get botOpenId(): string | undefined;
/** Cached bot name (available after `probe()` or `startWS()`). */
get botName(): string | undefined;
/**
* Start WebSocket event monitoring.
*
* Flow: probe bot identity → EventDispatcher → WSClient → start.
* The returned Promise resolves when `abortSignal` fires.
*/
startWS(opts: {
handlers: Record<string, (data: unknown) => Promise<void>>;
abortSignal?: AbortSignal;
autoProbe?: boolean;
}): Promise<void>;
/** Whether a WebSocket client is currently active. */
get wsConnected(): boolean;
/** Disconnect WebSocket but keep instance in cache. */
disconnect(): void;
/** Disconnect + remove from cache. */
dispose(): void;
/** Assert credentials exist or throw. */
private requireCredentials;
/**
* Start the WSClient and return a promise that resolves when the
* abort signal fires (or immediately if already aborted).
*/
private waitForAbort;
}
/**
* Returns the best available config for account resolution.
*
* Priority: live config (has `channels.feishu`) > fallback (has
* `channels.feishu`) > live config (last resort).
*
* The `config` object captured in tool-registration closures may be stale
* after a hot-reload, so we prefer the live config from
* `LarkClient.runtime.config.loadConfig()`. However, `loadConfig()` may
* return `{}` when the runtime config snapshot has been cleared (e.g. in
* isolated cron sessions), so we fall back to the closure-captured config
* when the live result lacks Feishu credentials.
*
* @param fallback - Config to use when the runtime is not yet initialised
* or when `loadConfig()` returns an incomplete config.
*/
export declare function getResolvedConfig(fallback: ClawdbotConfig): ClawdbotConfig;
@@ -0,0 +1,468 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Feishu / Lark SDK client management.
*
* Provides `LarkClient` — a unified manager for Lark SDK client instances,
* WebSocket connections, EventDispatcher lifecycle, and bot identity.
*
* Consumers obtain instances via factory methods:
* - `LarkClient.fromCfg(cfg, accountId)` — resolve account from config
* - `LarkClient.fromAccount(account)` — from a pre-resolved account
* - `LarkClient.fromCredentials(credentials)` — ephemeral instance (not cached)
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
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.LarkClient = void 0;
exports.getResolvedConfig = getResolvedConfig;
const Lark = __importStar(require("@larksuiteoapi/node-sdk"));
const user_name_cache_store_1 = require("../messaging/inbound/user-name-cache-store.js");
const accounts_1 = require("./accounts.js");
const chat_info_cache_1 = require("./chat-info-cache.js");
const lark_logger_1 = require("./lark-logger.js");
const runtime_store_1 = require("./runtime-store.js");
const version_1 = require("./version.js");
const log = (0, lark_logger_1.larkLogger)('core/lark-client');
// ---------------------------------------------------------------------------
// Inject User-Agent into all Feishu SDK requests
// ---------------------------------------------------------------------------
const GLOBAL_LARK_USER_AGENT_KEY = 'LARK_USER_AGENT';
function installGlobalUserAgent() {
// node-sdk's built-in interceptor reads global.LARK_USER_AGENT to override User-Agent
globalThis[GLOBAL_LARK_USER_AGENT_KEY] = (0, version_1.getUserAgent)();
}
installGlobalUserAgent();
// Disable axios auto-proxy to prevent HTTP_PROXY env vars from corrupting request URLs.
// Proxy routing is managed centrally by OpenClaw core's global-agent.
Lark.defaultHttpInstance.defaults.proxy = false;
Lark.defaultHttpInstance.interceptors.request.handlers = [];
// Inject User-Agent header into all HTTP requests via interceptor
Lark.defaultHttpInstance.interceptors.request.use((req) => {
if (req.headers) {
req.headers['User-Agent'] = (0, version_1.getUserAgent)();
}
return req;
}, undefined, { synchronous: true });
// ---------------------------------------------------------------------------
// Brand → SDK domain
// ---------------------------------------------------------------------------
const BRAND_TO_DOMAIN = {
feishu: Lark.Domain.Feishu,
lark: Lark.Domain.Lark,
};
/** Map a `LarkBrand` to the SDK `domain` parameter. */
function resolveBrand(brand) {
return BRAND_TO_DOMAIN[brand ?? 'feishu'] ?? brand.replace(/\/+$/, '');
}
// ---------------------------------------------------------------------------
// LarkClient
// ---------------------------------------------------------------------------
/** Instance cache keyed by accountId. */
const cache = new Map();
/**
* Compare two SecretRef-shaped objects by their identity fields.
* Key-order independent, unlike JSON.stringify.
*/
function secretRefsEqual(a, b) {
return a.source === b.source && a.provider === b.provider && a.id === b.id;
}
/**
* Compare two credential values that may be strings or SecretRef objects.
*
* - Both strings: direct `===`.
* - Both SecretRef objects: compare `source`, `provider`, `id` explicitly.
* - Mixed (string vs SecretRef): treat as equal — the platform resolves the
* SecretRef at startup (producing the cached string) but `loadConfig()`
* returns the raw object on subsequent calls. Detecting SecretRef identity
* changes is not useful here because the platform does not re-resolve
* feishu secrets on reload, so a new SecretRef would be equally unusable.
*/
function credentialsEqual(a, b) {
if (a === b)
return true;
if (typeof a === 'string' && typeof b === 'string')
return false;
if (a && b && typeof a === 'object' && typeof b === 'object') {
return secretRefsEqual(a, b);
}
// Mixed types: keep the cached instance that holds the working string.
if ((typeof a === 'string' && b && typeof b === 'object') || (typeof b === 'string' && a && typeof a === 'object')) {
return true;
}
return false;
}
class LarkClient {
account;
_sdk = null;
_wsClient = null;
_botOpenId;
_botName;
_lastProbeResult = null;
_lastProbeAt = 0;
/** Attached message deduplicator — disposed together with the client. */
messageDedup = null;
// ---- Plugin runtime (singleton) ------------------------------------------
/** Persist the runtime instance for later retrieval (activate 阶段调用一次). */
static setRuntime(runtime) {
(0, runtime_store_1.setLarkRuntime)(runtime);
}
/** Retrieve the stored runtime instance. Throws if not yet initialised. */
static get runtime() {
return (0, runtime_store_1.getLarkRuntime)();
}
// ---- Global config (singleton) -------------------------------------------
//
// Plugin commands receive an account-scoped config (channels.feishu replaced
// with the merged per-account config, `accounts` map stripped). Commands
// that need cross-account visibility (e.g. doctor, diagnose) read the
// original global config from here.
static _globalConfig = null;
/** Store the original global config (called during monitor startup). */
static setGlobalConfig(cfg) {
LarkClient._globalConfig = cfg;
}
/** Retrieve the stored global config, or `null` if not yet set. */
static get globalConfig() {
return LarkClient._globalConfig;
}
// --------------------------------------------------------------------------
constructor(account) {
this.account = account;
}
/** Shorthand for `this.account.accountId`. */
get accountId() {
return this.account.accountId;
}
// ---- Static factory / cache ------------------------------------------------
/** Resolve account from config and return a cached `LarkClient`. */
static fromCfg(cfg, accountId) {
return LarkClient.fromAccount((0, accounts_1.getLarkAccount)(cfg, accountId));
}
/**
* Get (or create) a cached `LarkClient` for the given account.
* If the cached instance has stale credentials it is replaced.
*/
static fromAccount(account) {
const existing = cache.get(account.accountId);
if (existing &&
existing.account.appId === account.appId &&
credentialsEqual(existing.account.appSecret, account.appSecret)) {
return existing;
}
// Credentials changed — tear down the stale instance before replacing it.
if (existing) {
log.info(`credentials changed, disposing stale instance`, { accountId: account.accountId });
existing.dispose();
}
const instance = new LarkClient(account);
cache.set(account.accountId, instance);
return instance;
}
/**
* Create an ephemeral `LarkClient` from bare credentials.
* The instance is **not** added to the global cache — suitable for
* one-off probe / diagnose calls that should not pollute account state.
*/
static fromCredentials(credentials) {
const base = {
accountId: credentials.accountId ?? 'default',
enabled: true,
brand: credentials.brand ?? 'feishu',
config: {},
};
const account = credentials.appId && credentials.appSecret
? { ...base, configured: true, appId: credentials.appId, appSecret: credentials.appSecret }
: { ...base, configured: false, appId: credentials.appId, appSecret: credentials.appSecret };
return new LarkClient(account);
}
/** Look up a cached instance by accountId. */
static get(accountId) {
return cache.get(accountId) ?? null;
}
/**
* Dispose one or all cached instances.
* With `accountId` — dispose that single instance.
* Without — dispose every cached instance and clear the cache.
*/
static async clearCache(accountId) {
if (accountId !== undefined) {
cache.get(accountId)?.dispose();
(0, user_name_cache_store_1.clearUserNameCache)(accountId);
(0, chat_info_cache_1.clearChatInfoCache)(accountId);
}
else {
for (const inst of cache.values())
inst.dispose();
(0, user_name_cache_store_1.clearUserNameCache)();
(0, chat_info_cache_1.clearChatInfoCache)();
}
}
// ---- SDK client (lazy) -----------------------------------------------------
/** Lazily-created Lark SDK client. */
get sdk() {
if (!this._sdk) {
const { appId, appSecret } = this.requireCredentials();
this._sdk = new Lark.Client({
appId,
appSecret,
appType: Lark.AppType.SelfBuild,
domain: resolveBrand(this.account.brand),
});
}
return this._sdk;
}
// ---- Bot identity ----------------------------------------------------------
/**
* Probe bot identity via the `bot/v1/openclaw_bot/ping` API.
* Results are cached on the instance for subsequent access via
* `botOpenId` / `botName`.
*/
async probe(opts) {
const maxAge = opts?.maxAgeMs ?? 0;
if (maxAge > 0 && this._lastProbeResult && Date.now() - this._lastProbeAt < maxAge) {
return this._lastProbeResult;
}
if (!this.account.appId || !this.account.appSecret) {
return { ok: false, error: 'missing credentials (appId, appSecret)' };
}
try {
const needBotInfo = opts?.needBotInfo ?? true;
const res = await this.sdk.request({
method: 'POST',
url: '/open-apis/bot/v1/openclaw_bot/ping',
data: { needBotInfo },
});
if (res.code !== 0) {
const result = {
ok: false,
appId: this.account.appId,
error: `API error: ${res.msg || `code ${res.code}`}`,
};
this._lastProbeResult = result;
this._lastProbeAt = Date.now();
return result;
}
const botInfo = res.data?.pingBotInfo;
this._botOpenId = botInfo?.botID;
this._botName = botInfo?.botName;
const result = {
ok: true,
appId: this.account.appId,
botName: this._botName,
botOpenId: this._botOpenId,
};
this._lastProbeResult = result;
this._lastProbeAt = Date.now();
return result;
}
catch (err) {
const result = {
ok: false,
appId: this.account.appId,
error: err instanceof Error ? err.message : String(err),
};
this._lastProbeResult = result;
this._lastProbeAt = Date.now();
return result;
}
}
/** Cached bot open_id (available after `probe()` or `startWS()`). */
get botOpenId() {
return this._botOpenId;
}
/** Cached bot name (available after `probe()` or `startWS()`). */
get botName() {
return this._botName;
}
// ---- WebSocket lifecycle ---------------------------------------------------
/**
* Start WebSocket event monitoring.
*
* Flow: probe bot identity → EventDispatcher → WSClient → start.
* The returned Promise resolves when `abortSignal` fires.
*/
async startWS(opts) {
const { handlers, abortSignal, autoProbe = true } = opts;
if (autoProbe)
await this.probe();
const dispatcher = new Lark.EventDispatcher({
encryptKey: this.account.encryptKey ?? '',
verificationToken: this.account.verificationToken ?? '',
});
dispatcher.register(handlers);
const { appId, appSecret } = this.requireCredentials();
// Close any existing WSClient before creating a new one to prevent
// orphaned connections when startWS is called multiple times.
if (this._wsClient) {
log.warn(`closing previous WSClient before reconnect`, { accountId: this.accountId });
try {
this._wsClient.close({ force: true });
}
catch {
// Ignore — the old client may already be torn down.
}
this._wsClient = null;
}
this._wsClient = new Lark.WSClient({
appId,
appSecret,
domain: resolveBrand(this.account.brand),
loggerLevel: Lark.LoggerLevel.info,
});
// SDK 的 handleEventData 只处理 type="event"card action 回调是 type="card" 会被丢弃。
// 打 patch 将 "card" 类型消息改成 "event" 后交给原 handler,让 EventDispatcher 正常路由。
const wsClientAny = this._wsClient;
const origHandleEventData = wsClientAny.handleEventData.bind(wsClientAny);
wsClientAny.handleEventData = (data) => {
const msgType = data.headers?.find?.((h) => h.key === 'type')?.value;
if (msgType === 'card') {
const patchedData = {
...data,
headers: data.headers.map((h) => (h.key === 'type' ? { ...h, value: 'event' } : h)),
};
return origHandleEventData(patchedData);
}
return origHandleEventData(data);
};
await this.waitForAbort(dispatcher, abortSignal);
}
/** Whether a WebSocket client is currently active. */
get wsConnected() {
return this._wsClient != null;
}
/** Disconnect WebSocket but keep instance in cache. */
disconnect() {
if (this._wsClient) {
log.info(`disconnecting WebSocket`, { accountId: this.accountId });
try {
this._wsClient.close({ force: true });
}
catch {
// Ignore errors during close — the client may already be torn down.
}
}
this._wsClient = null;
if (this.messageDedup) {
log.info(`disposing message dedup`, { accountId: this.accountId, size: this.messageDedup.size });
this.messageDedup.dispose();
this.messageDedup = null;
}
}
/** Disconnect + remove from cache. */
dispose() {
this.disconnect();
cache.delete(this.accountId);
}
// ---- Private helpers -------------------------------------------------------
/** Assert credentials exist or throw. */
requireCredentials() {
const appId = this.account.appId;
const appSecret = this.account.appSecret;
if (!appId || !appSecret) {
throw new Error(`LarkClient[${this.accountId}]: appId and appSecret are required`);
}
return { appId, appSecret };
}
/**
* Start the WSClient and return a promise that resolves when the
* abort signal fires (or immediately if already aborted).
*/
waitForAbort(dispatcher, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
this.disconnect();
return resolve();
}
signal?.addEventListener('abort', () => {
this.disconnect();
resolve();
}, { once: true });
try {
void this._wsClient.start({ eventDispatcher: dispatcher });
}
catch (err) {
this.disconnect();
reject(err);
}
});
}
}
exports.LarkClient = LarkClient;
// Inject LarkClient reference into chat-info-cache to break the circular
// dependency (chat-info-cache needs LarkClient.fromCfg but lark-client
// imports clearChatInfoCache from chat-info-cache).
(0, chat_info_cache_1.injectLarkClient)(LarkClient);
// ---------------------------------------------------------------------------
// Config resolution helper
// ---------------------------------------------------------------------------
/**
* Returns the best available config for account resolution.
*
* Priority: live config (has `channels.feishu`) > fallback (has
* `channels.feishu`) > live config (last resort).
*
* The `config` object captured in tool-registration closures may be stale
* after a hot-reload, so we prefer the live config from
* `LarkClient.runtime.config.loadConfig()`. However, `loadConfig()` may
* return `{}` when the runtime config snapshot has been cleared (e.g. in
* isolated cron sessions), so we fall back to the closure-captured config
* when the live result lacks Feishu credentials.
*
* @param fallback - Config to use when the runtime is not yet initialised
* or when `loadConfig()` returns an incomplete config.
*/
function getResolvedConfig(fallback) {
try {
const live = LarkClient.runtime.config.loadConfig();
// loadConfig() may return {} (empty config) when runtimeConfigSnapshot
// has been cleared (e.g. after writeConfigFile, secrets teardown, or
// concurrent cron race conditions in isolated sessions). In that case
// the closure-captured fallback still holds a valid resolved config.
if (live?.channels?.feishu)
return live;
if (fallback?.channels?.feishu) {
log.debug(`loadConfig() returned config without channels.feishu, using fallback`);
return fallback;
}
return live;
}
catch {
// runtime not yet initialised — fall back to passed config
return fallback;
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Structured logger factory for the Feishu plugin.
*
* Wraps `PluginRuntime.logging.getChildLogger()` with automatic
* LarkTicket injection from AsyncLocalStorage and a console fallback
* when the runtime is not yet initialised.
*
* Usage:
* const log = larkLogger("card/streaming");
* log.info("created entity", { cardId, sequence });
*/
export interface LarkLogger {
readonly subsystem: string;
debug(message: string, meta?: Record<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
child(name: string): LarkLogger;
}
export declare function larkLogger(subsystem: string): LarkLogger;
@@ -0,0 +1,160 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Structured logger factory for the Feishu plugin.
*
* Wraps `PluginRuntime.logging.getChildLogger()` with automatic
* LarkTicket injection from AsyncLocalStorage and a console fallback
* when the runtime is not yet initialised.
*
* Usage:
* const log = larkLogger("card/streaming");
* log.info("created entity", { cardId, sequence });
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.larkLogger = larkLogger;
const lark_ticket_1 = require("./lark-ticket.js");
const runtime_store_1 = require("./runtime-store.js");
// ---------------------------------------------------------------------------
// Console fallback (with ANSI colors)
// ---------------------------------------------------------------------------
// ANSI escape codes for colored console output
const CYAN = '\x1b[36m';
const YELLOW = '\x1b[33m';
const RED = '\x1b[31m';
const GRAY = '\x1b[90m';
const RESET = '\x1b[0m';
function consoleFallback(subsystem) {
const tag = `feishu/${subsystem}`;
/* eslint-disable no-console -- logger底层实现,console 是最终输出目标 */
return {
debug: (msg, meta) => console.debug(`${GRAY}[${tag}]${RESET}`, msg, ...(meta ? [meta] : [])),
info: (msg, meta) => console.log(`${CYAN}[${tag}]${RESET}`, msg, ...(meta ? [meta] : [])),
warn: (msg, meta) => console.warn(`${YELLOW}[${tag}]${RESET}`, msg, ...(meta ? [meta] : [])),
error: (msg, meta) => console.error(`${RED}[${tag}]${RESET}`, msg, ...(meta ? [meta] : [])),
};
/* eslint-enable no-console */
}
// ---------------------------------------------------------------------------
// Lazy runtime resolution
// ---------------------------------------------------------------------------
function resolveRuntimeLogger(subsystem) {
try {
const runtime = (0, runtime_store_1.tryGetLarkRuntime)();
if (!runtime)
return null;
return runtime.logging.getChildLogger({
subsystem: `feishu/${subsystem}`,
});
}
catch {
return null;
}
}
// ---------------------------------------------------------------------------
// LarkTicket enrichment
// ---------------------------------------------------------------------------
function getTraceMeta() {
const ctx = (0, lark_ticket_1.getTicket)();
if (!ctx)
return null;
const trace = {
accountId: ctx.accountId,
messageId: ctx.messageId,
chatId: ctx.chatId,
};
if (ctx.senderOpenId)
trace.senderOpenId = ctx.senderOpenId;
return trace;
}
function enrichMeta(meta) {
const trace = getTraceMeta();
if (!trace)
return meta ?? {};
return meta ? { ...trace, ...meta } : trace;
}
// ---------------------------------------------------------------------------
// Message formatting
// ---------------------------------------------------------------------------
/**
* Build a trace-aware prefix like `feishu[default][msg:om_xxx]:`.
*
* Mirrors the format used by `trace.ts` so log lines are consistent
* across the old and new logging systems.
*/
function buildTracePrefix() {
const ctx = (0, lark_ticket_1.getTicket)();
if (!ctx)
return 'feishu:';
return `feishu[${ctx.accountId}][msg:${ctx.messageId}]:`;
}
/**
* Format message with inline meta for text-based log output.
*
* RuntimeLogger implementations typically ignore the `meta` parameter in
* their text output (gateway.log / console). To ensure meta is always
* visible, we serialize user-supplied meta into the message string and
* prepend the trace context prefix (accountId + messageId).
*
* Example:
* formatMessage("card.create response", { code: 0, cardId: "c_xxx" })
* → "feishu[default][msg:om_xxx]: card.create response (code=0, cardId=c_xxx)"
*/
function formatMessage(message, meta) {
const prefix = buildTracePrefix();
if (!meta || Object.keys(meta).length === 0)
return `${prefix} ${message}`;
const parts = Object.entries(meta)
.map(([k, v]) => {
if (v === undefined || v == null)
return null;
if (typeof v === 'object')
return `${k}=${JSON.stringify(v)}`;
return `${k}=${v}`;
})
.filter(Boolean);
return parts.length > 0 ? `${prefix} ${message} (${parts.join(', ')})` : `${prefix} ${message}`;
}
// ---------------------------------------------------------------------------
// LarkLogger implementation
// ---------------------------------------------------------------------------
function createLarkLogger(subsystem) {
// RuntimeLogger is resolved lazily on first log call so that module-level
// `larkLogger()` calls work even before `LarkClient.setRuntime()`.
let cachedLogger = null;
let resolved = false;
function getLogger() {
if (!resolved) {
cachedLogger = resolveRuntimeLogger(subsystem);
if (cachedLogger)
resolved = true;
}
return cachedLogger ?? consoleFallback(subsystem);
}
return {
subsystem,
debug(message, meta) {
getLogger().debug?.(formatMessage(message, meta), enrichMeta(meta));
},
info(message, meta) {
getLogger().info(formatMessage(message, meta), enrichMeta(meta));
},
warn(message, meta) {
getLogger().warn(formatMessage(message, meta), enrichMeta(meta));
},
error(message, meta) {
getLogger().error(formatMessage(message, meta), enrichMeta(meta));
},
child(name) {
return createLarkLogger(`${subsystem}/${name}`);
},
};
}
// ---------------------------------------------------------------------------
// Public factory
// ---------------------------------------------------------------------------
function larkLogger(subsystem) {
return createLarkLogger(subsystem);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Request-level ticket for the Feishu plugin.
*
* Uses Node.js AsyncLocalStorage to propagate a ticket (message_id,
* chat_id, account_id) through the entire async call chain without passing
* parameters explicitly. Call {@link withTicket} at the event entry point
* (monitor.ts) and use {@link getTicket} anywhere downstream.
*/
export interface LarkTicket {
messageId: string;
chatId: string;
accountId: string;
startTime: number;
senderOpenId?: string;
chatType?: 'p2p' | 'group';
threadId?: string;
}
/**
* Run `fn` within a ticket context. All async operations spawned inside
* `fn` will inherit the context and can access it via {@link getTicket}.
*/
export declare function withTicket<T>(ticket: LarkTicket, fn: () => T | Promise<T>): T | Promise<T>;
/** Return the current ticket, or `undefined` if not inside withTicket. */
export declare function getTicket(): LarkTicket | undefined;
/** Milliseconds elapsed since the current ticket was created, or 0. */
export declare function ticketElapsed(): number;
@@ -0,0 +1,40 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Request-level ticket for the Feishu plugin.
*
* Uses Node.js AsyncLocalStorage to propagate a ticket (message_id,
* chat_id, account_id) through the entire async call chain without passing
* parameters explicitly. Call {@link withTicket} at the event entry point
* (monitor.ts) and use {@link getTicket} anywhere downstream.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.withTicket = withTicket;
exports.getTicket = getTicket;
exports.ticketElapsed = ticketElapsed;
const node_async_hooks_1 = require("node:async_hooks");
// ---------------------------------------------------------------------------
// Storage
// ---------------------------------------------------------------------------
const store = new node_async_hooks_1.AsyncLocalStorage();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Run `fn` within a ticket context. All async operations spawned inside
* `fn` will inherit the context and can access it via {@link getTicket}.
*/
function withTicket(ticket, fn) {
return store.run(ticket, fn);
}
/** Return the current ticket, or `undefined` if not inside withTicket. */
function getTicket() {
return store.getStore();
}
/** Milliseconds elapsed since the current ticket was created, or 0. */
function ticketElapsed() {
const t = store.getStore();
return t ? Date.now() - t.startTime : 0;
}

Some files were not shown because too many files have changed in this diff Show More