OpenClaw 完整备份 - 2026-03-21
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/**
|
||||
* 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 ToolCallInfo {
|
||||
name: string;
|
||||
status: 'running' | 'complete' | 'error';
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
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 milliseconds into a human-readable duration string.
|
||||
*/
|
||||
export declare function formatElapsed(ms: number): 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;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
confirmData?: ConfirmData;
|
||||
elapsedMs?: number;
|
||||
isError?: boolean;
|
||||
isAborted?: boolean;
|
||||
footer?: {
|
||||
status?: boolean;
|
||||
elapsed?: boolean;
|
||||
};
|
||||
}): FeishuCard;
|
||||
/**
|
||||
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
|
||||
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
|
||||
*/
|
||||
export declare function toCardKit2(card: FeishuCard): Record<string, unknown>;
|
||||
@@ -0,0 +1,404 @@
|
||||
"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).
|
||||
*/
|
||||
import { optimizeMarkdownStyle } from './markdown-style';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Element ID used for the streaming text area in cards. The CardKit
|
||||
* `cardElement.content()` API targets this element for typewriter-effect
|
||||
* streaming updates.
|
||||
*/
|
||||
export const STREAMING_ELEMENT_ID = 'streaming_content';
|
||||
export const REASONING_ELEMENT_ID = 'reasoning_content';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()`.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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" }
|
||||
*/
|
||||
export function formatReasoningDuration(ms) {
|
||||
const d = formatElapsed(ms);
|
||||
return { zh: `思考了 ${d}`, en: `Thought for ${d}` };
|
||||
}
|
||||
/**
|
||||
* Format milliseconds into a human-readable duration string.
|
||||
*/
|
||||
export 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',
|
||||
}];
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCardContent
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Build a full Feishu Interactive Message Card JSON object for the
|
||||
* given state.
|
||||
*/
|
||||
export function buildCardContent(state, data = {}) {
|
||||
switch (state) {
|
||||
case 'thinking':
|
||||
return buildThinkingCard();
|
||||
case 'streaming':
|
||||
return buildStreamingCard(data.text ?? '', data.toolCalls ?? [], data.reasoningText);
|
||||
case 'complete':
|
||||
return buildCompleteCard({
|
||||
text: data.text ?? '',
|
||||
toolCalls: data.toolCalls ?? [],
|
||||
elapsedMs: data.elapsedMs,
|
||||
isError: data.isError,
|
||||
reasoningText: data.reasoningText,
|
||||
reasoningElapsedMs: data.reasoningElapsedMs,
|
||||
isAborted: data.isAborted,
|
||||
footer: data.footer,
|
||||
});
|
||||
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, toolCalls, reasoningText) {
|
||||
const elements = [];
|
||||
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: optimizeMarkdownStyle(partialText),
|
||||
});
|
||||
}
|
||||
// Tool calls in progress
|
||||
if (toolCalls.length > 0) {
|
||||
const toolLines = toolCalls.map((tc) => {
|
||||
const statusIcon = tc.status === 'running' ? '\ud83d\udd04' : tc.status === 'complete' ? '\u2705' : '\u274c';
|
||||
return `${statusIcon} ${tc.name} - ${tc.status}`;
|
||||
});
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: toolLines.join('\n'),
|
||||
text_size: 'notation',
|
||||
});
|
||||
}
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
|
||||
elements,
|
||||
};
|
||||
}
|
||||
function buildCompleteCard(params) {
|
||||
const { text, toolCalls, elapsedMs, isError, reasoningText, reasoningElapsedMs, isAborted, footer } = params;
|
||||
const elements = [];
|
||||
// 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: optimizeMarkdownStyle(text),
|
||||
});
|
||||
// Tool calls summary
|
||||
if (toolCalls.length > 0) {
|
||||
const toolSummaryLines = toolCalls.map((tc) => {
|
||||
const statusIcon = tc.status === 'complete' ? '\u2705' : '\u274c';
|
||||
return `${statusIcon} **${tc.name}** - ${tc.status}`;
|
||||
});
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: toolSummaryLines.join('\n'),
|
||||
text_size: 'notation',
|
||||
});
|
||||
}
|
||||
// Footer meta-info: each metadata item is independently controlled via
|
||||
// the `footer` config. Both status and elapsed default to hidden.
|
||||
const zhParts = [];
|
||||
const enParts = [];
|
||||
if (footer?.status) {
|
||||
if (isError) {
|
||||
zhParts.push('出错');
|
||||
enParts.push('Error');
|
||||
}
|
||||
else if (isAborted) {
|
||||
zhParts.push('已停止');
|
||||
enParts.push('Stopped');
|
||||
}
|
||||
else {
|
||||
zhParts.push('已完成');
|
||||
enParts.push('Completed');
|
||||
}
|
||||
}
|
||||
if (footer?.elapsed && elapsedMs != null) {
|
||||
const d = formatElapsed(elapsedMs);
|
||||
zhParts.push(`耗时 ${d}`);
|
||||
enParts.push(`Elapsed ${d}`);
|
||||
}
|
||||
if (zhParts.length > 0) {
|
||||
elements.push(...buildFooter(zhParts.join(' · '), enParts.join(' · '), isError));
|
||||
}
|
||||
// Use the answer text (not reasoning) 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`.
|
||||
*/
|
||||
export function toCardKit2(card) {
|
||||
const result = {
|
||||
schema: '2.0',
|
||||
config: card.config,
|
||||
body: { elements: card.elements },
|
||||
};
|
||||
if (card.header)
|
||||
result.header = card.header;
|
||||
return result;
|
||||
}
|
||||
@@ -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,182 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* CardKit streaming APIs for Lark/Feishu.
|
||||
*/
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { normalizeFeishuTarget, normalizeMessageId, resolveReceiveIdType } from '../core/targets';
|
||||
import { runWithMessageUnavailableGuard } from '../core/message-unavailable';
|
||||
const log = 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 Error(`cardkit ${api} FAILED: code=${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.
|
||||
*/
|
||||
export async function createCardEntity(params) {
|
||||
const { cfg, card, accountId } = params;
|
||||
const client = 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.
|
||||
*/
|
||||
export async function streamCardContent(params) {
|
||||
const { cfg, cardId, elementId, content, sequence, accountId } = params;
|
||||
const client = 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.
|
||||
*/
|
||||
export async function updateCardKitCard(params) {
|
||||
const { cfg, cardId, card, sequence, accountId } = params;
|
||||
const client = 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}`,
|
||||
});
|
||||
}
|
||||
export 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().
|
||||
*/
|
||||
export async function sendCardByCardId(params) {
|
||||
const { cfg, to, cardId, replyToMessageId, replyInThread, accountId } = params;
|
||||
const client = 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 = normalizeMessageId(replyToMessageId);
|
||||
const response = await 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 = normalizeFeishuTarget(to);
|
||||
if (!target) {
|
||||
throw new Error(`[feishu-send] Invalid target: "${to}"`);
|
||||
}
|
||||
const receiveIdType = 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.).
|
||||
*/
|
||||
export async function setCardStreamingMode(params) {
|
||||
const { cfg, cardId, streamingMode, sequence, accountId } = params;
|
||||
const client = 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,135 @@
|
||||
"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.
|
||||
*/
|
||||
import { THROTTLE_CONSTANTS } from './reply-dispatcher-types';
|
||||
// ---------------------------------------------------------------------------
|
||||
// FlushController
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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 > 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();
|
||||
}, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,113 @@
|
||||
"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.
|
||||
*/
|
||||
import { fetchRemoteImageBuffer, uploadImageLark } from '../messaging/outbound/media';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
const log = larkLogger('card/image-resolver');
|
||||
/** Matches complete markdown image syntax: `` */
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||
export 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 fetchRemoteImageBuffer(url);
|
||||
const { imageKey } = await 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,98 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Markdown 样式优化工具
|
||||
*/
|
||||
/**
|
||||
* 优化 Markdown 样式:
|
||||
* - 标题降级:H1 → H4,H2~H6 → H5
|
||||
* - 表格前后增加段落间距
|
||||
* - 有序列表:序号后确保只有一个空格
|
||||
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
|
||||
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
|
||||
* - 代码块内容不受影响
|
||||
*/
|
||||
export 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(/```[\s\S]*?```/g, (m) => {
|
||||
return `${MARK}${codeBlocks.push(m) - 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, '$1\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,120 @@
|
||||
/**
|
||||
* 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, ReplyPayload } from 'openclaw/plugin-sdk';
|
||||
import type { FeishuFooterConfig } from '../core/types';
|
||||
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 StreamingTextState {
|
||||
accumulatedText: string;
|
||||
completedText: string;
|
||||
streamingPrefix: string;
|
||||
lastPartialText: 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;
|
||||
};
|
||||
export declare const EMPTY_REPLY_FALLBACK_TEXT = "Done.";
|
||||
export interface CreateFeishuReplyDispatcherParams {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: 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;
|
||||
}
|
||||
/**
|
||||
* Manual mirror of the SDK-internal ReplyDispatcher type
|
||||
* (from openclaw/plugin-sdk auto-reply/reply/reply-dispatcher.d.ts).
|
||||
*
|
||||
* Must be kept in sync when the SDK updates the dispatcher signature.
|
||||
*/
|
||||
export interface ReplyDispatcher {
|
||||
sendToolResult: (payload: ReplyPayload) => boolean;
|
||||
sendBlockReply: (payload: ReplyPayload) => boolean;
|
||||
sendFinalReply: (payload: ReplyPayload) => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
getQueuedCounts: () => Record<string, number>;
|
||||
markComplete: () => void;
|
||||
}
|
||||
/**
|
||||
* 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 StreamingCardDeps {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string | undefined;
|
||||
chatId: string;
|
||||
replyToMessageId: string | undefined;
|
||||
replyInThread: boolean | undefined;
|
||||
resolvedFooter: Required<FeishuFooterConfig>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"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.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardPhase — explicit state machine replacing boolean flags
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CARD_PHASES = {
|
||||
idle: 'idle',
|
||||
creating: 'creating',
|
||||
streaming: 'streaming',
|
||||
completed: 'completed',
|
||||
aborted: 'aborted',
|
||||
terminated: 'terminated',
|
||||
creation_failed: 'creation_failed',
|
||||
};
|
||||
export const TERMINAL_PHASES = new Set([
|
||||
'completed',
|
||||
'aborted',
|
||||
'terminated',
|
||||
'creation_failed',
|
||||
]);
|
||||
export const 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.
|
||||
*/
|
||||
export const THROTTLE_CONSTANTS = {
|
||||
CARDKIT_MS: 100,
|
||||
PATCH_MS: 1500,
|
||||
LONG_GAP_THRESHOLD_MS: 2000,
|
||||
BATCH_AFTER_GAP_MS: 300,
|
||||
};
|
||||
export const 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,293 @@
|
||||
"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
|
||||
*/
|
||||
import { createReplyPrefixContext, createTypingCallbacks, logTypingFailure, } from 'openclaw/plugin-sdk';
|
||||
import { getLarkAccount } from '../core/accounts';
|
||||
import { resolveFooterConfig } from '../core/footer-config';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { sendMessageFeishu, sendMarkdownCardFeishu } from '../messaging/outbound/send';
|
||||
import { addTypingIndicator, removeTypingIndicator } from '../messaging/outbound/typing';
|
||||
import { resolveReplyMode, expandAutoMode, shouldUseCard } from './reply-mode';
|
||||
import { StreamingCardController } from './streaming-card-controller';
|
||||
import { UnavailableGuard } from './unavailable-guard';
|
||||
const log = larkLogger('card/reply-dispatcher');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
export function createFeishuReplyDispatcher(params) {
|
||||
const core = LarkClient.runtime;
|
||||
const { cfg, agentId, chatId, replyToMessageId, accountId, replyInThread } = params;
|
||||
// Resolve account so we can read per-account config (e.g. replyMode)
|
||||
const account = getLarkAccount(cfg, accountId);
|
||||
const feishuCfg = account.config;
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
||||
// ---- Reply mode resolution ----
|
||||
const chatType = params.chatType;
|
||||
const effectiveReplyMode = resolveReplyMode({ feishuCfg, chatType });
|
||||
const replyMode = expandAutoMode({
|
||||
mode: effectiveReplyMode,
|
||||
streaming: feishuCfg?.streaming,
|
||||
chatType,
|
||||
});
|
||||
const useStreamingCards = replyMode === 'streaming';
|
||||
// ---- Block streaming for static mode ----
|
||||
const enableBlockStreaming = feishuCfg?.blockStreaming === true && !useStreamingCards;
|
||||
const resolvedFooter = resolveFooterConfig(feishuCfg?.footer);
|
||||
log.info('reply mode resolved', {
|
||||
effectiveReplyMode,
|
||||
replyMode,
|
||||
chatType,
|
||||
});
|
||||
// ---- 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');
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: 'feishu',
|
||||
});
|
||||
// ---- Streaming card controller (instantiated only when needed) ----
|
||||
const controller = useStreamingCards
|
||||
? new StreamingCardController({
|
||||
cfg,
|
||||
accountId,
|
||||
chatId,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
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 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 = createTypingCallbacks({
|
||||
keepaliveIntervalMs: 0,
|
||||
start: async () => {
|
||||
if (shouldSkip('typing.start.precheck'))
|
||||
return;
|
||||
if (!replyToMessageId || typingStopped || params.skipTyping)
|
||||
return;
|
||||
if (typingState?.reactionId)
|
||||
return;
|
||||
typingState = await addTypingIndicator({
|
||||
cfg,
|
||||
messageId: replyToMessageId,
|
||||
accountId,
|
||||
});
|
||||
if (shouldSkip('typing.start.postcheck'))
|
||||
return;
|
||||
if (typingStopped && typingState) {
|
||||
await 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 removeTypingIndicator({ cfg, state: typingState, accountId });
|
||||
typingState = null;
|
||||
log.info('removed typing indicator reaction');
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log.warn(message),
|
||||
channel: 'feishu',
|
||||
action: 'start',
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
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) => {
|
||||
log.debug('deliver called', { textPreview: payload.text?.slice(0, 100) });
|
||||
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;
|
||||
}
|
||||
const text = payload.text ?? '';
|
||||
if (!text.trim()) {
|
||||
log.debug('deliver: empty text, skipping');
|
||||
return;
|
||||
}
|
||||
// ---- Streaming card mode ----
|
||||
if (controller) {
|
||||
await controller.ensureCardCreated();
|
||||
if (controller.isTerminated)
|
||||
return;
|
||||
if (controller.cardMessageId) {
|
||||
await controller.onDeliver(payload);
|
||||
return;
|
||||
}
|
||||
// Card creation failed — fall through to static delivery
|
||||
log.warn('deliver: card creation failed, falling back to static delivery');
|
||||
}
|
||||
// ---- Static delivery ----
|
||||
if (shouldUseCard(text)) {
|
||||
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
||||
log.info('deliver: sending card chunks', { count: chunks.length, chatId });
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
if (staticGuard?.terminate('deliver.cardChunk', err))
|
||||
return;
|
||||
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 sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
if (staticGuard?.terminate('deliver.textChunk', err))
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
disableBlockStreaming: !enableBlockStreaming,
|
||||
...(controller
|
||||
? {
|
||||
onReasoningStream: (payload) => controller.onReasoningStream(payload),
|
||||
onPartialReply: (payload) => controller.onPartialReply(payload),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
markDispatchIdle,
|
||||
markFullyComplete: () => {
|
||||
dispatchFullyComplete = true;
|
||||
controller?.markFullyComplete();
|
||||
},
|
||||
abortCard,
|
||||
};
|
||||
}
|
||||
@@ -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,66 @@
|
||||
"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.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveReplyMode
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Resolve the effective reply mode based on configuration and chat type.
|
||||
*
|
||||
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
|
||||
*/
|
||||
export 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).
|
||||
*/
|
||||
export 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).
|
||||
*/
|
||||
export function shouldUseCard(text) {
|
||||
// Fenced code blocks
|
||||
if (/```[\s\S]*?```/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
// Markdown tables (header + separator rows separated by pipes)
|
||||
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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, TerminalReason, StreamingCardDeps } from './reply-dispatcher-types';
|
||||
export declare class StreamingCardController {
|
||||
private phase;
|
||||
private cardKit;
|
||||
private text;
|
||||
private reasoning;
|
||||
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;
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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>;
|
||||
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 finalizeCard;
|
||||
/**
|
||||
* Close streaming mode then update card content (shared by onError and abortCard).
|
||||
*/
|
||||
private closeStreamingAndUpdate;
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
"use strict";
|
||||
/**
|
||||
* 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 { SILENT_REPLY_TOKEN } from 'openclaw/plugin-sdk';
|
||||
import { extractLarkApiCode } from '../core/api-error';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { sendCardFeishu, updateCardFeishu } from '../messaging/outbound/send';
|
||||
import { createCardEntity, sendCardByCardId, streamCardContent, updateCardKitCard, setCardStreamingMode, } from './cardkit';
|
||||
import { buildCardContent, splitReasoningText, stripReasoningTags, STREAMING_ELEMENT_ID, toCardKit2 } from './builder';
|
||||
import { optimizeMarkdownStyle } from './markdown-style';
|
||||
import { ImageResolver } from './image-resolver';
|
||||
import { registerShutdownHook } from '../core/shutdown-hooks';
|
||||
import { FlushController } from './flush-controller';
|
||||
import { UnavailableGuard } from './unavailable-guard';
|
||||
import { TERMINAL_PHASES, PHASE_TRANSITIONS, THROTTLE_CONSTANTS, EMPTY_REPLY_FALLBACK_TEXT, } from './reply-dispatcher-types';
|
||||
const log = larkLogger('card/streaming');
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKit 2.0 initial streaming payload
|
||||
// ---------------------------------------------------------------------------
|
||||
const STREAMING_THINKING_CARD = {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
streaming_mode: true,
|
||||
locales: ['zh_cn', 'en_us'],
|
||||
summary: {
|
||||
content: 'Thinking...',
|
||||
i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: '',
|
||||
text_align: 'left',
|
||||
text_size: 'normal_v2',
|
||||
margin: '0px 0px 0px 0px',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
},
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: ' ',
|
||||
icon: {
|
||||
tag: 'custom_icon',
|
||||
img_key: 'img_v3_02vb_496bec09-4b43-4773-ad6b-0cdd103cd2bg',
|
||||
size: '16px 16px',
|
||||
},
|
||||
element_id: 'loading_icon',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingCardController
|
||||
// ---------------------------------------------------------------------------
|
||||
export class StreamingCardController {
|
||||
// ---- Explicit state machine ----
|
||||
phase = 'idle';
|
||||
// ---- Structured state ----
|
||||
cardKit = {
|
||||
cardKitCardId: null,
|
||||
originalCardKitCardId: null,
|
||||
cardKitSequence: 0,
|
||||
cardMessageId: null,
|
||||
};
|
||||
text = {
|
||||
accumulatedText: '',
|
||||
completedText: '',
|
||||
streamingPrefix: '',
|
||||
lastPartialText: '',
|
||||
};
|
||||
reasoning = {
|
||||
accumulatedReasoningText: '',
|
||||
reasoningStartTime: null,
|
||||
reasoningElapsedMs: 0,
|
||||
isReasoningPhase: false,
|
||||
};
|
||||
// ---- Sub-controllers ----
|
||||
flush;
|
||||
guard;
|
||||
imageResolver;
|
||||
// ---- Lifecycle ----
|
||||
createEpoch = 0;
|
||||
_terminalReason = null;
|
||||
dispatchFullyComplete = false;
|
||||
cardCreationPromise = null;
|
||||
disposeShutdownHook = null;
|
||||
dispatchStartTime = Date.now();
|
||||
// ---- Injected dependencies ----
|
||||
deps;
|
||||
elapsed() {
|
||||
return Date.now() - this.dispatchStartTime;
|
||||
}
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
this.guard = new UnavailableGuard({
|
||||
replyToMessageId: deps.replyToMessageId,
|
||||
getCardMessageId: () => this.cardKit.cardMessageId,
|
||||
onTerminate: () => {
|
||||
this.transition('terminated', 'UnavailableGuard', 'unavailable');
|
||||
},
|
||||
});
|
||||
this.flush = new FlushController(() => this.performFlush());
|
||||
this.imageResolver = new ImageResolver({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
onImageResolved: () => {
|
||||
if (!this.isTerminalPhase && this.cardKit.cardMessageId) {
|
||||
void this.throttledCardUpdate();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Public accessors
|
||||
// ------------------------------------------------------------------
|
||||
get cardMessageId() {
|
||||
return this.cardKit.cardMessageId;
|
||||
}
|
||||
get isTerminalPhase() {
|
||||
return TERMINAL_PHASES.has(this.phase);
|
||||
}
|
||||
/**
|
||||
* 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() {
|
||||
return this.phase === 'aborted';
|
||||
}
|
||||
/** Whether the reply pipeline was terminated due to an unavailable message. */
|
||||
get isTerminated() {
|
||||
return this.guard.isTerminated;
|
||||
}
|
||||
/** Check if the pipeline should skip further operations for this source. */
|
||||
shouldSkipForUnavailable(source) {
|
||||
return this.guard.shouldSkip(source);
|
||||
}
|
||||
/** Attempt to terminate the pipeline due to an unavailable message error. */
|
||||
terminateIfUnavailable(source, err) {
|
||||
return this.guard.terminate(source, err);
|
||||
}
|
||||
/** Why the controller entered a terminal phase, or null if still active. */
|
||||
get terminalReason() {
|
||||
return this._terminalReason;
|
||||
}
|
||||
/** @internal — exposed for test assertions only. */
|
||||
get currentPhase() {
|
||||
return this.phase;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Unified callback guard
|
||||
// ------------------------------------------------------------------
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
shouldProceed(source) {
|
||||
if (this.guard.isTerminated || this.guard.shouldSkip(source))
|
||||
return false;
|
||||
return !this.isTerminalPhase;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// State machine
|
||||
// ------------------------------------------------------------------
|
||||
isStaleCreate(epoch) {
|
||||
return epoch !== this.createEpoch;
|
||||
}
|
||||
transition(to, source, reason) {
|
||||
const from = this.phase;
|
||||
if (from === to)
|
||||
return false;
|
||||
if (!PHASE_TRANSITIONS[from].has(to)) {
|
||||
log.warn('phase transition rejected', { from, to, source });
|
||||
return false;
|
||||
}
|
||||
this.phase = to;
|
||||
log.info('phase transition', { from, to, source, reason });
|
||||
if (TERMINAL_PHASES.has(to)) {
|
||||
this._terminalReason = reason ?? null;
|
||||
this.onEnterTerminalPhase();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
onEnterTerminalPhase() {
|
||||
this.createEpoch += 1;
|
||||
this.flush.cancelPendingFlush();
|
||||
this.flush.complete();
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// SDK callback bindings
|
||||
// ------------------------------------------------------------------
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async onDeliver(payload) {
|
||||
if (!this.shouldProceed('onDeliver'))
|
||||
return;
|
||||
const text = payload.text ?? '';
|
||||
if (!text.trim())
|
||||
return;
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onDeliver.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
const split = splitReasoningText(text);
|
||||
if (split.reasoningText && !split.answerText) {
|
||||
// Pure reasoning payload
|
||||
this.reasoning.reasoningElapsedMs = this.reasoning.reasoningStartTime
|
||||
? Date.now() - this.reasoning.reasoningStartTime
|
||||
: 0;
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText;
|
||||
this.reasoning.isReasoningPhase = true;
|
||||
await this.throttledCardUpdate();
|
||||
return;
|
||||
}
|
||||
// Answer payload (may also contain inline reasoning from tags)
|
||||
this.reasoning.isReasoningPhase = false;
|
||||
if (split.reasoningText) {
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText;
|
||||
}
|
||||
const answerText = split.answerText ?? text;
|
||||
// 累积 deliver 文本用于最终卡片
|
||||
this.text.completedText += (this.text.completedText ? '\n\n' : '') + answerText;
|
||||
// 没有流式数据时,用 deliver 文本显示在卡片上
|
||||
if (!this.text.lastPartialText && !this.text.streamingPrefix) {
|
||||
this.text.accumulatedText += (this.text.accumulatedText ? '\n\n' : '') + answerText;
|
||||
this.text.streamingPrefix = this.text.accumulatedText;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
}
|
||||
async onReasoningStream(payload) {
|
||||
if (!this.shouldProceed('onReasoningStream'))
|
||||
return;
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onReasoningStream.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
const rawText = payload.text ?? '';
|
||||
if (!rawText)
|
||||
return;
|
||||
if (!this.reasoning.reasoningStartTime) {
|
||||
this.reasoning.reasoningStartTime = Date.now();
|
||||
}
|
||||
this.reasoning.isReasoningPhase = true;
|
||||
const split = splitReasoningText(rawText);
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText ?? rawText;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
async onPartialReply(payload) {
|
||||
if (!this.shouldProceed('onPartialReply'))
|
||||
return;
|
||||
const text = stripReasoningTags(payload.text ?? '');
|
||||
log.debug('onPartialReply', { len: text.length });
|
||||
if (!text)
|
||||
return;
|
||||
if (!this.reasoning.reasoningStartTime) {
|
||||
this.reasoning.reasoningStartTime = Date.now();
|
||||
}
|
||||
if (this.reasoning.isReasoningPhase) {
|
||||
this.reasoning.isReasoningPhase = false;
|
||||
this.reasoning.reasoningElapsedMs = this.reasoning.reasoningStartTime
|
||||
? Date.now() - this.reasoning.reasoningStartTime
|
||||
: 0;
|
||||
}
|
||||
// 检测回复边界:文本长度缩短 → 新回复开始
|
||||
if (this.text.lastPartialText && text.length < this.text.lastPartialText.length) {
|
||||
this.text.streamingPrefix += (this.text.streamingPrefix ? '\n\n' : '') + this.text.lastPartialText;
|
||||
}
|
||||
this.text.lastPartialText = text;
|
||||
this.text.accumulatedText = this.text.streamingPrefix ? this.text.streamingPrefix + '\n\n' + text : text;
|
||||
// NO_REPLY 缓冲
|
||||
if (!this.text.streamingPrefix && SILENT_REPLY_TOKEN.startsWith(this.text.accumulatedText.trim())) {
|
||||
log.debug('onPartialReply: buffering NO_REPLY prefix');
|
||||
return;
|
||||
}
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onPartialReply.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
async onError(err, info) {
|
||||
if (this.guard.terminate('onError', err))
|
||||
return;
|
||||
log.error(`${info.kind} reply failed`, { error: String(err) });
|
||||
this.finalizeCard('onError', 'error');
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise)
|
||||
await this.cardCreationPromise;
|
||||
const errorEffectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (this.cardKit.cardMessageId) {
|
||||
try {
|
||||
const rawErrorText = this.text.accumulatedText
|
||||
? `${this.text.accumulatedText}\n\n---\n**Error**: An error occurred while generating the response.`
|
||||
: '**Error**: An error occurred while generating the response.';
|
||||
const errorText = this.imageResolver.resolveImages(rawErrorText);
|
||||
const errorCard = buildCardContent('complete', {
|
||||
text: errorText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs: this.elapsed(),
|
||||
isError: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
if (errorEffectiveCardId) {
|
||||
await this.closeStreamingAndUpdate(errorEffectiveCardId, errorCard, 'onError');
|
||||
}
|
||||
else {
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: errorCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore update failures during error handling
|
||||
}
|
||||
}
|
||||
}
|
||||
async onIdle() {
|
||||
if (this.guard.isTerminated || this.guard.shouldSkip('onIdle'))
|
||||
return;
|
||||
if (!this.dispatchFullyComplete)
|
||||
return;
|
||||
if (this.isTerminalPhase)
|
||||
return;
|
||||
this.finalizeCard('onIdle', 'normal');
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise) {
|
||||
await this.cardCreationPromise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await this.flush.waitForFlush();
|
||||
}
|
||||
const idleEffectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (this.cardKit.cardMessageId) {
|
||||
try {
|
||||
if (idleEffectiveCardId) {
|
||||
const seqBeforeClose = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info('onIdle: closing streaming mode', {
|
||||
seqBefore: seqBeforeClose,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await setCardStreamingMode({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: idleEffectiveCardId,
|
||||
streamingMode: false,
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
const isNoReplyLeak = !this.text.completedText && SILENT_REPLY_TOKEN.startsWith(this.text.accumulatedText.trim());
|
||||
const displayText = this.text.completedText || (isNoReplyLeak ? '' : this.text.accumulatedText) || EMPTY_REPLY_FALLBACK_TEXT;
|
||||
if (!this.text.completedText && !this.text.accumulatedText) {
|
||||
log.warn('reply completed without visible text, using empty-reply fallback');
|
||||
}
|
||||
const resolvedDisplayText = await this.imageResolver.resolveImagesAwait(displayText, 15_000);
|
||||
const completeCard = buildCardContent('complete', {
|
||||
text: resolvedDisplayText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs: this.elapsed(),
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
if (idleEffectiveCardId) {
|
||||
const seqBeforeUpdate = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info('onIdle: updating final card', {
|
||||
seqBefore: seqBeforeUpdate,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await updateCardKitCard({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: idleEffectiveCardId,
|
||||
card: toCardKit2(completeCard),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: completeCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
log.info('reply completed, card finalized', {
|
||||
elapsedMs: this.elapsed(),
|
||||
isCardKit: !!idleEffectiveCardId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
log.warn('final card update failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// External control
|
||||
// ------------------------------------------------------------------
|
||||
markFullyComplete() {
|
||||
log.debug('markFullyComplete', {
|
||||
completedTextLen: this.text.completedText.length,
|
||||
accumulatedTextLen: this.text.accumulatedText.length,
|
||||
});
|
||||
this.dispatchFullyComplete = true;
|
||||
}
|
||||
async abortCard() {
|
||||
try {
|
||||
if (!this.transition('aborted', 'abortCard', 'abort'))
|
||||
return;
|
||||
// transition() already executed onEnterTerminalPhase (cancel + complete + dispose hook)
|
||||
// Only need to wait for any in-flight flush to finish
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise)
|
||||
await this.cardCreationPromise;
|
||||
const effectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (effectiveCardId) {
|
||||
const elapsedMs = Date.now() - this.dispatchStartTime;
|
||||
const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
|
||||
const abortCardContent = buildCardContent('complete', {
|
||||
text: abortText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs,
|
||||
isAborted: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
await this.closeStreamingAndUpdate(effectiveCardId, abortCardContent, 'abortCard');
|
||||
log.info('abortCard completed', { effectiveCardId });
|
||||
}
|
||||
else if (this.cardKit.cardMessageId) {
|
||||
// IM fallback: 卡片不是通过 CardKit 发的,用 im.message.patch 更新
|
||||
const elapsedMs = Date.now() - this.dispatchStartTime;
|
||||
const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
|
||||
const abortCard = buildCardContent('complete', {
|
||||
text: abortText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs,
|
||||
isAborted: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: abortCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
log.info('abortCard completed (IM fallback)', { messageId: this.cardKit.cardMessageId });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
log.warn('abortCard failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: card creation
|
||||
// ------------------------------------------------------------------
|
||||
async ensureCardCreated() {
|
||||
if (this.guard.shouldSkip('ensureCardCreated.precheck'))
|
||||
return;
|
||||
if (this.cardKit.cardMessageId || this.phase === 'creation_failed' || this.isTerminalPhase) {
|
||||
return;
|
||||
}
|
||||
if (this.cardCreationPromise) {
|
||||
await this.cardCreationPromise;
|
||||
return;
|
||||
}
|
||||
if (!this.transition('creating', 'ensureCardCreated'))
|
||||
return;
|
||||
this.createEpoch += 1;
|
||||
const epoch = this.createEpoch;
|
||||
this.cardCreationPromise = (async () => {
|
||||
try {
|
||||
try {
|
||||
// Step 1: Create card entity
|
||||
const cId = await createCardEntity({
|
||||
cfg: this.deps.cfg,
|
||||
card: STREAMING_THINKING_CARD,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after createCardEntity, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cId) {
|
||||
this.cardKit.cardKitCardId = cId;
|
||||
this.cardKit.originalCardKitCardId = cId;
|
||||
this.cardKit.cardKitSequence = 1;
|
||||
this.disposeShutdownHook = registerShutdownHook(`streaming-card:${cId}`, () => this.abortCard());
|
||||
log.info('created CardKit entity', {
|
||||
cardId: cId,
|
||||
initialSequence: this.cardKit.cardKitSequence,
|
||||
});
|
||||
// Step 2: Send IM message referencing card_id
|
||||
const result = await sendCardByCardId({
|
||||
cfg: this.deps.cfg,
|
||||
to: this.deps.chatId,
|
||||
cardId: cId,
|
||||
replyToMessageId: this.deps.replyToMessageId,
|
||||
replyInThread: this.deps.replyInThread,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after sendCardByCardId, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
return;
|
||||
}
|
||||
this.cardKit.cardMessageId = result.messageId;
|
||||
this.flush.setCardMessageReady(true);
|
||||
if (!this.transition('streaming', 'ensureCardCreated.cardkit')) {
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
return;
|
||||
}
|
||||
log.info('sent CardKit card', { messageId: result.messageId });
|
||||
}
|
||||
else {
|
||||
throw new Error('card.create returned empty card_id');
|
||||
}
|
||||
}
|
||||
catch (cardKitErr) {
|
||||
if (this.isStaleCreate(epoch))
|
||||
return;
|
||||
if (this.guard.terminate('ensureCardCreated.cardkitFlow', cardKitErr)) {
|
||||
return;
|
||||
}
|
||||
// CardKit flow failed — fall back to regular IM card
|
||||
const apiDetail = extractApiDetail(cardKitErr);
|
||||
log.warn('CardKit flow failed, falling back to IM', { apiDetail });
|
||||
this.cardKit.cardKitCardId = null;
|
||||
this.cardKit.originalCardKitCardId = null;
|
||||
const fallbackCard = buildCardContent('thinking');
|
||||
const result = await sendCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
to: this.deps.chatId,
|
||||
card: fallbackCard,
|
||||
replyToMessageId: this.deps.replyToMessageId,
|
||||
replyInThread: this.deps.replyInThread,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after IM fallback send, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.cardKit.cardMessageId = result.messageId;
|
||||
this.flush.setCardMessageReady(true);
|
||||
if (!this.transition('streaming', 'ensureCardCreated.imFallback')) {
|
||||
return;
|
||||
}
|
||||
log.info('sent fallback IM card', { messageId: result.messageId });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.isStaleCreate(epoch))
|
||||
return;
|
||||
if (this.guard.terminate('ensureCardCreated.outer', err)) {
|
||||
return;
|
||||
}
|
||||
log.warn('thinking card failed, falling back to static', { error: String(err) });
|
||||
this.transition('creation_failed', 'ensureCardCreated.outer', 'creation_failed');
|
||||
}
|
||||
})();
|
||||
await this.cardCreationPromise;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: flush
|
||||
// ------------------------------------------------------------------
|
||||
async performFlush() {
|
||||
if (!this.cardKit.cardMessageId || this.isTerminalPhase)
|
||||
return;
|
||||
// v2 CardKit 卡片不能走 IM patch,如果流式 CardKit 已禁用但 originalCardKitCardId
|
||||
// 仍在,说明卡片是通过 CardKit 发的——跳过中间态更新,等终态用 originalCardKitCardId 收尾
|
||||
if (!this.cardKit.cardKitCardId && this.cardKit.originalCardKitCardId) {
|
||||
log.debug('performFlush: skipping (CardKit streaming disabled, awaiting final update)');
|
||||
return;
|
||||
}
|
||||
log.debug('flushCardUpdate: enter', {
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
isCardKit: !!this.cardKit.cardKitCardId,
|
||||
});
|
||||
try {
|
||||
const displayText = this.buildDisplayText();
|
||||
const resolvedText = this.imageResolver.resolveImages(displayText);
|
||||
if (this.cardKit.cardKitCardId) {
|
||||
// CardKit streaming — typewriter effect
|
||||
const prevSeq = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.debug('flushCardUpdate: seq bump', {
|
||||
seqBefore: prevSeq,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await streamCardContent({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: this.cardKit.cardKitCardId,
|
||||
elementId: STREAMING_ELEMENT_ID,
|
||||
content: optimizeMarkdownStyle(resolvedText),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.debug('flushCardUpdate: IM patch fallback');
|
||||
const card = buildCardContent('streaming', {
|
||||
text: this.reasoning.isReasoningPhase ? '' : resolvedText,
|
||||
reasoningText: this.reasoning.isReasoningPhase ? this.reasoning.accumulatedReasoningText : undefined,
|
||||
});
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: card,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.guard.terminate('flushCardUpdate', err))
|
||||
return;
|
||||
const apiCode = extractLarkApiCode(err);
|
||||
if (apiCode === 230020) {
|
||||
log.info('flushCardUpdate: rate limited (230020), skipping', {
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const apiDetail = extractApiDetail(err);
|
||||
log.error('card stream update failed', {
|
||||
apiCode,
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
apiDetail,
|
||||
});
|
||||
if (this.cardKit.cardKitCardId) {
|
||||
log.warn('disabling CardKit streaming, falling back to im.message.patch');
|
||||
this.cardKit.cardKitCardId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
buildDisplayText() {
|
||||
if (this.reasoning.isReasoningPhase && this.reasoning.accumulatedReasoningText) {
|
||||
const reasoningDisplay = `💭 **Thinking...**\n\n${this.reasoning.accumulatedReasoningText}`;
|
||||
return this.text.accumulatedText ? this.text.accumulatedText + '\n\n' + reasoningDisplay : reasoningDisplay;
|
||||
}
|
||||
return this.text.accumulatedText;
|
||||
}
|
||||
async throttledCardUpdate() {
|
||||
if (this.guard.shouldSkip('throttledCardUpdate'))
|
||||
return;
|
||||
const throttleMs = this.cardKit.cardKitCardId ? THROTTLE_CONSTANTS.CARDKIT_MS : THROTTLE_CONSTANTS.PATCH_MS;
|
||||
await this.flush.throttledUpdate(throttleMs);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: lifecycle helpers
|
||||
// ------------------------------------------------------------------
|
||||
finalizeCard(source, reason) {
|
||||
this.transition('completed', source, reason);
|
||||
}
|
||||
/**
|
||||
* Close streaming mode then update card content (shared by onError and abortCard).
|
||||
*/
|
||||
async closeStreamingAndUpdate(cardId, card, label) {
|
||||
const seqBeforeClose = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info(`${label}: closing streaming mode`, {
|
||||
seqBefore: seqBeforeClose,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await setCardStreamingMode({
|
||||
cfg: this.deps.cfg,
|
||||
cardId,
|
||||
streamingMode: false,
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
const seqBeforeUpdate = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info(`${label}: updating card`, {
|
||||
seqBefore: seqBeforeUpdate,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await updateCardKitCard({
|
||||
cfg: this.deps.cfg,
|
||||
cardId,
|
||||
card: toCardKit2(card),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error detail extraction helpers (replacing `any` casts)
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractApiDetail(err) {
|
||||
if (!err || typeof err !== 'object')
|
||||
return String(err);
|
||||
const e = err;
|
||||
return e.response?.data ? JSON.stringify(e.response.data) : String(err);
|
||||
}
|
||||
@@ -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,84 @@
|
||||
"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.
|
||||
*/
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { extractLarkApiCode } from '../core/api-error';
|
||||
import { getMessageUnavailableState, isMessageUnavailable, isMessageUnavailableError, isTerminalMessageApiCode, markMessageUnavailable, } from '../core/message-unavailable';
|
||||
const log = larkLogger('card/unavailable-guard');
|
||||
// ---------------------------------------------------------------------------
|
||||
// UnavailableGuard
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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 (!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 = isMessageUnavailableError(err) ? err : undefined;
|
||||
const cardMessageId = this.getCardMessageId();
|
||||
const state = getMessageUnavailableState(this.replyToMessageId) ?? getMessageUnavailableState(cardMessageId ?? undefined);
|
||||
let apiCode = fromError?.apiCode ?? state?.apiCode;
|
||||
if (!apiCode && err) {
|
||||
const detectedCode = extractLarkApiCode(err);
|
||||
if (isTerminalMessageApiCode(detectedCode)) {
|
||||
const fallbackMessageId = this.replyToMessageId ?? cardMessageId ?? undefined;
|
||||
if (fallbackMessageId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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,125 @@
|
||||
"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.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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`). */
|
||||
export 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.
|
||||
*/
|
||||
export function isLikelyAbortText(text) {
|
||||
if (!text)
|
||||
return false;
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
if (trimmed === '/stop')
|
||||
return true;
|
||||
return isAbortTrigger(trimmed);
|
||||
}
|
||||
/**
|
||||
* 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 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,59 @@
|
||||
"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.
|
||||
*/
|
||||
const chatQueues = new Map();
|
||||
const activeDispatchers = new Map();
|
||||
/**
|
||||
* Append `:thread:{threadId}` suffix when threadId is present.
|
||||
* Consistent with the SDK's `:thread:` separator convention.
|
||||
*/
|
||||
export function threadScopedKey(base, threadId) {
|
||||
return threadId ? `${base}:thread:${threadId}` : base;
|
||||
}
|
||||
export function buildQueueKey(accountId, chatId, threadId) {
|
||||
return threadScopedKey(`${accountId}:${chatId}`, threadId);
|
||||
}
|
||||
export function registerActiveDispatcher(key, entry) {
|
||||
activeDispatchers.set(key, entry);
|
||||
}
|
||||
export function unregisterActiveDispatcher(key) {
|
||||
activeDispatchers.delete(key);
|
||||
}
|
||||
export function getActiveDispatcher(key) {
|
||||
return activeDispatchers.get(key);
|
||||
}
|
||||
/** Check whether the queue has an active task for the given key. */
|
||||
export function hasActiveTask(key) {
|
||||
return chatQueues.has(key);
|
||||
}
|
||||
export 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 next = prev.then(task, task); // continue queue even if previous task failed
|
||||
chatQueues.set(key, next);
|
||||
const cleanup = () => {
|
||||
if (chatQueues.get(key) === next) {
|
||||
chatQueues.delete(key);
|
||||
}
|
||||
};
|
||||
next.then(cleanup, cleanup);
|
||||
return { status, promise: next };
|
||||
}
|
||||
/** @internal Test-only: reset all queue and dispatcher state. */
|
||||
export 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,102 @@
|
||||
"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`).
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
|
||||
import { getLarkAccount, getLarkAccountIds } from '../core/accounts';
|
||||
import { collectIsolationWarnings } from '../core/security-check';
|
||||
/** Generic Feishu account config merge. */
|
||||
function mergeFeishuAccountConfig(cfg, accountId, patch) {
|
||||
const isDefault = !accountId || accountId === 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. */
|
||||
export function setAccountEnabled(cfg, accountId, enabled) {
|
||||
return mergeFeishuAccountConfig(cfg, accountId, { enabled });
|
||||
}
|
||||
/** Apply an arbitrary config patch to a Feishu account. */
|
||||
export function applyAccountConfig(cfg, accountId, patch) {
|
||||
return mergeFeishuAccountConfig(cfg, accountId, patch);
|
||||
}
|
||||
/** Delete a Feishu account entry from the config. */
|
||||
export function deleteAccount(cfg, accountId) {
|
||||
const isDefault = !accountId || accountId === 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. */
|
||||
export function collectFeishuSecurityWarnings(params) {
|
||||
const { cfg, accountId } = params;
|
||||
const warnings = [];
|
||||
const account = 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 = getLarkAccountIds(cfg);
|
||||
if (allIds.length === 0 || accountId === allIds[0]) {
|
||||
for (const w of 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 { FeishuDirectoryPeer, FeishuDirectoryGroup } 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,192 @@
|
||||
"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.
|
||||
*/
|
||||
import { getLarkAccount } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { normalizeFeishuTarget } from '../core/targets';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*/
|
||||
export async function listFeishuDirectoryPeers(params) {
|
||||
const account = 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) => 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).
|
||||
*/
|
||||
export async function listFeishuDirectoryGroups(params) {
|
||||
const account = 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.
|
||||
*/
|
||||
export async function listFeishuDirectoryPeersLive(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
try {
|
||||
const client = 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.
|
||||
*/
|
||||
export async function listFeishuDirectoryGroupsLive(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
try {
|
||||
const client = 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,15 @@
|
||||
/**
|
||||
* 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 handleCardActionEvent(ctx: MonitorContext, data: unknown): Promise<unknown>;
|
||||
@@ -0,0 +1,222 @@
|
||||
"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.
|
||||
*/
|
||||
import { handleFeishuMessage } from '../messaging/inbound/handler';
|
||||
import { handleFeishuReaction, resolveReactionContext } from '../messaging/inbound/reaction-handler';
|
||||
import { isMessageExpired } from '../messaging/inbound/dedup';
|
||||
import { withTicket } from '../core/lark-ticket';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { handleCardAction } from '../tools/auto-auth';
|
||||
import { enqueueFeishuChatTask, buildQueueKey, hasActiveTask, getActiveDispatcher } from './chat-queue';
|
||||
import { extractRawTextFromEvent, isLikelyAbortText } from './abort-detect';
|
||||
const elog = 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleMessageEvent(ctx, data) {
|
||||
if (!isEventOwnershipValid(ctx, data))
|
||||
return;
|
||||
const { accountId, log, error } = ctx;
|
||||
try {
|
||||
const event = data;
|
||||
const msgId = event.message?.message_id ?? 'unknown';
|
||||
const chatId = event.message?.chat_id ?? '';
|
||||
const threadId = event.message?.thread_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 (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 = extractRawTextFromEvent(event);
|
||||
if (abortText && isLikelyAbortText(abortText)) {
|
||||
const queueKey = buildQueueKey(accountId, chatId, threadId);
|
||||
if (hasActiveTask(queueKey)) {
|
||||
const active = 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 } = enqueueFeishuChatTask({
|
||||
accountId,
|
||||
chatId,
|
||||
threadId,
|
||||
task: async () => {
|
||||
try {
|
||||
await withTicket({
|
||||
messageId: msgId,
|
||||
chatId,
|
||||
accountId,
|
||||
startTime: Date.now(),
|
||||
senderOpenId: event.sender?.sender_id?.open_id || '',
|
||||
chatType: event.message?.chat_type || undefined,
|
||||
threadId,
|
||||
}, () => 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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 (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 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 } = enqueueFeishuChatTask({
|
||||
accountId,
|
||||
chatId: preResolved.chatId,
|
||||
threadId: preResolved.threadId,
|
||||
task: async () => {
|
||||
try {
|
||||
await withTicket({
|
||||
messageId: msgId,
|
||||
chatId: preResolved.chatId,
|
||||
accountId,
|
||||
startTime: Date.now(),
|
||||
senderOpenId: operatorOpenId,
|
||||
chatType: preResolved.chatType,
|
||||
threadId: preResolved.threadId,
|
||||
}, () => 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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)}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card action handler
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleCardActionEvent(ctx, data) {
|
||||
try {
|
||||
return await handleCardAction(data, ctx.cfg, ctx.accountId);
|
||||
}
|
||||
catch (err) {
|
||||
elog.warn(`card.action.trigger handler error: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -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,130 @@
|
||||
"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.
|
||||
*/
|
||||
import { getLarkAccount, getEnabledLarkAccounts } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { MessageDedup } from '../messaging/inbound/dedup';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { drainShutdownHooks } from '../core/shutdown-hooks';
|
||||
import { handleMessageEvent, handleReactionEvent, handleBotMembershipEvent, handleCardActionEvent, } from './event-handlers';
|
||||
const mlog = 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 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 = 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 LarkClient.runtime.config.loadConfig();
|
||||
},
|
||||
lark,
|
||||
accountId,
|
||||
chatHistories,
|
||||
messageDedup,
|
||||
runtime,
|
||||
log,
|
||||
error,
|
||||
};
|
||||
await lark.startWS({
|
||||
handlers: {
|
||||
'im.message.receive_v1': (data) => handleMessageEvent(ctx, data),
|
||||
'im.message.message_read_v1': async () => { },
|
||||
'im.message.reaction.created_v1': (data) => handleReactionEvent(ctx, data),
|
||||
'im.chat.member.bot.added_v1': (data) => handleBotMembershipEvent(ctx, data, 'added'),
|
||||
'im.chat.member.bot.deleted_v1': (data) => handleBotMembershipEvent(ctx, data, 'removed'),
|
||||
// 飞书 SDK EventDispatcher.register 不支持带返回值的处理器,此处 as any 是 SDK 类型限制的变通
|
||||
'card.action.trigger': ((data) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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).
|
||||
*/
|
||||
export 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.
|
||||
LarkClient.setGlobalConfig(cfg);
|
||||
const log = opts.runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
|
||||
// Single-account mode.
|
||||
if (opts.accountId) {
|
||||
const account = 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 drainShutdownHooks({ log });
|
||||
return;
|
||||
}
|
||||
// Multi-account mode: start all enabled accounts in parallel.
|
||||
const accounts = 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 drainShutdownHooks({ log });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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, DmPolicy } from 'openclaw/plugin-sdk';
|
||||
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,89 @@
|
||||
"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.
|
||||
*/
|
||||
import { addWildcardAllowFrom } from 'openclaw/plugin-sdk';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config mutation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export function setFeishuDmPolicy(cfg, dmPolicy) {
|
||||
const allowFrom = dmPolicy === 'open'
|
||||
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuAllowFrom(cfg, allowFrom) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroupPolicy(cfg, groupPolicy) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroupAllowFrom(cfg, groupAllowFrom) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groupAllowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroups(cfg, groups) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groups,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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,68 @@
|
||||
"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".
|
||||
*/
|
||||
import { setFeishuGroups, setFeishuGroupAllowFrom } from './onboarding-config';
|
||||
/**
|
||||
* 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 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 = setFeishuGroups(next, migratedGroups);
|
||||
next = 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 { ChannelOnboardingAdapter } from 'openclaw/plugin-sdk';
|
||||
export declare const feishuOnboardingAdapter: ChannelOnboardingAdapter;
|
||||
@@ -0,0 +1,297 @@
|
||||
"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.
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID, formatDocsLink } from 'openclaw/plugin-sdk';
|
||||
import { getLarkCredentials } from '../core/accounts';
|
||||
import { probeFeishu } from './probe';
|
||||
import { setFeishuDmPolicy, setFeishuAllowFrom, setFeishuGroupPolicy, setFeishuGroupAllowFrom, parseAllowFromInput, } from './onboarding-config';
|
||||
import { migrateLegacyGroupAllowFrom } from './onboarding-migrate';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: ${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 = 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 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) => setFeishuDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
export const feishuOnboardingAdapter = {
|
||||
channel,
|
||||
// -----------------------------------------------------------------------
|
||||
// getStatus
|
||||
// -----------------------------------------------------------------------
|
||||
getStatus: async ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu;
|
||||
const configured = Boolean(getLarkCredentials(feishuCfg));
|
||||
// Attempt a live probe when credentials are present.
|
||||
let probeResult = null;
|
||||
if (configured && feishuCfg) {
|
||||
try {
|
||||
probeResult = await 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 = 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 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 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 = 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 = parseAllowFromInput(String(entry));
|
||||
if (parts.length > 0) {
|
||||
next = 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: 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,279 @@
|
||||
"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.
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk';
|
||||
import { getLarkAccount, getLarkAccountIds, getDefaultLarkAccountId } from '../core/accounts';
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups, listFeishuDirectoryPeersLive, listFeishuDirectoryGroupsLive, } from './directory';
|
||||
import { feishuOnboardingAdapter } from './onboarding';
|
||||
import { feishuOutbound } from '../messaging/outbound/outbound';
|
||||
import { feishuMessageActions } from '../messaging/outbound/actions';
|
||||
import { resolveFeishuGroupToolPolicy } from '../messaging/inbound/policy';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { sendMessageFeishu } from '../messaging/outbound/send';
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId } from '../core/targets';
|
||||
import { triggerOnboarding } from '../tools/onboarding-auth';
|
||||
import { setAccountEnabled, applyAccountConfig, deleteAccount, collectFeishuSecurityWarnings } from './config-adapter';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { FEISHU_CONFIG_JSON_SCHEMA } from '../core/config-schema';
|
||||
const pluginLog = larkLogger('channel/plugin');
|
||||
/** 状态轮询的探针结果缓存时长(10 分钟)。 */
|
||||
const PROBE_CACHE_TTL_MS = 10 * 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export const feishuPlugin = {
|
||||
id: 'feishu',
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Pairing
|
||||
// -------------------------------------------------------------------------
|
||||
pairing: {
|
||||
idLabel: 'feishuUserId',
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ''),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const accountId = getDefaultLarkAccountId(cfg);
|
||||
pluginLog.info('notifyApproval called', { id, accountId });
|
||||
// 1. 发送配对成功消息(保持现有行为)
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: id,
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
accountId,
|
||||
});
|
||||
// 2. 触发 onboarding
|
||||
try {
|
||||
await 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: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Reload
|
||||
// -------------------------------------------------------------------------
|
||||
reload: { configPrefixes: ['channels.feishu'] },
|
||||
// -------------------------------------------------------------------------
|
||||
// Config schema (JSON Schema)
|
||||
// -------------------------------------------------------------------------
|
||||
configSchema: {
|
||||
schema: FEISHU_CONFIG_JSON_SCHEMA,
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Config adapter
|
||||
// -------------------------------------------------------------------------
|
||||
config: {
|
||||
listAccountIds: (cfg) => getLarkAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => getLarkAccount(cfg, accountId),
|
||||
defaultAccountId: (cfg) => getDefaultLarkAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
return setAccountEnabled(cfg, accountId, enabled);
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
return 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 = 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 }) => collectFeishuSecurityWarnings({ cfg, accountId: accountId ?? DEFAULT_ACCOUNT_ID }),
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Setup
|
||||
// -------------------------------------------------------------------------
|
||||
setup: {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg, accountId }) => {
|
||||
return applyAccountConfig(cfg, accountId, { enabled: true });
|
||||
},
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Onboarding
|
||||
// -------------------------------------------------------------------------
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
// -------------------------------------------------------------------------
|
||||
// Messaging
|
||||
// -------------------------------------------------------------------------
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuId,
|
||||
hint: '<chatId|user:openId|chat:chatId>',
|
||||
},
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Directory
|
||||
// -------------------------------------------------------------------------
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async (p) => listFeishuDirectoryPeers(adaptDirectoryParams(p)),
|
||||
listGroups: async (p) => listFeishuDirectoryGroups(adaptDirectoryParams(p)),
|
||||
listPeersLive: async (p) => listFeishuDirectoryPeersLive(adaptDirectoryParams(p)),
|
||||
listGroupsLive: async (p) => listFeishuDirectoryGroupsLive(adaptDirectoryParams(p)),
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Outbound
|
||||
// -------------------------------------------------------------------------
|
||||
outbound: feishuOutbound,
|
||||
// -------------------------------------------------------------------------
|
||||
// Threading
|
||||
// -------------------------------------------------------------------------
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: normalizeFeishuTarget(context.To ?? '') ?? undefined,
|
||||
currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||
currentMessageId: context.CurrentMessageId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
actions: feishuMessageActions,
|
||||
// -------------------------------------------------------------------------
|
||||
// Status
|
||||
// -------------------------------------------------------------------------
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: 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 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 import('./monitor.js');
|
||||
const account = 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}]`);
|
||||
LarkClient.clearCache(ctx.accountId);
|
||||
ctx.log?.info(`stopped feishu[${ctx.accountId}]`);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
import type { FeishuProbeResult } from './types';
|
||||
import { type LarkClientCredentials } from '../core/lark-client';
|
||||
/**
|
||||
* 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,22 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
/**
|
||||
* 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 async function probeFeishu(credentials) {
|
||||
if (!credentials?.appId || !credentials?.appSecret) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'missing credentials (appId, appSecret)',
|
||||
};
|
||||
}
|
||||
return LarkClient.fromCredentials(credentials).probe();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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, HistoryEntry } from 'openclaw/plugin-sdk';
|
||||
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.
|
||||
*/
|
||||
export {};
|
||||
@@ -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,162 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* feishu_auth command — 飞书用户权限批量授权命令实现
|
||||
*
|
||||
* 直接复用 onboarding-auth.ts 的 triggerOnboarding() 函数。
|
||||
* 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
|
||||
*/
|
||||
import { triggerOnboarding } from '../tools/onboarding-auth';
|
||||
import { getTicket } from '../core/lark-ticket';
|
||||
import { getLarkAccount } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { getAppInfo, getAppGrantedScopes } from '../core/app-scope-checker';
|
||||
import { getStoredToken, tokenStatus } from '../core/token-store';
|
||||
import { filterSensitiveScopes } from '../core/tool-scopes';
|
||||
import { assertOwnerAccessStrict, OwnerAccessDeniedError } from '../core/owner-policy';
|
||||
import { openPlatformDomain } from '../core/domains';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = getTicket();
|
||||
const senderOpenId = ticket?.senderOpenId;
|
||||
if (!senderOpenId) {
|
||||
return { kind: 'no_identity' };
|
||||
}
|
||||
// 提前检查 owner 身份,给出明确提示
|
||||
const acct = getLarkAccount(config, ticket.accountId);
|
||||
if (!acct.configured) {
|
||||
return { kind: 'account_incomplete', accountId: ticket.accountId };
|
||||
}
|
||||
const sdk = LarkClient.fromAccount(acct).sdk;
|
||||
const { appId } = acct;
|
||||
const openDomain = openPlatformDomain(acct.brand);
|
||||
try {
|
||||
await 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 assertOwnerAccessStrict(acct, sdk, senderOpenId);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof OwnerAccessDeniedError) {
|
||||
return { kind: 'owner_only' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// 预检:是否还有未授权的 scope
|
||||
let appScopes;
|
||||
try {
|
||||
appScopes = await 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 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 = filterSensitiveScopes(appScopes);
|
||||
if (appScopes.length === 0) {
|
||||
return { kind: 'no_user_scopes' };
|
||||
}
|
||||
const existing = await getStoredToken(appId, senderOpenId);
|
||||
const tokenValid = existing && 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 triggerOnboarding({
|
||||
cfg: config,
|
||||
userOpenId: senderOpenId,
|
||||
accountId: ticket.accountId,
|
||||
});
|
||||
return { kind: 'auth_sent' };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* 执行飞书用户权限批量授权命令
|
||||
* 直接调用 triggerOnboarding(),包含 owner 检查
|
||||
*/
|
||||
export async function runFeishuAuth(config, locale = 'zh_cn') {
|
||||
const result = await executeFeishuAuth(config);
|
||||
return formatAuthResult(result, locale);
|
||||
}
|
||||
/**
|
||||
* 运行飞书授权命令,同时生成中英双语结果。
|
||||
* 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
|
||||
*/
|
||||
export 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,808 @@
|
||||
"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.
|
||||
*/
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { probeFeishu } from '../channel/probe';
|
||||
import { getLarkAccountIds, getLarkAccount, getEnabledLarkAccounts } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
/**
|
||||
* Resolve the global config for cross-account operations.
|
||||
* See doctor.ts for rationale.
|
||||
*/
|
||||
function resolveGlobalConfig(config) {
|
||||
return LarkClient.globalConfig ?? config;
|
||||
}
|
||||
import { assertLarkOk, formatLarkError } from '../core/api-error';
|
||||
import { resolveAnyEnabledToolsConfig } from '../core/tools-config';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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({});
|
||||
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 = getEnabledLarkAccounts(config);
|
||||
if (accounts.length === 0)
|
||||
return [];
|
||||
const toolsCfg = 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 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 = 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: `权限检查失败: ${formatLarkError(err)}`,
|
||||
});
|
||||
}
|
||||
// A6: Brand
|
||||
checks.push({
|
||||
name: '品牌配置',
|
||||
status: 'pass',
|
||||
message: `brand: ${account.brand}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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 = 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 = 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;
|
||||
}
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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');
|
||||
}
|
||||
export 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,26 @@
|
||||
/**
|
||||
* 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';
|
||||
/** @deprecated Use FeishuLocale instead */
|
||||
export type DoctorLocale = import('./locale').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,585 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* feishu-doctor 诊断报告 Markdown 格式化(完全重构版)
|
||||
*
|
||||
* 直接生成 Markdown 诊断报告,不依赖 diagnose.ts 的任何架构和代码。
|
||||
* 按照 doctor_template.md 的格式规范实现。
|
||||
*/
|
||||
import { getEnabledLarkAccounts } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
/**
|
||||
* 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 LarkClient.globalConfig ?? config;
|
||||
}
|
||||
import { getAppGrantedScopes, missingScopes } from '../core/app-scope-checker';
|
||||
import { getAppOwnerFallback } from '../core/app-owner-fallback';
|
||||
import { getStoredToken, tokenStatus } from '../core/token-store';
|
||||
import { filterSensitiveScopes, REQUIRED_APP_SCOPES, TOOL_SCOPES } from '../core/tool-scopes';
|
||||
import { probeFeishu } from '../channel/probe';
|
||||
import { AppScopeCheckFailedError } from '../core/tool-client';
|
||||
import { getPluginVersion } from '../core/version';
|
||||
import { openPlatformDomain } from '../core/domains';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)) {
|
||||
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 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 = openPlatformDomain(account.brand);
|
||||
try {
|
||||
// 获取应用已开通的权限(tenant token)
|
||||
const grantedScopes = await getAppGrantedScopes(sdk, appId, 'tenant');
|
||||
// 计算缺失的必需权限
|
||||
const requiredMissing = missingScopes(grantedScopes, Array.from(REQUIRED_APP_SCOPES));
|
||||
if (requiredMissing.length === 0) {
|
||||
// 全部权限已开通
|
||||
return {
|
||||
status: 'pass',
|
||||
markdown: t.allPermsGranted(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 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 = 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 = openPlatformDomain(account.brand);
|
||||
const lines = [];
|
||||
try {
|
||||
// 1. 获取应用所有者
|
||||
const ownerId = await getAppOwnerFallback(account, sdk);
|
||||
// 2. 读取 token
|
||||
const token = ownerId ? await 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 getAppGrantedScopes(sdk, appId, 'user');
|
||||
let allScopes = getAllToolScopes();
|
||||
allScopes = filterSensitiveScopes(allScopes);
|
||||
const appGrantedCount = appUserScopes.filter((s) => allScopes.includes(s)).length;
|
||||
if (hasUserAuth) {
|
||||
// 有用户授权 - 检查授权状态
|
||||
const status = 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 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
|
||||
*/
|
||||
export 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 = 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}: ${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 = 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 发送。
|
||||
*/
|
||||
export 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 { OpenClawPluginApi, OpenClawConfig } 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,213 @@
|
||||
"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).
|
||||
*/
|
||||
import { runDiagnosis, formatDiagReportText } from './diagnose';
|
||||
import { runFeishuDoctor } from './doctor';
|
||||
import { runFeishuAuth } from './auth';
|
||||
import { getPluginVersion } from '../core/version';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 格式结果。
|
||||
*/
|
||||
export 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(getPluginVersion(), warnings.join('\n\n'));
|
||||
}
|
||||
return t.startOk(getPluginVersion());
|
||||
}
|
||||
/**
|
||||
* 运行 /feishu start,同时生成中英双语结果。
|
||||
*/
|
||||
export function runFeishuStartI18n(config) {
|
||||
return {
|
||||
zh_cn: runFeishuStart(config, 'zh_cn'),
|
||||
en_us: runFeishuStart(config, 'en_us'),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 生成 /feishu help 帮助文本。
|
||||
*/
|
||||
export function getFeishuHelp(locale = 'zh_cn') {
|
||||
const t = T[locale];
|
||||
return (`${t.helpTitle(getPluginVersion())}\n\n` +
|
||||
`${t.helpUsage}\n` +
|
||||
` ${t.helpStart}\n` +
|
||||
` ${t.helpAuth}\n` +
|
||||
` ${t.helpDoctor}\n` +
|
||||
` ${t.helpHelp}`);
|
||||
}
|
||||
/**
|
||||
* 生成 /feishu help,同时生成中英双语结果。
|
||||
*/
|
||||
export function getFeishuHelpI18n() {
|
||||
return {
|
||||
zh_cn: getFeishuHelp('zh_cn'),
|
||||
en_us: getFeishuHelp('en_us'),
|
||||
};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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 runDiagnosis({ config: ctx.config });
|
||||
return { text: 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 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 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 runFeishuAuth(ctx.config);
|
||||
return { text: result };
|
||||
}
|
||||
// /feishu doctor
|
||||
if (subcommand === 'doctor') {
|
||||
const markdown = await 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.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 { FeishuConfig, LarkAccount, LarkCredentials, ConfiguredLarkAccount } 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;
|
||||
/** 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,164 @@
|
||||
"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.
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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). */
|
||||
function mergeAccountConfig(base, override) {
|
||||
return { ...base, ...override };
|
||||
}
|
||||
/** 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.
|
||||
*/
|
||||
export function getLarkAccountIds(cfg) {
|
||||
const section = getLarkConfig(cfg);
|
||||
if (!section)
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
const accountMap = getAccountMap(section);
|
||||
if (!accountMap || Object.keys(accountMap).length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
const accountIds = Object.keys(accountMap);
|
||||
// 当 accounts 存在时,如果顶层也配置了 appId/appSecret(即默认机器人),
|
||||
// 将 DEFAULT_ACCOUNT_ID 加入列表,确保顶层机器人不会被忽略。
|
||||
// 但如果 accountMap 已经包含 default,则不重复添加。
|
||||
const hasDefault = accountIds.some((id) => id.trim().toLowerCase() === DEFAULT_ACCOUNT_ID);
|
||||
if (!hasDefault) {
|
||||
const base = baseConfig(section);
|
||||
if (base.appId && base.appSecret) {
|
||||
return [DEFAULT_ACCOUNT_ID, ...accountIds];
|
||||
}
|
||||
}
|
||||
return accountIds;
|
||||
}
|
||||
/** Return the first (default) account ID. */
|
||||
export 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`.
|
||||
*/
|
||||
export function getLarkAccount(cfg, accountId) {
|
||||
const requestedId = accountId ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) : 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 !== 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,
|
||||
};
|
||||
}
|
||||
/** Return all accounts that are both configured and enabled. */
|
||||
export 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.
|
||||
*/
|
||||
export 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`. */
|
||||
export function isConfigured(account) {
|
||||
return account.configured;
|
||||
}
|
||||
@@ -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,140 @@
|
||||
"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.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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,113 @@
|
||||
"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.
|
||||
*/
|
||||
import { extractPermissionGrantUrl, extractPermissionScopes } from './permission-url';
|
||||
import { LARK_ERROR } from './auth-errors';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 !== LARK_ERROR.APP_SCOPE_MISSING)
|
||||
return null;
|
||||
const authUrl = extractPermissionGrantUrl(msg);
|
||||
const scopes = 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 风格
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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).
|
||||
*/
|
||||
export 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,21 @@
|
||||
/**
|
||||
* 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 { 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: any): 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 缓存。
|
||||
*/
|
||||
import { getAppInfo } from './app-scope-checker';
|
||||
import { larkLogger } from './lark-logger';
|
||||
const log = 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
|
||||
*/
|
||||
export async function getAppOwnerFallback(account,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sdk) {
|
||||
const { appId } = account;
|
||||
try {
|
||||
const appInfo = await 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,191 @@
|
||||
"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 */
|
||||
import { larkLogger } from './lark-logger';
|
||||
const log = larkLogger('core/app-scope-checker');
|
||||
import { AppScopeCheckFailedError } from './auth-errors';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache
|
||||
// ---------------------------------------------------------------------------
|
||||
const cache = new Map();
|
||||
const CACHE_TTL_MS = 30 * 1000; // 30 秒
|
||||
/** 清除指定 appId 的缓存。 */
|
||||
export 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"]`
|
||||
*/
|
||||
export 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 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 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 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")
|
||||
*/
|
||||
export 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 列表
|
||||
*/
|
||||
export 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 列表
|
||||
*/
|
||||
export 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" 表示任一即可
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -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,155 @@
|
||||
"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 使用。
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feishu error code constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/** 飞书 OAPI 错误码常量,替代各处硬编码的 magic number。 */
|
||||
export const 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。 */
|
||||
export const REFRESH_TOKEN_RETRYABLE = new Set([
|
||||
LARK_ERROR.REFRESH_SERVER_ERROR,
|
||||
]);
|
||||
/** 消息终止错误码集合(撤回/删除),遇到后应停止对该消息的后续操作。 */
|
||||
export const MESSAGE_TERMINAL_CODES = new Set([
|
||||
LARK_ERROR.MESSAGE_RECALLED,
|
||||
LARK_ERROR.MESSAGE_DELETED,
|
||||
]);
|
||||
/** access_token 失效相关的错误码集合,遇到后可尝试刷新重试。 */
|
||||
export const TOKEN_RETRY_CODES = new Set([LARK_ERROR.TOKEN_INVALID, 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.
|
||||
*/
|
||||
export class NeedAuthorizationError extends Error {
|
||||
userOpenId;
|
||||
constructor(userOpenId) {
|
||||
super('need_user_authorization');
|
||||
this.name = 'NeedAuthorizationError';
|
||||
this.userOpenId = userOpenId;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 应用缺少 application:application:self_manage 权限,无法查询应用权限配置。
|
||||
*
|
||||
* 需要管理员在飞书开放平台开通 application:application:self_manage 权限。
|
||||
*/
|
||||
export class AppScopeCheckFailedError extends Error {
|
||||
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||
appId;
|
||||
constructor(appId) {
|
||||
super('应用缺少 application:application:self_manage 权限,无法查询应用权限配置。请管理员在开放平台开通该权限。');
|
||||
this.name = 'AppScopeCheckFailedError';
|
||||
this.appId = appId;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 应用未开通 OAPI 所需 scope。
|
||||
*
|
||||
* 需要管理员在飞书开放平台开通权限。
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 用户未授权或 scope 不足,需要发起 OAuth 授权。
|
||||
*
|
||||
* `requiredScopes` 为 APP∩OAPI 的有效 scope,可直接传给
|
||||
* `feishu_oauth authorize --scope`。
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 服务端报 99991679 — 用户 token 的 scope 不足。
|
||||
*
|
||||
* 需要增量授权:用缺失的 scope 发起新 Device Flow。
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
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'>;
|
||||
@@ -0,0 +1,153 @@
|
||||
"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)
|
||||
*/
|
||||
import { LarkClient } from './lark-client';
|
||||
import { larkLogger } from './lark-logger';
|
||||
const log = 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). */
|
||||
export 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.
|
||||
*/
|
||||
export 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).
|
||||
*/
|
||||
export 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 {
|
||||
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.
|
||||
*/
|
||||
export 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,448 @@
|
||||
/**
|
||||
* 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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
requireMention: 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>;
|
||||
}, 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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
pairing: "pairing";
|
||||
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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
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>;
|
||||
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
||||
groupPolicy: z.ZodOptional<z.ZodEnum<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
requireMention: 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>;
|
||||
}, 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<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>, z.ZodObject<{
|
||||
default: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
group: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
direct: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
}, z.core.$strip>]>>;
|
||||
streaming: z.ZodOptional<z.ZodBoolean>;
|
||||
blockStreaming: z.ZodOptional<z.ZodBoolean>;
|
||||
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>;
|
||||
}, z.core.$strip>>;
|
||||
markdown: z.ZodOptional<z.ZodObject<{
|
||||
tables: z.ZodOptional<z.ZodEnum<{
|
||||
code: "code";
|
||||
off: "off";
|
||||
bullets: "bullets";
|
||||
}>>;
|
||||
}, 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<{
|
||||
all: "all";
|
||||
off: "off";
|
||||
own: "own";
|
||||
}>>;
|
||||
threadSession: 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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
pairing: "pairing";
|
||||
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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
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>;
|
||||
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
||||
groupPolicy: z.ZodOptional<z.ZodEnum<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
requireMention: 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>;
|
||||
}, 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<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>, z.ZodObject<{
|
||||
default: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
group: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
direct: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
}, z.core.$strip>]>>;
|
||||
streaming: z.ZodOptional<z.ZodBoolean>;
|
||||
blockStreaming: z.ZodOptional<z.ZodBoolean>;
|
||||
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>;
|
||||
}, z.core.$strip>>;
|
||||
markdown: z.ZodOptional<z.ZodObject<{
|
||||
tables: z.ZodOptional<z.ZodEnum<{
|
||||
code: "code";
|
||||
off: "off";
|
||||
bullets: "bullets";
|
||||
}>>;
|
||||
}, 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<{
|
||||
all: "all";
|
||||
off: "off";
|
||||
own: "own";
|
||||
}>>;
|
||||
threadSession: 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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
pairing: "pairing";
|
||||
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<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
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>;
|
||||
groups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
||||
groupPolicy: z.ZodOptional<z.ZodEnum<{
|
||||
allowlist: "allowlist";
|
||||
open: "open";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
requireMention: 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>;
|
||||
}, 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<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>, z.ZodObject<{
|
||||
default: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
group: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
direct: z.ZodOptional<z.ZodEnum<{
|
||||
streaming: "streaming";
|
||||
auto: "auto";
|
||||
static: "static";
|
||||
}>>;
|
||||
}, z.core.$strip>]>>;
|
||||
streaming: z.ZodOptional<z.ZodBoolean>;
|
||||
blockStreaming: z.ZodOptional<z.ZodBoolean>;
|
||||
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>;
|
||||
}, z.core.$strip>>;
|
||||
markdown: z.ZodOptional<z.ZodObject<{
|
||||
tables: z.ZodOptional<z.ZodEnum<{
|
||||
code: "code";
|
||||
off: "off";
|
||||
bullets: "bullets";
|
||||
}>>;
|
||||
}, 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<{
|
||||
all: "all";
|
||||
off: "off";
|
||||
own: "own";
|
||||
}>>;
|
||||
threadSession: 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,201 @@
|
||||
"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.
|
||||
*/
|
||||
import { z, toJSONSchema } from 'zod';
|
||||
export { z };
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared micro-schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
const DmPolicyEnum = z.enum(['open', 'pairing', 'allowlist', 'disabled']);
|
||||
const GroupPolicyEnum = z.enum(['open', 'allowlist', 'disabled']);
|
||||
const ConnectionModeEnum = z.enum(['websocket', 'webhook']);
|
||||
const ReplyModeValue = z.enum(['auto', 'static', 'streaming']);
|
||||
const ReplyModeSchema = z
|
||||
.union([
|
||||
ReplyModeValue,
|
||||
z.object({
|
||||
default: ReplyModeValue.optional(),
|
||||
group: ReplyModeValue.optional(),
|
||||
direct: ReplyModeValue.optional(),
|
||||
}),
|
||||
])
|
||||
.optional();
|
||||
const ChunkModeEnum = z.enum(['newline', 'paragraph', 'none']);
|
||||
const DomainSchema = z.union([z.literal('feishu'), z.literal('lark'), z.string().regex(/^https:\/\//)]).optional();
|
||||
const AllowFromSchema = z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === null)
|
||||
return undefined;
|
||||
return Array.isArray(v) ? v : [v];
|
||||
});
|
||||
const ToolPolicySchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
const FeishuToolsFlagSchema = z
|
||||
.object({
|
||||
doc: z.boolean().optional(),
|
||||
wiki: z.boolean().optional(),
|
||||
drive: z.boolean().optional(),
|
||||
perm: z.boolean().optional(),
|
||||
scopes: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
const FeishuFooterSchema = z
|
||||
.object({
|
||||
status: z.boolean().optional(),
|
||||
elapsed: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
const BlockStreamingCoalesceSchema = z
|
||||
.object({
|
||||
minChars: z.number().optional(),
|
||||
maxChars: z.number().optional(),
|
||||
idleMs: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
const MarkdownConfigSchema = z
|
||||
.object({
|
||||
tables: z.enum(['off', 'bullets', 'code']).optional(),
|
||||
})
|
||||
.optional();
|
||||
const HeartbeatSchema = z
|
||||
.object({
|
||||
every: z.string().optional(),
|
||||
activeHours: z
|
||||
.object({
|
||||
start: z.string().optional(),
|
||||
end: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
target: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
accountId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
const CapabilitiesSchema = z
|
||||
.object({
|
||||
image: z.boolean().optional(),
|
||||
audio: z.boolean().optional(),
|
||||
video: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
const DedupSchema = z
|
||||
.object({
|
||||
ttlMs: z.number().optional(), // default 43200000 (12h)
|
||||
maxEntries: z.number().optional(), // default 5000
|
||||
})
|
||||
.optional();
|
||||
const ReactionNotificationModeSchema = z.enum(['off', 'own', 'all']).optional();
|
||||
export const UATConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowedScopes: z.array(z.string()).optional(),
|
||||
blockedScopes: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
const DmConfigSchema = z
|
||||
.object({
|
||||
historyLimit: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group schema
|
||||
// ---------------------------------------------------------------------------
|
||||
export const FeishuGroupSchema = z.object({
|
||||
groupPolicy: GroupPolicyEnum.optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: AllowFromSchema,
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account config schema (same shape as top-level minus `accounts`)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const FeishuAccountConfigSchema = z.object({
|
||||
appId: z.string().optional(),
|
||||
appSecret: z.string().optional(),
|
||||
encryptKey: z.string().optional(),
|
||||
verificationToken: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
domain: DomainSchema,
|
||||
connectionMode: ConnectionModeEnum.optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
webhookPort: z.number().optional(),
|
||||
dmPolicy: DmPolicyEnum.optional(),
|
||||
allowFrom: AllowFromSchema,
|
||||
groupPolicy: GroupPolicyEnum.optional(),
|
||||
groupAllowFrom: AllowFromSchema,
|
||||
requireMention: z.boolean().optional(),
|
||||
groups: z.record(z.string(), FeishuGroupSchema).optional(),
|
||||
historyLimit: z.number().optional(),
|
||||
dmHistoryLimit: z.number().optional(),
|
||||
dms: DmConfigSchema,
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: ChunkModeEnum.optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
||||
mediaMaxMb: z.number().optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
replyMode: ReplyModeSchema,
|
||||
streaming: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
tools: FeishuToolsFlagSchema,
|
||||
footer: FeishuFooterSchema,
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
capabilities: CapabilitiesSchema,
|
||||
dedup: DedupSchema,
|
||||
reactionNotifications: ReactionNotificationModeSchema,
|
||||
threadSession: z.boolean().optional(),
|
||||
uat: UATConfigSchema,
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level Feishu config schema
|
||||
// ---------------------------------------------------------------------------
|
||||
export const FeishuConfigSchema = FeishuAccountConfigSchema.extend({
|
||||
accounts: z.record(z.string(), 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: 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.
|
||||
*/
|
||||
export const FEISHU_CONFIG_JSON_SCHEMA = toJSONSchema(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,213 @@
|
||||
"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.
|
||||
*/
|
||||
import { larkLogger } from './lark-logger';
|
||||
const log = larkLogger('core/device-flow');
|
||||
import { feishuFetch } from './feishu-fetch';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Resolve the two OAuth endpoint URLs based on the configured brand.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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 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.
|
||||
*/
|
||||
export 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 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: '授权超时,请重新发起' };
|
||||
}
|
||||
@@ -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,29 @@
|
||||
"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.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
/** 开放平台域名 (API & 权限管理页面) */
|
||||
export function openPlatformDomain(brand) {
|
||||
return brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
|
||||
}
|
||||
/** Applink 域名 */
|
||||
export function applinkDomain(brand) {
|
||||
return brand === 'lark' ? 'https://applink.larksuite.com' : 'https://applink.feishu.cn';
|
||||
}
|
||||
/** 主站域名 (文档、表格等用户可见链接) */
|
||||
export function wwwDomain(brand) {
|
||||
return brand === 'lark' ? 'https://www.larksuite.com' : 'https://www.feishu.cn';
|
||||
}
|
||||
/** MCP 服务域名 */
|
||||
export 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,26 @@
|
||||
"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.
|
||||
*/
|
||||
import { getUserAgent } from './version';
|
||||
/**
|
||||
* 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 function feishuFetch(url, init) {
|
||||
const headers = {
|
||||
...init?.headers,
|
||||
'User-Agent': 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,40 @@
|
||||
"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).
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* The default footer configuration.
|
||||
*
|
||||
* By default all metadata items are hidden — neither status text
|
||||
* ("已完成" / "出错" / "已停止") nor elapsed time are shown.
|
||||
*/
|
||||
export const DEFAULT_FOOTER_CONFIG = {
|
||||
status: false,
|
||||
elapsed: 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.
|
||||
*/
|
||||
export function resolveFooterConfig(cfg) {
|
||||
if (!cfg)
|
||||
return { ...DEFAULT_FOOTER_CONFIG };
|
||||
return {
|
||||
status: cfg.status ?? DEFAULT_FOOTER_CONFIG.status,
|
||||
elapsed: cfg.elapsed ?? DEFAULT_FOOTER_CONFIG.elapsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 { LarkBrand, LarkAccount, FeishuProbeResult } from './types';
|
||||
import type { MessageDedup } from '../messaging/inbound/dedup';
|
||||
/** 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;
|
||||
private static _runtime;
|
||||
/** 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): void;
|
||||
/** Lazily-created Lark SDK client. */
|
||||
get sdk(): Lark.Client;
|
||||
/**
|
||||
* Probe bot identity via the `bot/v3/info` API.
|
||||
* Results are cached on the instance for subsequent access via
|
||||
* `botOpenId` / `botName`.
|
||||
*/
|
||||
probe(opts?: {
|
||||
maxAgeMs?: number;
|
||||
}): 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;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"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 */
|
||||
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||
import { getLarkAccount } from './accounts';
|
||||
import { clearUserNameCache } from '../messaging/inbound/user-name-cache';
|
||||
import { clearChatInfoCache } from './chat-info-cache';
|
||||
import { getUserAgent } from './version';
|
||||
import { larkLogger } from './lark-logger';
|
||||
const log = larkLogger('core/lark-client');
|
||||
// ---------------------------------------------------------------------------
|
||||
// 注入 User-Agent 到所有飞书 SDK 请求
|
||||
// ---------------------------------------------------------------------------
|
||||
const GLOBAL_LARK_USER_AGENT_KEY = 'LARK_USER_AGENT';
|
||||
function installGlobalUserAgent() {
|
||||
// node-sdk 内置拦截器最终会读取 global.LARK_USER_AGENT 并覆盖 User-Agent
|
||||
globalThis[GLOBAL_LARK_USER_AGENT_KEY] = getUserAgent();
|
||||
}
|
||||
installGlobalUserAgent();
|
||||
Lark.defaultHttpInstance.interceptors.request.handlers = [];
|
||||
// 使用 interceptors 在所有 HTTP 请求中注入 User-Agent header
|
||||
Lark.defaultHttpInstance.interceptors.request.use((req) => {
|
||||
if (req.headers) {
|
||||
req.headers['User-Agent'] = 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();
|
||||
export 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) ------------------------------------------
|
||||
static _runtime = null;
|
||||
/** Persist the runtime instance for later retrieval (activate 阶段调用一次). */
|
||||
static setRuntime(runtime) {
|
||||
LarkClient._runtime = runtime;
|
||||
}
|
||||
/** Retrieve the stored runtime instance. Throws if not yet initialised. */
|
||||
static get runtime() {
|
||||
if (!LarkClient._runtime) {
|
||||
throw new Error('Feishu plugin runtime has not been initialised. ' +
|
||||
'Ensure LarkClient.setRuntime() is called during plugin activation.');
|
||||
}
|
||||
return LarkClient._runtime;
|
||||
}
|
||||
// ---- 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(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 && 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 clearCache(accountId) {
|
||||
if (accountId !== undefined) {
|
||||
cache.get(accountId)?.dispose();
|
||||
clearUserNameCache(accountId);
|
||||
clearChatInfoCache(accountId);
|
||||
}
|
||||
else {
|
||||
for (const inst of cache.values())
|
||||
inst.dispose();
|
||||
clearUserNameCache();
|
||||
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/v3/info` 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 res = await this.sdk.request({
|
||||
method: 'GET',
|
||||
url: '/open-apis/bot/v3/info',
|
||||
data: {},
|
||||
});
|
||||
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 bot = res.bot || res.data?.bot;
|
||||
this._botOpenId = bot?.open_id;
|
||||
this._botName = bot?.bot_name;
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,155 @@
|
||||
"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 });
|
||||
*/
|
||||
import { LarkClient } from './lark-client';
|
||||
import { getTicket } from './lark-ticket';
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
return LarkClient.runtime.logging.getChildLogger({
|
||||
subsystem: `feishu/${subsystem}`,
|
||||
});
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// LarkTicket enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
function getTraceMeta() {
|
||||
const ctx = 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 = 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
|
||||
// ---------------------------------------------------------------------------
|
||||
export 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,36 @@
|
||||
"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.
|
||||
*/
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
const store = new 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}.
|
||||
*/
|
||||
export function withTicket(ticket, fn) {
|
||||
return store.run(ticket, fn);
|
||||
}
|
||||
/** Return the current ticket, or `undefined` if not inside withTicket. */
|
||||
export function getTicket() {
|
||||
return store.getStore();
|
||||
}
|
||||
/** Milliseconds elapsed since the current ticket was created, or 0. */
|
||||
export function ticketElapsed() {
|
||||
const t = store.getStore();
|
||||
return t ? Date.now() - t.startTime : 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* 消息不可用(已撤回/已删除)状态管理。
|
||||
*
|
||||
* 目标:
|
||||
* 1) 当命中飞书终止错误码(230011/231003)时,按 message_id 标记不可用;
|
||||
* 2) 后续针对该 message_id 的 API 调用直接短路,避免持续报错刷屏。
|
||||
*/
|
||||
import { LARK_ERROR } from './auth-errors';
|
||||
export type TerminalMessageApiCode = typeof LARK_ERROR.MESSAGE_RECALLED | typeof LARK_ERROR.MESSAGE_DELETED;
|
||||
export interface MessageUnavailableState {
|
||||
apiCode: TerminalMessageApiCode;
|
||||
markedAtMs: number;
|
||||
operation?: string;
|
||||
}
|
||||
export declare function isTerminalMessageApiCode(code: unknown): code is TerminalMessageApiCode;
|
||||
export declare function markMessageUnavailable(params: {
|
||||
messageId: string;
|
||||
apiCode: TerminalMessageApiCode;
|
||||
operation?: string;
|
||||
}): void;
|
||||
export declare function getMessageUnavailableState(messageId: string | undefined): MessageUnavailableState | undefined;
|
||||
export declare function isMessageUnavailable(messageId: string | undefined): boolean;
|
||||
export declare function markMessageUnavailableFromError(params: {
|
||||
messageId: string | undefined;
|
||||
error: unknown;
|
||||
operation?: string;
|
||||
}): TerminalMessageApiCode | undefined;
|
||||
export declare class MessageUnavailableError extends Error {
|
||||
readonly messageId: string;
|
||||
readonly apiCode: TerminalMessageApiCode;
|
||||
readonly operation?: string;
|
||||
constructor(params: {
|
||||
messageId: string;
|
||||
apiCode: TerminalMessageApiCode;
|
||||
operation?: string;
|
||||
});
|
||||
}
|
||||
export declare function isMessageUnavailableError(error: unknown): error is MessageUnavailableError;
|
||||
export declare function assertMessageAvailable(messageId: string | undefined, operation?: string): void;
|
||||
/**
|
||||
* 针对 message_id 的统一保护:
|
||||
* - 调用前检查是否已标记不可用;
|
||||
* - 调用报错后识别 230011/231003 并标记;
|
||||
* - 命中时抛出 MessageUnavailableError 供上游快速终止流程。
|
||||
*/
|
||||
export declare function runWithMessageUnavailableGuard<T>(params: {
|
||||
messageId: string | undefined;
|
||||
operation: string;
|
||||
fn: () => Promise<T>;
|
||||
}): Promise<T>;
|
||||
@@ -0,0 +1,131 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* 消息不可用(已撤回/已删除)状态管理。
|
||||
*
|
||||
* 目标:
|
||||
* 1) 当命中飞书终止错误码(230011/231003)时,按 message_id 标记不可用;
|
||||
* 2) 后续针对该 message_id 的 API 调用直接短路,避免持续报错刷屏。
|
||||
*/
|
||||
import { MESSAGE_TERMINAL_CODES } from './auth-errors';
|
||||
import { extractLarkApiCode } from './api-error';
|
||||
import { normalizeMessageId } from './targets';
|
||||
const UNAVAILABLE_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_CACHE_SIZE_BEFORE_PRUNE = 512;
|
||||
const unavailableMessageCache = new Map();
|
||||
function pruneExpired(nowMs = Date.now()) {
|
||||
for (const [messageId, state] of unavailableMessageCache) {
|
||||
if (nowMs - state.markedAtMs > UNAVAILABLE_CACHE_TTL_MS) {
|
||||
unavailableMessageCache.delete(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
export function isTerminalMessageApiCode(code) {
|
||||
return typeof code === 'number' && MESSAGE_TERMINAL_CODES.has(code);
|
||||
}
|
||||
export function markMessageUnavailable(params) {
|
||||
const normalizedId = normalizeMessageId(params.messageId);
|
||||
if (!normalizedId)
|
||||
return;
|
||||
if (unavailableMessageCache.size >= MAX_CACHE_SIZE_BEFORE_PRUNE) {
|
||||
pruneExpired();
|
||||
}
|
||||
unavailableMessageCache.set(normalizedId, {
|
||||
apiCode: params.apiCode,
|
||||
operation: params.operation,
|
||||
markedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
export function getMessageUnavailableState(messageId) {
|
||||
const normalizedId = normalizeMessageId(messageId);
|
||||
if (!normalizedId)
|
||||
return undefined;
|
||||
const state = unavailableMessageCache.get(normalizedId);
|
||||
if (!state)
|
||||
return undefined;
|
||||
if (Date.now() - state.markedAtMs > UNAVAILABLE_CACHE_TTL_MS) {
|
||||
unavailableMessageCache.delete(normalizedId);
|
||||
return undefined;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
export function isMessageUnavailable(messageId) {
|
||||
return !!getMessageUnavailableState(messageId);
|
||||
}
|
||||
export function markMessageUnavailableFromError(params) {
|
||||
const normalizedId = normalizeMessageId(params.messageId);
|
||||
if (!normalizedId)
|
||||
return undefined;
|
||||
const code = extractLarkApiCode(params.error);
|
||||
if (!isTerminalMessageApiCode(code))
|
||||
return undefined;
|
||||
markMessageUnavailable({
|
||||
messageId: normalizedId,
|
||||
apiCode: code,
|
||||
operation: params.operation,
|
||||
});
|
||||
return code;
|
||||
}
|
||||
export class MessageUnavailableError extends Error {
|
||||
messageId;
|
||||
apiCode;
|
||||
operation;
|
||||
constructor(params) {
|
||||
const operationText = params.operation ? `, op=${params.operation}` : '';
|
||||
super(`[feishu-message-unavailable] message ${params.messageId} unavailable (code=${params.apiCode}${operationText})`);
|
||||
this.name = 'MessageUnavailableError';
|
||||
this.messageId = params.messageId;
|
||||
this.apiCode = params.apiCode;
|
||||
this.operation = params.operation;
|
||||
}
|
||||
}
|
||||
export function isMessageUnavailableError(error) {
|
||||
return (error instanceof MessageUnavailableError ||
|
||||
(typeof error === 'object' && error !== null && error.name === 'MessageUnavailableError'));
|
||||
}
|
||||
export function assertMessageAvailable(messageId, operation) {
|
||||
const normalizedId = normalizeMessageId(messageId);
|
||||
if (!normalizedId)
|
||||
return;
|
||||
const state = getMessageUnavailableState(normalizedId);
|
||||
if (!state)
|
||||
return;
|
||||
throw new MessageUnavailableError({
|
||||
messageId: normalizedId,
|
||||
apiCode: state.apiCode,
|
||||
operation: operation ?? state.operation,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 针对 message_id 的统一保护:
|
||||
* - 调用前检查是否已标记不可用;
|
||||
* - 调用报错后识别 230011/231003 并标记;
|
||||
* - 命中时抛出 MessageUnavailableError 供上游快速终止流程。
|
||||
*/
|
||||
export async function runWithMessageUnavailableGuard(params) {
|
||||
const normalizedId = normalizeMessageId(params.messageId);
|
||||
if (!normalizedId) {
|
||||
return params.fn();
|
||||
}
|
||||
assertMessageAvailable(normalizedId, params.operation);
|
||||
try {
|
||||
return await params.fn();
|
||||
}
|
||||
catch (error) {
|
||||
const code = markMessageUnavailableFromError({
|
||||
messageId: normalizedId,
|
||||
error,
|
||||
operation: params.operation,
|
||||
});
|
||||
if (code) {
|
||||
throw new MessageUnavailableError({
|
||||
messageId: normalizedId,
|
||||
apiCode: code,
|
||||
operation: params.operation,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* owner-policy.ts — 应用 Owner 访问控制策略。
|
||||
*
|
||||
* 从 uat-client.ts 迁移 owner 检查逻辑到独立 policy 层。
|
||||
* 提供 fail-close 策略(安全优先:授权发起路径)。
|
||||
*/
|
||||
import type { ConfiguredLarkAccount } from './types';
|
||||
/**
|
||||
* 非应用 owner 尝试执行 owner-only 操作时抛出。
|
||||
*
|
||||
* 注意:`appOwnerId` 仅用于内部日志,不应序列化到用户可见的响应中,
|
||||
* 以避免泄露 owner 的 open_id。
|
||||
*/
|
||||
export declare class OwnerAccessDeniedError extends Error {
|
||||
readonly userOpenId: string;
|
||||
readonly appOwnerId: string;
|
||||
constructor(userOpenId: string, appOwnerId: string);
|
||||
}
|
||||
/**
|
||||
* 校验用户是否为应用 owner(fail-close 版本)。
|
||||
*
|
||||
* - 获取 owner 失败时 → 拒绝(安全优先)
|
||||
* - owner 不匹配时 → 拒绝
|
||||
*
|
||||
* 适用于:`executeAuthorize`(OAuth 授权发起)、`commands/auth.ts`(批量授权)等
|
||||
* 赋予实质性权限的入口。
|
||||
*/
|
||||
export declare function assertOwnerAccessStrict(account: ConfiguredLarkAccount, sdk: any, userOpenId: string): Promise<void>;
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* owner-policy.ts — 应用 Owner 访问控制策略。
|
||||
*
|
||||
* 从 uat-client.ts 迁移 owner 检查逻辑到独立 policy 层。
|
||||
* 提供 fail-close 策略(安全优先:授权发起路径)。
|
||||
*/
|
||||
import { getAppOwnerFallback } from './app-owner-fallback';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error class
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* 非应用 owner 尝试执行 owner-only 操作时抛出。
|
||||
*
|
||||
* 注意:`appOwnerId` 仅用于内部日志,不应序列化到用户可见的响应中,
|
||||
* 以避免泄露 owner 的 open_id。
|
||||
*/
|
||||
export class OwnerAccessDeniedError extends Error {
|
||||
userOpenId;
|
||||
appOwnerId;
|
||||
constructor(userOpenId, appOwnerId) {
|
||||
super('Permission denied: Only the app owner is authorized to use this feature.');
|
||||
this.name = 'OwnerAccessDeniedError';
|
||||
this.userOpenId = userOpenId;
|
||||
this.appOwnerId = appOwnerId;
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy functions
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* 校验用户是否为应用 owner(fail-close 版本)。
|
||||
*
|
||||
* - 获取 owner 失败时 → 拒绝(安全优先)
|
||||
* - owner 不匹配时 → 拒绝
|
||||
*
|
||||
* 适用于:`executeAuthorize`(OAuth 授权发起)、`commands/auth.ts`(批量授权)等
|
||||
* 赋予实质性权限的入口。
|
||||
*/
|
||||
export async function assertOwnerAccessStrict(account,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sdk, userOpenId) {
|
||||
const ownerOpenId = await getAppOwnerFallback(account, sdk);
|
||||
if (!ownerOpenId) {
|
||||
throw new OwnerAccessDeniedError(userOpenId, 'unknown');
|
||||
}
|
||||
if (ownerOpenId !== userOpenId) {
|
||||
throw new OwnerAccessDeniedError(userOpenId, ownerOpenId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Permission URL extraction utilities.
|
||||
*
|
||||
* Shared functions for extracting and processing permission grant URLs
|
||||
* from Feishu API error messages.
|
||||
*/
|
||||
/**
|
||||
* Extract permission grant URL from a Feishu error message and optimize it
|
||||
* by keeping only the highest-priority permission.
|
||||
*
|
||||
* @param msg - The error message containing the grant URL
|
||||
* @returns The optimized grant URL with single permission, or empty string if not found
|
||||
*/
|
||||
export declare function extractPermissionGrantUrl(msg: string): string;
|
||||
/**
|
||||
* Extract permission scopes from a Feishu error message.
|
||||
* Looks for scopes in the format [scope1,scope2,...]
|
||||
*/
|
||||
export declare function extractPermissionScopes(msg: string): string;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Permission URL extraction utilities.
|
||||
*
|
||||
* Shared functions for extracting and processing permission grant URLs
|
||||
* from Feishu API error messages.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission priority
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Permission priority for sorting.
|
||||
* Lower number = higher priority.
|
||||
* - read: 1 (highest)
|
||||
* - write: 2
|
||||
* - other / both read+write: 3 (lowest)
|
||||
*/
|
||||
function getPermissionPriority(scope) {
|
||||
const lowerScope = scope.toLowerCase();
|
||||
const hasRead = lowerScope.includes('read');
|
||||
const hasWrite = lowerScope.includes('write');
|
||||
if (hasRead && !hasWrite)
|
||||
return 1;
|
||||
if (hasWrite && !hasRead)
|
||||
return 2;
|
||||
return 3;
|
||||
}
|
||||
/**
|
||||
* Extract the highest-priority permission from a scope list.
|
||||
* Returns the permission with the lowest priority number (read > write > other).
|
||||
*/
|
||||
function extractHighestPriorityScope(scopeList) {
|
||||
return scopeList.split(',').sort((a, b) => getPermissionPriority(a) - getPermissionPriority(b))[0] ?? '';
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission URL extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Extract permission grant URL from a Feishu error message and optimize it
|
||||
* by keeping only the highest-priority permission.
|
||||
*
|
||||
* @param msg - The error message containing the grant URL
|
||||
* @returns The optimized grant URL with single permission, or empty string if not found
|
||||
*/
|
||||
export function extractPermissionGrantUrl(msg) {
|
||||
const urlMatch = msg.match(/https:\/\/[^\s]+\/app\/[^\s]+/);
|
||||
if (!urlMatch?.[0]) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const url = new URL(urlMatch[0]);
|
||||
const scopeListParam = url.searchParams.get('q') ?? '';
|
||||
const firstScope = extractHighestPriorityScope(scopeListParam);
|
||||
if (firstScope) {
|
||||
url.searchParams.set('q', firstScope);
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
catch {
|
||||
return urlMatch[0];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Extract permission scopes from a Feishu error message.
|
||||
* Looks for scopes in the format [scope1,scope2,...]
|
||||
*/
|
||||
export function extractPermissionScopes(msg) {
|
||||
const scopeMatch = msg.match(/\[([^\]]+)\]/);
|
||||
return scopeMatch?.[1] ?? 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* raw-request.ts — 飞书 Open API 裸 HTTP 请求工具。
|
||||
*
|
||||
* 从 tool-client.ts 提取,提供不依赖 SDK 的直接 API 调用能力。
|
||||
* 用于 SDK 未覆盖的 API 或需要精细控制请求的场景。
|
||||
*/
|
||||
import type { LarkBrand } from './types';
|
||||
/** 将 LarkBrand 映射为 API base URL。 */
|
||||
export declare function resolveDomainUrl(brand: LarkBrand): string;
|
||||
export interface RawLarkRequestOptions {
|
||||
brand: LarkBrand;
|
||||
path: string;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
query?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
accessToken?: string;
|
||||
}
|
||||
/**
|
||||
* 发起 raw HTTP 请求到飞书 API,自动处理域名解析、header 注入和错误检测。
|
||||
*
|
||||
* 飞书 API 统一错误模式:返回 JSON 中 `code !== 0` 表示失败。
|
||||
*/
|
||||
export declare function rawLarkRequest<T>(options: RawLarkRequestOptions): Promise<T>;
|
||||
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* raw-request.ts — 飞书 Open API 裸 HTTP 请求工具。
|
||||
*
|
||||
* 从 tool-client.ts 提取,提供不依赖 SDK 的直接 API 调用能力。
|
||||
* 用于 SDK 未覆盖的 API 或需要精细控制请求的场景。
|
||||
*/
|
||||
import { feishuFetch } from './feishu-fetch';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain URL resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
/** 将 LarkBrand 映射为 API base URL。 */
|
||||
export function resolveDomainUrl(brand) {
|
||||
const map = {
|
||||
feishu: 'https://open.feishu.cn',
|
||||
lark: 'https://open.larksuite.com',
|
||||
};
|
||||
return map[brand] ?? `https://${brand}`;
|
||||
}
|
||||
/**
|
||||
* 发起 raw HTTP 请求到飞书 API,自动处理域名解析、header 注入和错误检测。
|
||||
*
|
||||
* 飞书 API 统一错误模式:返回 JSON 中 `code !== 0` 表示失败。
|
||||
*/
|
||||
export async function rawLarkRequest(options) {
|
||||
const baseUrl = resolveDomainUrl(options.brand);
|
||||
const url = new URL(options.path, baseUrl);
|
||||
if (options.query) {
|
||||
for (const [k, v] of Object.entries(options.query)) {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
const headers = {};
|
||||
if (options.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${options.accessToken}`;
|
||||
}
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (options.headers) {
|
||||
Object.assign(headers, options.headers);
|
||||
}
|
||||
const resp = await feishuFetch(url.toString(), {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = (await resp.json());
|
||||
// 飞书 API 统一错误模式:code !== 0
|
||||
if (data.code !== undefined && data.code !== 0) {
|
||||
const err = new Error(data.msg ?? `Lark API error: code=${data.code}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
err.code = data.code;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
err.msg = data.msg;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Scope 管理模块
|
||||
*
|
||||
* 为所有工具动作提供类型安全的 scope 查询和检查功能。
|
||||
*
|
||||
* ## 三个核心概念
|
||||
*
|
||||
* ### 1. Required Scopes(API 需要的权限)
|
||||
* - 定义:每个 API 调用所需的飞书权限列表
|
||||
* - 来源:tool-scopes.ts(手动维护的类型化配置)
|
||||
* - 示例:`["calendar:calendar.event:create", "calendar:calendar.event:update"]`
|
||||
* - 用途:判断应用和用户是否需要申请/授权权限
|
||||
*
|
||||
* ### 2. App Granted Scopes(应用已开通的权限)
|
||||
* - 定义:应用在飞书开放平台配置并获得管理员批准的权限
|
||||
* - 来源:通过 API 查询 `/open-apis/application/v6/applications`
|
||||
* - 作用:应用级权限前置检查,避免无效的用户授权请求
|
||||
* - 检查时机:在请求用户授权前
|
||||
*
|
||||
* ### 3. User Granted Scopes(用户授权的权限)
|
||||
* - 定义:用户通过 OAuth 流程明确授权给应用的权限
|
||||
* - 来源:OAuth token 中的 scope 字段
|
||||
* - 作用:用户级权限检查,确保用户已授权所需权限
|
||||
* - 检查时机:每次 API 调用前
|
||||
*
|
||||
* ## 权限检查流程
|
||||
*
|
||||
* ```
|
||||
* 1. 获取 Required Scopes (API 需要什么权限?)
|
||||
* ↓
|
||||
* 2. 检查 App Granted Scopes (应用开通了吗?)
|
||||
* ↓ 是
|
||||
* 3. 检查 User Granted Scopes (用户授权了吗?)
|
||||
* ↓ 是
|
||||
* 4. 调用 API
|
||||
* ```
|
||||
*/
|
||||
import { type ToolActionKey, type ToolScopeMapping, TOOL_SCOPES } from './tool-scopes';
|
||||
export type { ToolActionKey, ToolScopeMapping };
|
||||
export { TOOL_SCOPES };
|
||||
/**
|
||||
* 获取单个工具动作所需的 scopes(Required Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键(例如 "feishu_calendar_event.create")
|
||||
* @returns API 需要的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const requiredScopes = getRequiredScopes("feishu_calendar_event.create");
|
||||
* // 返回: ["calendar:calendar.event:create", "calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export declare function getRequiredScopes(toolAction: ToolActionKey): string[];
|
||||
/**
|
||||
* 获取多个工具动作的合并 Required Scopes(去重)
|
||||
*
|
||||
* @param toolActions - 工具动作键数组
|
||||
* @returns 去重并排序后的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const requiredScopes = getRequiredScopesForActions([
|
||||
* "feishu_calendar_event.create",
|
||||
* "feishu_calendar_event.list"
|
||||
* ]);
|
||||
* // 返回两个动作的所有唯一 scopes
|
||||
* ```
|
||||
*/
|
||||
export declare function getRequiredScopesForActions(toolActions: ToolActionKey[]): string[];
|
||||
/**
|
||||
* 检查工具动作是否需要任何 scope
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @returns 如果动作需要至少一个 scope 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* hasRequiredScopes("feishu_calendar_event.create"); // true
|
||||
* hasRequiredScopes("feishu_sheets_spreadsheet.create"); // false (空数组)
|
||||
* ```
|
||||
*/
|
||||
export declare function hasRequiredScopes(toolAction: ToolActionKey): boolean;
|
||||
/**
|
||||
* 获取需要特定 scope 的所有工具动作
|
||||
*
|
||||
* @param scope - Scope 字符串(例如 "calendar:calendar.event:create")
|
||||
* @returns 需要此 scope 的工具动作键数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const actions = getActionsForScope("calendar:calendar.event:create");
|
||||
* // 返回: ["feishu_calendar_event.create"]
|
||||
* ```
|
||||
*/
|
||||
export declare function getActionsForScope(scope: string): ToolActionKey[];
|
||||
/**
|
||||
* 检查应用是否开通了工具动作所需的所有权限(App Granted Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param appGrantedScopes - 应用已开通的 scope 集合(来自开放平台)
|
||||
* @returns 如果应用已开通所有必需的 scopes 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const appScopes = new Set([
|
||||
* "calendar:calendar.event:create",
|
||||
* "calendar:calendar.event:update"
|
||||
* ]);
|
||||
* checkAppScopes("feishu_calendar_event.create", appScopes); // true
|
||||
*
|
||||
* const partialAppScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* checkAppScopes("feishu_calendar_event.create", partialAppScopes); // false
|
||||
* ```
|
||||
*/
|
||||
export declare function checkAppScopes(toolAction: ToolActionKey, appGrantedScopes: Set<string> | string[]): boolean;
|
||||
/**
|
||||
* 获取应用未开通的 scopes
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param appGrantedScopes - 应用已开通的 scope 集合
|
||||
* @returns 应用未开通的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const appScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* const missing = getMissingAppScopes("feishu_calendar_event.create", appScopes);
|
||||
* // 返回: ["calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export declare function getMissingAppScopes(toolAction: ToolActionKey, appGrantedScopes: Set<string> | string[]): string[];
|
||||
/**
|
||||
* 检查用户是否授权了工具动作所需的所有权限(User Granted Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param userGrantedScopes - 用户已授权的 scope 集合(来自 OAuth token)
|
||||
* @returns 如果用户已授权所有必需的 scopes 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userScopes = new Set([
|
||||
* "calendar:calendar.event:create",
|
||||
* "calendar:calendar.event:update"
|
||||
* ]);
|
||||
* checkUserScopes("feishu_calendar_event.create", userScopes); // true
|
||||
*
|
||||
* const partialUserScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* checkUserScopes("feishu_calendar_event.create", partialUserScopes); // false
|
||||
* ```
|
||||
*/
|
||||
export declare function checkUserScopes(toolAction: ToolActionKey, userGrantedScopes: Set<string> | string[]): boolean;
|
||||
/**
|
||||
* 获取用户未授权的 scopes
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param userGrantedScopes - 用户已授权的 scope 集合
|
||||
* @returns 用户未授权的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* const missing = getMissingUserScopes("feishu_calendar_event.create", userScopes);
|
||||
* // 返回: ["calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export declare function getMissingUserScopes(toolAction: ToolActionKey, userGrantedScopes: Set<string> | string[]): string[];
|
||||
@@ -0,0 +1,214 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Scope 管理模块
|
||||
*
|
||||
* 为所有工具动作提供类型安全的 scope 查询和检查功能。
|
||||
*
|
||||
* ## 三个核心概念
|
||||
*
|
||||
* ### 1. Required Scopes(API 需要的权限)
|
||||
* - 定义:每个 API 调用所需的飞书权限列表
|
||||
* - 来源:tool-scopes.ts(手动维护的类型化配置)
|
||||
* - 示例:`["calendar:calendar.event:create", "calendar:calendar.event:update"]`
|
||||
* - 用途:判断应用和用户是否需要申请/授权权限
|
||||
*
|
||||
* ### 2. App Granted Scopes(应用已开通的权限)
|
||||
* - 定义:应用在飞书开放平台配置并获得管理员批准的权限
|
||||
* - 来源:通过 API 查询 `/open-apis/application/v6/applications`
|
||||
* - 作用:应用级权限前置检查,避免无效的用户授权请求
|
||||
* - 检查时机:在请求用户授权前
|
||||
*
|
||||
* ### 3. User Granted Scopes(用户授权的权限)
|
||||
* - 定义:用户通过 OAuth 流程明确授权给应用的权限
|
||||
* - 来源:OAuth token 中的 scope 字段
|
||||
* - 作用:用户级权限检查,确保用户已授权所需权限
|
||||
* - 检查时机:每次 API 调用前
|
||||
*
|
||||
* ## 权限检查流程
|
||||
*
|
||||
* ```
|
||||
* 1. 获取 Required Scopes (API 需要什么权限?)
|
||||
* ↓
|
||||
* 2. 检查 App Granted Scopes (应用开通了吗?)
|
||||
* ↓ 是
|
||||
* 3. 检查 User Granted Scopes (用户授权了吗?)
|
||||
* ↓ 是
|
||||
* 4. 调用 API
|
||||
* ```
|
||||
*/
|
||||
import { TOOL_SCOPES } from './tool-scopes';
|
||||
export { TOOL_SCOPES };
|
||||
// ===== 函数:Required Scopes(API 需要的权限)=====
|
||||
/**
|
||||
* 获取单个工具动作所需的 scopes(Required Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键(例如 "feishu_calendar_event.create")
|
||||
* @returns API 需要的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const requiredScopes = getRequiredScopes("feishu_calendar_event.create");
|
||||
* // 返回: ["calendar:calendar.event:create", "calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export function getRequiredScopes(toolAction) {
|
||||
return TOOL_SCOPES[toolAction] ?? [];
|
||||
}
|
||||
/**
|
||||
* 获取多个工具动作的合并 Required Scopes(去重)
|
||||
*
|
||||
* @param toolActions - 工具动作键数组
|
||||
* @returns 去重并排序后的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const requiredScopes = getRequiredScopesForActions([
|
||||
* "feishu_calendar_event.create",
|
||||
* "feishu_calendar_event.list"
|
||||
* ]);
|
||||
* // 返回两个动作的所有唯一 scopes
|
||||
* ```
|
||||
*/
|
||||
export function getRequiredScopesForActions(toolActions) {
|
||||
const scopesSet = new Set();
|
||||
for (const action of toolActions) {
|
||||
const scopes = getRequiredScopes(action);
|
||||
scopes.forEach((scope) => scopesSet.add(scope));
|
||||
}
|
||||
return Array.from(scopesSet).sort();
|
||||
}
|
||||
/**
|
||||
* 检查工具动作是否需要任何 scope
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @returns 如果动作需要至少一个 scope 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* hasRequiredScopes("feishu_calendar_event.create"); // true
|
||||
* hasRequiredScopes("feishu_sheets_spreadsheet.create"); // false (空数组)
|
||||
* ```
|
||||
*/
|
||||
export function hasRequiredScopes(toolAction) {
|
||||
return getRequiredScopes(toolAction).length > 0;
|
||||
}
|
||||
/**
|
||||
* 获取需要特定 scope 的所有工具动作
|
||||
*
|
||||
* @param scope - Scope 字符串(例如 "calendar:calendar.event:create")
|
||||
* @returns 需要此 scope 的工具动作键数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const actions = getActionsForScope("calendar:calendar.event:create");
|
||||
* // 返回: ["feishu_calendar_event.create"]
|
||||
* ```
|
||||
*/
|
||||
export function getActionsForScope(scope) {
|
||||
const actions = [];
|
||||
for (const [action, scopes] of Object.entries(TOOL_SCOPES)) {
|
||||
if (scopes.includes(scope)) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
// ===== 函数:App Scope 检查 =====
|
||||
/**
|
||||
* 检查应用是否开通了工具动作所需的所有权限(App Granted Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param appGrantedScopes - 应用已开通的 scope 集合(来自开放平台)
|
||||
* @returns 如果应用已开通所有必需的 scopes 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const appScopes = new Set([
|
||||
* "calendar:calendar.event:create",
|
||||
* "calendar:calendar.event:update"
|
||||
* ]);
|
||||
* checkAppScopes("feishu_calendar_event.create", appScopes); // true
|
||||
*
|
||||
* const partialAppScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* checkAppScopes("feishu_calendar_event.create", partialAppScopes); // false
|
||||
* ```
|
||||
*/
|
||||
export function checkAppScopes(toolAction, appGrantedScopes) {
|
||||
const requiredScopes = getRequiredScopes(toolAction);
|
||||
// 如果不需要任何 scope,则总是满足要求
|
||||
if (requiredScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const grantedSet = Array.isArray(appGrantedScopes) ? new Set(appGrantedScopes) : appGrantedScopes;
|
||||
return requiredScopes.every((scope) => grantedSet.has(scope));
|
||||
}
|
||||
/**
|
||||
* 获取应用未开通的 scopes
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param appGrantedScopes - 应用已开通的 scope 集合
|
||||
* @returns 应用未开通的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const appScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* const missing = getMissingAppScopes("feishu_calendar_event.create", appScopes);
|
||||
* // 返回: ["calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export function getMissingAppScopes(toolAction, appGrantedScopes) {
|
||||
const requiredScopes = getRequiredScopes(toolAction);
|
||||
const grantedSet = Array.isArray(appGrantedScopes) ? new Set(appGrantedScopes) : appGrantedScopes;
|
||||
return requiredScopes.filter((scope) => !grantedSet.has(scope));
|
||||
}
|
||||
// ===== 函数:User Scope 检查 =====
|
||||
/**
|
||||
* 检查用户是否授权了工具动作所需的所有权限(User Granted Scopes)
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param userGrantedScopes - 用户已授权的 scope 集合(来自 OAuth token)
|
||||
* @returns 如果用户已授权所有必需的 scopes 则返回 true
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userScopes = new Set([
|
||||
* "calendar:calendar.event:create",
|
||||
* "calendar:calendar.event:update"
|
||||
* ]);
|
||||
* checkUserScopes("feishu_calendar_event.create", userScopes); // true
|
||||
*
|
||||
* const partialUserScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* checkUserScopes("feishu_calendar_event.create", partialUserScopes); // false
|
||||
* ```
|
||||
*/
|
||||
export function checkUserScopes(toolAction, userGrantedScopes) {
|
||||
const requiredScopes = getRequiredScopes(toolAction);
|
||||
// 如果不需要任何 scope,则总是满足要求
|
||||
if (requiredScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const grantedSet = Array.isArray(userGrantedScopes) ? new Set(userGrantedScopes) : userGrantedScopes;
|
||||
return requiredScopes.every((scope) => grantedSet.has(scope));
|
||||
}
|
||||
/**
|
||||
* 获取用户未授权的 scopes
|
||||
*
|
||||
* @param toolAction - 工具动作键
|
||||
* @param userGrantedScopes - 用户已授权的 scope 集合
|
||||
* @returns 用户未授权的 scope 字符串数组
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userScopes = new Set(["calendar:calendar.event:create"]);
|
||||
* const missing = getMissingUserScopes("feishu_calendar_event.create", userScopes);
|
||||
* // 返回: ["calendar:calendar.event:update"]
|
||||
* ```
|
||||
*/
|
||||
export function getMissingUserScopes(toolAction, userGrantedScopes) {
|
||||
const requiredScopes = getRequiredScopes(toolAction);
|
||||
const grantedSet = Array.isArray(userGrantedScopes) ? new Set(userGrantedScopes) : userGrantedScopes;
|
||||
return requiredScopes.filter((scope) => !grantedSet.has(scope));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Multi-account isolation checks.
|
||||
*
|
||||
* Detects potentially unsafe configurations where multiple Feishu accounts
|
||||
* belonging to different tenants (different appId) share the default agent
|
||||
* without proper isolation via agents + bindings.
|
||||
*/
|
||||
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
import type { LarkAccount } from './types';
|
||||
export type IsolationStatus =
|
||||
/** Single account / same appId / no multi-tenant concern */
|
||||
{
|
||||
mode: 'not-applicable';
|
||||
}
|
||||
/** All accounts have bindings pointing to different agents */
|
||||
| {
|
||||
mode: 'isolated';
|
||||
accounts: LarkAccount[];
|
||||
}
|
||||
/** All accounts have bindings but share the same agent (explicit choice) */
|
||||
| {
|
||||
mode: 'shared-explicit';
|
||||
accounts: LarkAccount[];
|
||||
sharedAgentId: string;
|
||||
}
|
||||
/** Some or all accounts have no bindings — implicit sharing, risky */
|
||||
| {
|
||||
mode: 'shared-implicit';
|
||||
accounts: LarkAccount[];
|
||||
unboundAccounts: LarkAccount[];
|
||||
};
|
||||
/**
|
||||
* Diagnose whether multiple enabled accounts from different tenants
|
||||
* are properly isolated via agent bindings.
|
||||
*/
|
||||
export declare function checkMultiAccountIsolation(cfg: ClawdbotConfig): IsolationStatus;
|
||||
/**
|
||||
* Check whether `session.dmScope` is set to per-account isolation.
|
||||
*
|
||||
* Without this setting, different bots talking to the same user share
|
||||
* the same session — even if agent bindings are configured.
|
||||
*/
|
||||
export declare function needsDmScopeFix(cfg: ClawdbotConfig): boolean;
|
||||
/** Return the fix command string, or null if not needed. */
|
||||
export declare function getDmScopeFixCommand(cfg: ClawdbotConfig): string | null;
|
||||
/**
|
||||
* Generate a combined warning block for doctor / start.
|
||||
* Returns null when everything is fine.
|
||||
*/
|
||||
export declare function formatIsolationWarning(status: IsolationStatus, cfg?: ClawdbotConfig): string | null;
|
||||
/**
|
||||
* Generate `openclaw config set` commands for per-account isolation.
|
||||
*/
|
||||
export declare function generateIsolationFixCommands(cfg: ClawdbotConfig): {
|
||||
commands: string[];
|
||||
preview: string;
|
||||
} | null;
|
||||
/**
|
||||
* Generate commands for explicitly sharing the same agent across accounts.
|
||||
*/
|
||||
export declare function generateSharedAgentCommands(cfg: ClawdbotConfig): {
|
||||
commands: string[];
|
||||
preview: string;
|
||||
} | null;
|
||||
export declare function collectIsolationWarnings(_cfg: ClawdbotConfig): string[];
|
||||
export declare function emitSecurityWarnings(_cfg: ClawdbotConfig, _logger: {
|
||||
warn?: (msg: string) => void;
|
||||
info?: (msg: string) => void;
|
||||
}): void;
|
||||
@@ -0,0 +1,175 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Multi-account isolation checks.
|
||||
*
|
||||
* Detects potentially unsafe configurations where multiple Feishu accounts
|
||||
* belonging to different tenants (different appId) share the default agent
|
||||
* without proper isolation via agents + bindings.
|
||||
*/
|
||||
import { getEnabledLarkAccounts } from './accounts';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check logic
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Diagnose whether multiple enabled accounts from different tenants
|
||||
* are properly isolated via agent bindings.
|
||||
*/
|
||||
export function checkMultiAccountIsolation(cfg) {
|
||||
const accounts = getEnabledLarkAccounts(cfg);
|
||||
if (accounts.length <= 1)
|
||||
return { mode: 'not-applicable' };
|
||||
const appIds = new Set(accounts.map((a) => (a.configured ? a.appId : undefined)).filter((id) => !!id));
|
||||
if (appIds.size <= 1)
|
||||
return { mode: 'not-applicable' };
|
||||
const feishuBindings = cfg.bindings?.filter((b) => b.match?.channel === 'feishu' && b.match?.accountId);
|
||||
if (!feishuBindings || feishuBindings.length === 0) {
|
||||
return { mode: 'shared-implicit', accounts, unboundAccounts: accounts };
|
||||
}
|
||||
const boundAccountIds = new Set(feishuBindings.map((b) => b.match.accountId));
|
||||
const unboundAccounts = accounts.filter((a) => !boundAccountIds.has(a.accountId));
|
||||
if (unboundAccounts.length > 0) {
|
||||
return { mode: 'shared-implicit', accounts, unboundAccounts };
|
||||
}
|
||||
const agentIds = new Set(feishuBindings.map((b) => b.agentId));
|
||||
if (agentIds.size === 1) {
|
||||
return {
|
||||
mode: 'shared-explicit',
|
||||
accounts,
|
||||
sharedAgentId: agentIds.values().next().value,
|
||||
};
|
||||
}
|
||||
return { mode: 'isolated', accounts };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function accountNames(accounts) {
|
||||
return accounts.map((a) => a.name ?? a.accountId).join('、');
|
||||
}
|
||||
function isMultiTenant(cfg) {
|
||||
const accounts = getEnabledLarkAccounts(cfg);
|
||||
if (accounts.length <= 1)
|
||||
return false;
|
||||
const appIds = new Set(accounts.map((a) => (a.configured ? a.appId : undefined)).filter((id) => !!id));
|
||||
return appIds.size > 1;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session dmScope
|
||||
// ---------------------------------------------------------------------------
|
||||
const RECOMMENDED_DM_SCOPE = 'per-account-channel-peer';
|
||||
/**
|
||||
* Check whether `session.dmScope` is set to per-account isolation.
|
||||
*
|
||||
* Without this setting, different bots talking to the same user share
|
||||
* the same session — even if agent bindings are configured.
|
||||
*/
|
||||
export function needsDmScopeFix(cfg) {
|
||||
if (!isMultiTenant(cfg))
|
||||
return false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return cfg.session?.dmScope !== RECOMMENDED_DM_SCOPE;
|
||||
}
|
||||
/** Return the fix command string, or null if not needed. */
|
||||
export function getDmScopeFixCommand(cfg) {
|
||||
if (!needsDmScopeFix(cfg))
|
||||
return null;
|
||||
return `openclaw config set session.dmScope "${RECOMMENDED_DM_SCOPE}"`;
|
||||
}
|
||||
/** User-facing dmScope warning block (markdown). */
|
||||
function formatDmScopeWarning() {
|
||||
return ('⚠️ **私聊消息串混**\n\n' +
|
||||
'当同一个用户同时使用多个机器人时,不同机器人的私聊消息会混在同一段对话里,' +
|
||||
'导致 AI 无法区分用户在跟哪个机器人说话。');
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Warning text for /feishu doctor & /feishu start
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Generate a combined warning block for doctor / start.
|
||||
* Returns null when everything is fine.
|
||||
*/
|
||||
export function formatIsolationWarning(status, cfg) {
|
||||
const sections = [];
|
||||
// Agent sharing warning
|
||||
if (status.mode === 'shared-implicit') {
|
||||
const names = accountNames(status.accounts);
|
||||
sections.push(`⚠️ **多个机器人共用记忆,对话内容可能互相可见**\n\n` +
|
||||
`当前 ${status.accounts.length} 个飞书机器人(${names})共用同一个 AI 记忆。\n` +
|
||||
`用户 A 跟机器人「${status.accounts[0].name ?? status.accounts[0].accountId}」说的话,` +
|
||||
`可能出现在机器人「${status.accounts[1]?.name ?? status.accounts[1]?.accountId ?? '...'}」的回复中。\n\n` +
|
||||
`👉 发送 **/feishu isolate** 一键查看修复方案`);
|
||||
}
|
||||
// dmScope warning
|
||||
if (cfg && needsDmScopeFix(cfg)) {
|
||||
sections.push(formatDmScopeWarning() + '\n\n' + '👉 发送 **/feishu isolate** 一键查看修复方案');
|
||||
}
|
||||
if (sections.length === 0)
|
||||
return null;
|
||||
return sections.join('\n\n---\n\n');
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix command generation
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Generate `openclaw config set` commands for per-account isolation.
|
||||
*/
|
||||
export function generateIsolationFixCommands(cfg) {
|
||||
const status = checkMultiAccountIsolation(cfg);
|
||||
if (status.mode !== 'shared-implicit')
|
||||
return null;
|
||||
const accounts = status.accounts;
|
||||
const commands = [];
|
||||
const agentsList = accounts.map((a) => ({
|
||||
id: `feishu-${a.accountId}`,
|
||||
name: `飞书 ${a.name ?? a.accountId}`,
|
||||
}));
|
||||
commands.push(`openclaw config set agents.list '${JSON.stringify(agentsList)}' --json`);
|
||||
const bindings = accounts.map((a) => ({
|
||||
match: { channel: 'feishu', accountId: a.accountId },
|
||||
agentId: `feishu-${a.accountId}`,
|
||||
}));
|
||||
commands.push(`openclaw config set bindings '${JSON.stringify(bindings)}' --json`);
|
||||
const dmScopeCmd = getDmScopeFixCommand(cfg);
|
||||
if (dmScopeCmd)
|
||||
commands.push(dmScopeCmd);
|
||||
commands.push('openclaw gateway restart');
|
||||
const previewLines = accounts.map((a) => ` ${a.name ?? a.accountId} → 独立记忆(feishu-${a.accountId})`);
|
||||
return { commands, preview: previewLines.join('\n') };
|
||||
}
|
||||
/**
|
||||
* Generate commands for explicitly sharing the same agent across accounts.
|
||||
*/
|
||||
export function generateSharedAgentCommands(cfg) {
|
||||
const status = checkMultiAccountIsolation(cfg);
|
||||
if (status.mode !== 'shared-implicit')
|
||||
return null;
|
||||
const accounts = status.accounts;
|
||||
const commands = [];
|
||||
const bindings = accounts.map((a) => ({
|
||||
match: { channel: 'feishu', accountId: a.accountId },
|
||||
agentId: 'default',
|
||||
}));
|
||||
commands.push(`openclaw config set bindings '${JSON.stringify(bindings)}' --json`);
|
||||
const dmScopeCmd = getDmScopeFixCommand(cfg);
|
||||
if (dmScopeCmd)
|
||||
commands.push(dmScopeCmd);
|
||||
commands.push('openclaw gateway restart');
|
||||
const previewLines = accounts.map((a) => ` ${a.name ?? a.accountId} → 共用记忆(default)`);
|
||||
return { commands, preview: previewLines.join('\n') };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// collectWarnings adapter (for SDK security.collectWarnings)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function collectIsolationWarnings(_cfg) {
|
||||
// TODO: 产品明确多账号隔离方案后再透出告警
|
||||
return [];
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup log
|
||||
// ---------------------------------------------------------------------------
|
||||
export function emitSecurityWarnings(_cfg, _logger) {
|
||||
// TODO: 产品明确多账号隔离方案后再透出告警
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @internal — test-only reset. */
|
||||
export declare function _resetShutdownHooks(): void;
|
||||
/**
|
||||
* Register a cleanup callback to run during graceful shutdown.
|
||||
*
|
||||
* @param key - Unique identifier for this hook (duplicate keys overwrite).
|
||||
* @param cleanup - Async function to execute on shutdown.
|
||||
* @returns An unregister function — call it when the resource is
|
||||
* released normally (e.g. card streaming completes).
|
||||
*/
|
||||
export declare function registerShutdownHook(key: string, cleanup: () => Promise<void>): () => void;
|
||||
/**
|
||||
* Drain all registered shutdown hooks (best-effort, bounded by deadline).
|
||||
*
|
||||
* @param opts - Optional configuration.
|
||||
* @param opts.deadlineMs - Maximum time to wait for all hooks (default 5000).
|
||||
* @param opts.log - Logger function for progress/error output.
|
||||
*/
|
||||
export declare function drainShutdownHooks(opts?: {
|
||||
deadlineMs?: number;
|
||||
log?: (...args: unknown[]) => void;
|
||||
}): Promise<void>;
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
// SPDX-License-Identifier: MIT
|
||||
/**
|
||||
* Process-level graceful shutdown hook registry.
|
||||
*
|
||||
* Provides a singleton Map of async cleanup callbacks, drained
|
||||
* during graceful shutdown by the channel monitor.
|
||||
*/
|
||||
const hooks = new Map();
|
||||
/** @internal — test-only reset. */
|
||||
export function _resetShutdownHooks() {
|
||||
hooks.clear();
|
||||
}
|
||||
/**
|
||||
* Register a cleanup callback to run during graceful shutdown.
|
||||
*
|
||||
* @param key - Unique identifier for this hook (duplicate keys overwrite).
|
||||
* @param cleanup - Async function to execute on shutdown.
|
||||
* @returns An unregister function — call it when the resource is
|
||||
* released normally (e.g. card streaming completes).
|
||||
*/
|
||||
export function registerShutdownHook(key, cleanup) {
|
||||
hooks.set(key, cleanup);
|
||||
return () => {
|
||||
hooks.delete(key);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Drain all registered shutdown hooks (best-effort, bounded by deadline).
|
||||
*
|
||||
* @param opts - Optional configuration.
|
||||
* @param opts.deadlineMs - Maximum time to wait for all hooks (default 5000).
|
||||
* @param opts.log - Logger function for progress/error output.
|
||||
*/
|
||||
export async function drainShutdownHooks(opts) {
|
||||
if (hooks.size === 0)
|
||||
return;
|
||||
const log = opts?.log;
|
||||
const deadline = opts?.deadlineMs ?? 5000;
|
||||
log?.(`graceful shutdown: draining ${hooks.size} cleanup hook(s)`);
|
||||
const entries = Array.from(hooks.entries());
|
||||
hooks.clear();
|
||||
const promises = entries.map(async ([key, cleanup]) => {
|
||||
try {
|
||||
await cleanup();
|
||||
log?.(`graceful shutdown: hook "${key}" done`);
|
||||
}
|
||||
catch (err) {
|
||||
log?.(`graceful shutdown: hook "${key}" failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, deadline);
|
||||
});
|
||||
await Promise.race([Promise.allSettled(promises).then(() => clearTimeout(timer)), timeoutPromise]);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Feishu target ID parsing and formatting utilities.
|
||||
*
|
||||
* Feishu uses several namespaced identifier prefixes:
|
||||
* - `oc_*` -- chat (group / DM) IDs
|
||||
* - `ou_*` -- open user IDs
|
||||
* - plain alphanumeric strings -- user IDs from the tenant directory
|
||||
*
|
||||
* This module provides helpers to detect, normalise, and format these IDs
|
||||
* for both internal routing and outbound Feishu API calls.
|
||||
*/
|
||||
import type { FeishuIdType } from './types';
|
||||
/**
|
||||
* Detect the Feishu ID type from a raw identifier string.
|
||||
*
|
||||
* Returns `null` when the string does not match any known pattern.
|
||||
*/
|
||||
export declare function detectIdType(id: string): FeishuIdType | null;
|
||||
/**
|
||||
* Strip OpenClaw routing prefixes (`chat:`, `user:`, `open_id:`) from a
|
||||
* raw target string, returning the bare Feishu identifier.
|
||||
*
|
||||
* Returns `null` when the input is empty or falsy.
|
||||
*/
|
||||
export declare function normalizeFeishuTarget(raw: string): string | null;
|
||||
export interface FeishuRouteTarget {
|
||||
target: string;
|
||||
replyToMessageId?: string;
|
||||
threadId?: string;
|
||||
}
|
||||
export declare function parseFeishuRouteTarget(raw: string): FeishuRouteTarget;
|
||||
export declare function encodeFeishuRouteTarget(params: {
|
||||
target: string;
|
||||
replyToMessageId?: string;
|
||||
threadId?: string | number | null;
|
||||
}): string;
|
||||
/**
|
||||
* Add the appropriate OpenClaw routing prefix to a bare Feishu identifier.
|
||||
*
|
||||
* When `type` is omitted, the prefix is inferred via `detectIdType`.
|
||||
*/
|
||||
export declare function formatFeishuTarget(id: string, type?: FeishuIdType): string;
|
||||
/**
|
||||
* Determine the `receive_id_type` query parameter for the Feishu send-message
|
||||
* API based on the target identifier.
|
||||
*/
|
||||
export declare function resolveReceiveIdType(id: string): 'chat_id' | 'open_id' | 'user_id';
|
||||
/**
|
||||
* 规范化 message_id,去除合成后缀(如 `om_xxx:auth-complete` → `om_xxx`)。
|
||||
*/
|
||||
export declare function normalizeMessageId(messageId: string): string;
|
||||
export declare function normalizeMessageId(messageId: string | undefined): string | undefined;
|
||||
/**
|
||||
* Return `true` when a raw string looks like it could be a Feishu target
|
||||
* (either an OpenClaw-tagged form or a native prefix).
|
||||
*/
|
||||
export declare function looksLikeFeishuId(raw: string): boolean;
|
||||
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Feishu target ID parsing and formatting utilities.
|
||||
*
|
||||
* Feishu uses several namespaced identifier prefixes:
|
||||
* - `oc_*` -- chat (group / DM) IDs
|
||||
* - `ou_*` -- open user IDs
|
||||
* - plain alphanumeric strings -- user IDs from the tenant directory
|
||||
*
|
||||
* This module provides helpers to detect, normalise, and format these IDs
|
||||
* for both internal routing and outbound Feishu API calls.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known prefix patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHAT_PREFIX = 'oc_';
|
||||
const OPEN_ID_PREFIX = 'ou_';
|
||||
// Canonical routing prefixes used inside OpenClaw (not Feishu-native).
|
||||
const TAG_CHAT = 'chat:';
|
||||
const TAG_USER = 'user:';
|
||||
const TAG_OPEN_ID = 'open_id:';
|
||||
// Feishu channel prefix (used by SDK for some routing scenarios).
|
||||
const TAG_FEISHU = 'feishu:';
|
||||
const ROUTE_META_FRAGMENT_REPLY_TO = '__feishu_reply_to';
|
||||
const ROUTE_META_FRAGMENT_THREAD_ID = '__feishu_thread_id';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Detect the Feishu ID type from a raw identifier string.
|
||||
*
|
||||
* Returns `null` when the string does not match any known pattern.
|
||||
*/
|
||||
export function detectIdType(id) {
|
||||
if (!id)
|
||||
return null;
|
||||
if (id.startsWith(CHAT_PREFIX))
|
||||
return 'chat_id';
|
||||
if (id.startsWith(OPEN_ID_PREFIX))
|
||||
return 'open_id';
|
||||
// Plain alphanumeric strings (no prefix) are treated as tenant user IDs.
|
||||
if (/^[a-zA-Z0-9]+$/.test(id))
|
||||
return 'user_id';
|
||||
return null;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Strip OpenClaw routing prefixes (`chat:`, `user:`, `open_id:`) from a
|
||||
* raw target string, returning the bare Feishu identifier.
|
||||
*
|
||||
* Returns `null` when the input is empty or falsy.
|
||||
*/
|
||||
export function normalizeFeishuTarget(raw) {
|
||||
if (!raw)
|
||||
return null;
|
||||
const trimmed = parseFeishuRouteTarget(raw).target.trim();
|
||||
if (!trimmed)
|
||||
return null;
|
||||
// Handle Feishu channel prefix (e.g., "feishu:ou_xxx" -> "ou_xxx")
|
||||
if (trimmed.startsWith(TAG_FEISHU)) {
|
||||
const inner = trimmed.slice(TAG_FEISHU.length).trim();
|
||||
if (inner)
|
||||
return inner;
|
||||
}
|
||||
if (trimmed.startsWith(TAG_CHAT))
|
||||
return trimmed.slice(TAG_CHAT.length);
|
||||
if (trimmed.startsWith(TAG_USER))
|
||||
return trimmed.slice(TAG_USER.length);
|
||||
if (trimmed.startsWith(TAG_OPEN_ID))
|
||||
return trimmed.slice(TAG_OPEN_ID.length);
|
||||
return trimmed;
|
||||
}
|
||||
export function parseFeishuRouteTarget(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed)
|
||||
return { target: '' };
|
||||
const hashIndex = trimmed.indexOf('#');
|
||||
if (hashIndex < 0)
|
||||
return { target: trimmed };
|
||||
const target = trimmed.slice(0, hashIndex).trim();
|
||||
const fragment = trimmed.slice(hashIndex + 1).trim();
|
||||
if (!fragment)
|
||||
return { target };
|
||||
const params = new URLSearchParams(fragment);
|
||||
const replyToMessageId = normalizeMessageId(params.get(ROUTE_META_FRAGMENT_REPLY_TO)?.trim() || undefined);
|
||||
const threadId = params.get(ROUTE_META_FRAGMENT_THREAD_ID)?.trim() || undefined;
|
||||
return {
|
||||
target,
|
||||
...(replyToMessageId ? { replyToMessageId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
export function encodeFeishuRouteTarget(params) {
|
||||
const target = params.target.trim();
|
||||
if (!target)
|
||||
return target;
|
||||
const replyToMessageId = normalizeMessageId(params.replyToMessageId?.trim() || undefined);
|
||||
const threadId = params.threadId != null && String(params.threadId).trim() !== '' ? String(params.threadId).trim() : undefined;
|
||||
if (!replyToMessageId && !threadId)
|
||||
return target;
|
||||
const fragment = new URLSearchParams();
|
||||
if (replyToMessageId)
|
||||
fragment.set(ROUTE_META_FRAGMENT_REPLY_TO, replyToMessageId);
|
||||
if (threadId)
|
||||
fragment.set(ROUTE_META_FRAGMENT_THREAD_ID, threadId);
|
||||
return `${target}#${fragment.toString()}`;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Add the appropriate OpenClaw routing prefix to a bare Feishu identifier.
|
||||
*
|
||||
* When `type` is omitted, the prefix is inferred via `detectIdType`.
|
||||
*/
|
||||
export function formatFeishuTarget(id, type) {
|
||||
const resolved = type ?? detectIdType(id);
|
||||
if (resolved === 'chat_id')
|
||||
return `${TAG_CHAT}${id}`;
|
||||
return `${TAG_USER}${id}`;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// API receive-ID resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Determine the `receive_id_type` query parameter for the Feishu send-message
|
||||
* API based on the target identifier.
|
||||
*/
|
||||
export function resolveReceiveIdType(id) {
|
||||
if (id.startsWith(CHAT_PREFIX))
|
||||
return 'chat_id';
|
||||
if (id.startsWith(OPEN_ID_PREFIX))
|
||||
return 'open_id';
|
||||
// Default to open_id for any other pattern (safer for outbound API calls).
|
||||
return 'open_id';
|
||||
}
|
||||
export function normalizeMessageId(messageId) {
|
||||
if (!messageId)
|
||||
return undefined;
|
||||
const colonIndex = messageId.indexOf(':');
|
||||
if (colonIndex >= 0)
|
||||
return messageId.slice(0, colonIndex);
|
||||
return messageId;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick predicate
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Return `true` when a raw string looks like it could be a Feishu target
|
||||
* (either an OpenClaw-tagged form or a native prefix).
|
||||
*/
|
||||
export function looksLikeFeishuId(raw) {
|
||||
if (!raw)
|
||||
return false;
|
||||
return (raw.startsWith(TAG_CHAT) ||
|
||||
raw.startsWith(TAG_USER) ||
|
||||
raw.startsWith(TAG_OPEN_ID) ||
|
||||
raw.startsWith(CHAT_PREFIX) ||
|
||||
raw.startsWith(OPEN_ID_PREFIX));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user