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