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

This commit is contained in:
2026-09-09 16:13:31 +08:00
parent c79d73c0fe
commit 0aa13d3cf0
440 changed files with 51636 additions and 13 deletions
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "audio" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertAudio: ContentConverterFn;
@@ -0,0 +1,24 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "audio" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertAudio = void 0;
const utils_1 = require("./utils.js");
const convertAudio = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const fileKey = parsed?.file_key;
if (!fileKey) {
return { content: '[audio]', resources: [] };
}
const duration = parsed?.duration;
const durationAttr = duration != null ? ` duration="${(0, utils_1.formatDuration)(duration)}"` : '';
return {
content: `<audio key="${fileKey}"${durationAttr}/>`,
resources: [{ type: 'audio', fileKey, duration: duration ?? undefined }],
};
};
exports.convertAudio = convertAudio;
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converters for calendar-related message types:
* - share_calendar_event
* - calendar
* - general_calendar
*/
import type { ContentConverterFn } from './types';
export declare const convertShareCalendarEvent: ContentConverterFn;
export declare const convertCalendar: ContentConverterFn;
export declare const convertGeneralCalendar: ContentConverterFn;
@@ -0,0 +1,56 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converters for calendar-related message types:
* - share_calendar_event
* - calendar
* - general_calendar
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertGeneralCalendar = exports.convertCalendar = exports.convertShareCalendarEvent = void 0;
const utils_1 = require("./utils.js");
function formatCalendarContent(parsed) {
const summary = parsed?.summary ?? '';
const parts = [];
if (summary) {
parts.push(`📅 ${summary}`);
}
const start = parsed?.start_time ? (0, utils_1.millisToDatetime)(parsed.start_time) : '';
const end = parsed?.end_time ? (0, utils_1.millisToDatetime)(parsed.end_time) : '';
if (start && end) {
parts.push(`🕙 ${start} ~ ${end}`);
}
else if (start) {
parts.push(`🕙 ${start}`);
}
return parts.join('\n') || '[calendar event]';
}
const convertShareCalendarEvent = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const inner = formatCalendarContent(parsed);
return {
content: `<calendar_share>${inner}</calendar_share>`,
resources: [],
};
};
exports.convertShareCalendarEvent = convertShareCalendarEvent;
const convertCalendar = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const inner = formatCalendarContent(parsed);
return {
content: `<calendar_invite>${inner}</calendar_invite>`,
resources: [],
};
};
exports.convertCalendar = convertCalendar;
const convertGeneralCalendar = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const inner = formatCalendarContent(parsed);
return {
content: `<calendar>${inner}</calendar>`,
resources: [],
};
};
exports.convertGeneralCalendar = convertGeneralCalendar;
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared helper functions for Feishu content converters.
*/
import type { ApiMessageItem, ConvertContext } from './types';
/** 从 mention 的 id 字段提取 open_id(兼容事件推送的对象格式和 API 响应的字符串格式) */
export declare function extractMentionOpenId(id: unknown): string;
/**
* Build a {@link ConvertContext} from a raw Feishu API message item.
*
* Extracts the `mentions` array that the IM API returns on each message
* item and maps it into the key→MentionInfo / openId→MentionInfo
* structures the converter system expects.
*/
export declare function buildConvertContextFromItem(item: ApiMessageItem, fallbackMessageId: string, accountId?: string): ConvertContext;
/**
* Resolve mention placeholders in text.
*
* - Bot self-mention + stripBotMentions: leading-only strip. When the
* self-mention sits at the very start of the message, drop it (the
* `WasMentioned` envelope field already tells the agent "this message
* was addressed to you", so the anchor is redundant). When it appears
* mid-text, render it as plain `@Name` so the surrounding context still
* reads naturally without leaving an inline anchor the LLM might echo
* back into its reply.
* - Non-bot mentions: replace the placeholder key with readable `@name`.
*/
export declare function resolveMentions(text: string, ctx: ConvertContext): string;
@@ -0,0 +1,82 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared helper functions for Feishu content converters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractMentionOpenId = extractMentionOpenId;
exports.buildConvertContextFromItem = buildConvertContextFromItem;
exports.resolveMentions = resolveMentions;
const user_name_cache_1 = require("../inbound/user-name-cache.js");
const utils_1 = require("./utils.js");
/** 从 mention 的 id 字段提取 open_id(兼容事件推送的对象格式和 API 响应的字符串格式) */
function extractMentionOpenId(id) {
if (typeof id === 'string')
return id;
if (id != null && typeof id === 'object' && 'open_id' in id) {
const openId = id.open_id;
return typeof openId === 'string' ? openId : '';
}
return '';
}
/**
* Build a {@link ConvertContext} from a raw Feishu API message item.
*
* Extracts the `mentions` array that the IM API returns on each message
* item and maps it into the key→MentionInfo / openId→MentionInfo
* structures the converter system expects.
*/
function buildConvertContextFromItem(item, fallbackMessageId, accountId) {
const mentions = new Map();
const mentionsByOpenId = new Map();
for (const m of item.mentions ?? []) {
const openId = extractMentionOpenId(m.id);
if (!openId)
continue;
const info = {
key: m.key,
openId,
name: m.name ?? '',
isBot: false,
};
mentions.set(m.key, info);
mentionsByOpenId.set(openId, info);
}
return {
mentions,
mentionsByOpenId,
messageId: item.message_id ?? fallbackMessageId,
accountId,
resolveUserName: accountId ? (openId) => (0, user_name_cache_1.getUserNameCache)(accountId).get(openId) : undefined,
};
}
/**
* Resolve mention placeholders in text.
*
* - Bot self-mention + stripBotMentions: leading-only strip. When the
* self-mention sits at the very start of the message, drop it (the
* `WasMentioned` envelope field already tells the agent "this message
* was addressed to you", so the anchor is redundant). When it appears
* mid-text, render it as plain `@Name` so the surrounding context still
* reads naturally without leaving an inline anchor the LLM might echo
* back into its reply.
* - Non-bot mentions: replace the placeholder key with readable `@name`.
*/
function resolveMentions(text, ctx) {
if (ctx.mentions.size === 0)
return text;
let result = text;
for (const [key, info] of ctx.mentions) {
if (info.isBot && ctx.stripBotMentions) {
result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key), 'g'), `@${info.name}`);
const leadingPattern = new RegExp(`^\\s*@${(0, utils_1.escapeRegExp)(info.name)}[\\s,:]*`);
result = result.replace(leadingPattern, '').trimStart();
}
else {
result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key), 'g'), `@${info.name}`);
}
}
return result;
}
@@ -0,0 +1,24 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Content converter for Feishu messages.
*
* Each message type (text, post, image, etc.) has a dedicated converter
* function that parses raw JSON content into an AI-friendly text
* representation plus a list of resource descriptors.
*
* This module is a general-purpose message parsing utility — usable
* from inbound handling, outbound formatting, and skills.
*/
import type { ConvertContext, ConvertResult } from './types';
export type { ApiMessageItem, ConvertContext, ConvertResult, ContentConverterFn } from './types';
export { buildConvertContextFromItem, extractMentionOpenId, resolveMentions } from './content-converter-helpers';
/**
* Convert raw message content using the converter for the given message
* type. Falls back to the "unknown" converter for unrecognised types.
*
* Returns a Promise because some converters (e.g. merge_forward) perform
* async operations. Synchronous converters are awaited transparently.
*/
export declare function convertMessageContent(raw: string, messageType: string, ctx: ConvertContext): Promise<ConvertResult>;
@@ -0,0 +1,40 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Content converter for Feishu messages.
*
* Each message type (text, post, image, etc.) has a dedicated converter
* function that parses raw JSON content into an AI-friendly text
* representation plus a list of resource descriptors.
*
* This module is a general-purpose message parsing utility — usable
* from inbound handling, outbound formatting, and skills.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveMentions = exports.extractMentionOpenId = exports.buildConvertContextFromItem = void 0;
exports.convertMessageContent = convertMessageContent;
const index_1 = require("./index.js");
var content_converter_helpers_1 = require("./content-converter-helpers.js");
Object.defineProperty(exports, "buildConvertContextFromItem", { enumerable: true, get: function () { return content_converter_helpers_1.buildConvertContextFromItem; } });
Object.defineProperty(exports, "extractMentionOpenId", { enumerable: true, get: function () { return content_converter_helpers_1.extractMentionOpenId; } });
Object.defineProperty(exports, "resolveMentions", { enumerable: true, get: function () { return content_converter_helpers_1.resolveMentions; } });
// ---------------------------------------------------------------------------
// Convert
// ---------------------------------------------------------------------------
/**
* Convert raw message content using the converter for the given message
* type. Falls back to the "unknown" converter for unrecognised types.
*
* Returns a Promise because some converters (e.g. merge_forward) perform
* async operations. Synchronous converters are awaited transparently.
*/
async function convertMessageContent(raw, messageType, ctx) {
const fn = index_1.converters.get(messageType) ?? index_1.converters.get('unknown');
if (!fn) {
return { content: raw, resources: [] };
}
const nextCtx = ctx.convertMessageContent ? ctx : { ...ctx, convertMessageContent };
return fn(raw, nextCtx);
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "file" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertFile: ContentConverterFn;
@@ -0,0 +1,24 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "file" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertFile = void 0;
const utils_1 = require("./utils.js");
const convertFile = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const fileKey = parsed?.file_key;
if (!fileKey) {
return { content: '[file]', resources: [] };
}
const fileName = parsed?.file_name ?? '';
const nameAttr = fileName ? ` name="${fileName}"` : '';
return {
content: `<file key="${fileKey}"${nameAttr}/>`,
resources: [{ type: 'file', fileKey, fileName: fileName || undefined }],
};
};
exports.convertFile = convertFile;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "folder" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertFolder: ContentConverterFn;
@@ -0,0 +1,24 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "folder" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertFolder = void 0;
const utils_1 = require("./utils.js");
const convertFolder = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const fileKey = parsed?.file_key;
if (!fileKey) {
return { content: '[folder]', resources: [] };
}
const fileName = parsed?.file_name ?? '';
const nameAttr = fileName ? ` name="${fileName}"` : '';
return {
content: `<folder key="${fileKey}"${nameAttr}/>`,
resources: [],
};
};
exports.convertFolder = convertFolder;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "hongbao" (red packet) message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertHongbao: ContentConverterFn;
@@ -0,0 +1,20 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "hongbao" (red packet) message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertHongbao = void 0;
const utils_1 = require("./utils.js");
const convertHongbao = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const text = parsed?.text;
const textAttr = text ? ` text="${text}"` : '';
return {
content: `<hongbao${textAttr}/>`,
resources: [],
};
};
exports.convertHongbao = convertHongbao;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "image" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertImage: ContentConverterFn;
@@ -0,0 +1,22 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "image" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertImage = void 0;
const utils_1 = require("./utils.js");
const convertImage = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const imageKey = parsed?.image_key;
if (!imageKey) {
return { content: '[image]', resources: [] };
}
return {
content: `![image](${imageKey})`,
resources: [{ type: 'image', fileKey: imageKey }],
};
};
exports.convertImage = convertImage;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Content converter mapping for all Feishu message types.
*/
import type { ContentConverterFn } from './types';
export declare const converters: ReadonlyMap<string, ContentConverterFn>;
@@ -0,0 +1,53 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Content converter mapping for all Feishu message types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.converters = void 0;
const text_1 = require("./text.js");
const post_1 = require("./post.js");
const image_1 = require("./image.js");
const file_1 = require("./file.js");
const audio_1 = require("./audio.js");
const video_1 = require("./video.js");
const sticker_1 = require("./sticker.js");
const index_1 = require("./interactive/index.js");
const share_1 = require("./share.js");
const location_1 = require("./location.js");
const merge_forward_1 = require("./merge-forward.js");
const folder_1 = require("./folder.js");
const system_1 = require("./system.js");
const hongbao_1 = require("./hongbao.js");
const calendar_1 = require("./calendar.js");
const video_chat_1 = require("./video-chat.js");
const todo_1 = require("./todo.js");
const vote_1 = require("./vote.js");
const unknown_1 = require("./unknown.js");
exports.converters = new Map([
['text', text_1.convertText],
['post', post_1.convertPost],
['image', image_1.convertImage],
['file', file_1.convertFile],
['audio', audio_1.convertAudio],
['video', video_1.convertVideo],
['media', video_1.convertVideo],
['sticker', sticker_1.convertSticker],
['interactive', index_1.convertInteractive],
['share_chat', share_1.convertShareChat],
['share_user', share_1.convertShareUser],
['location', location_1.convertLocation],
['merge_forward', merge_forward_1.convertMergeForward],
['folder', folder_1.convertFolder],
['system', system_1.convertSystem],
['hongbao', hongbao_1.convertHongbao],
['share_calendar_event', calendar_1.convertShareCalendarEvent],
['calendar', calendar_1.convertCalendar],
['general_calendar', calendar_1.convertGeneralCalendar],
['video_chat', video_chat_1.convertVideoChat],
['todo', todo_1.convertTodo],
['vote', vote_1.convertVote],
['unknown', unknown_1.convertUnknown],
]);
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { ConvertCardResult, Obj, RawCardContent } from './types';
export declare const MODE: {
readonly Concise: 0;
readonly Detailed: 1;
};
type Mode = (typeof MODE)[keyof typeof MODE];
export declare class CardConverter {
private mode;
private attachment;
constructor(mode: Mode);
convert(input: RawCardContent): ConvertCardResult;
private extractBody;
private extractHeaderTitle;
private convertBody;
convertElements(elements: unknown[], depth: number): string;
convertElement(elem: Obj, depth: number): string;
extractProperty(elem: Obj): Obj;
extractTextContent(textElem: unknown): string;
private extractTextFromProperty;
convertPlainText(prop: Obj): string;
convertMarkdown(prop: Obj): string;
convertMarkdownV1(elem: Obj, prop: Obj): string;
convertMarkdownElements(elements: unknown[]): string;
convertDiv(prop: Obj, _id: string): string;
convertNote(prop: Obj): string;
convertLink(prop: Obj): string;
convertEmoji(prop: Obj): string;
convertLocalDatetime(prop: Obj): string;
convertList(prop: Obj): string;
convertBlockquote(prop: Obj): string;
convertCodeBlock(prop: Obj): string;
convertCodeSpan(prop: Obj): string;
convertHeading(prop: Obj): string;
convertFallbackText(prop: Obj): string;
convertTextTag(prop: Obj): string;
convertNumberTag(prop: Obj): string;
convertUnknown(prop: Obj | undefined, tag: string): string;
convertColumnSet(prop: Obj, depth: number): string;
convertColumn(prop: Obj, depth: number): string;
convertForm(prop: Obj, _id: string): string;
convertCollapsiblePanel(prop: Obj, _id: string): string;
convertInteractiveContainer(prop: Obj, _id: string): string;
convertRepeat(prop: Obj): string;
convertButton(prop: Obj, _id: string): string;
convertActions(prop: Obj): string;
convertSelect(prop: Obj, _id: string, isMulti: boolean): string;
convertSelectImg(prop: Obj, _id: string): string;
convertInput(prop: Obj, _id: string): string;
convertDatePicker(prop: Obj, _id: string, pickerType: string): string;
convertChecker(prop: Obj, _id: string): string;
convertOverflow(prop: Obj): string;
convertPerson(prop: Obj, _id: string): string;
convertPersonV1(prop: Obj, _id: string): string;
convertPersonList(prop: Obj): string;
convertAvatar(prop: Obj, _id: string): string;
convertAt(prop: Obj): string;
convertImage(prop: Obj, _id: string): string;
convertImgCombination(prop: Obj): string;
convertChart(prop: Obj, _id: string): string;
private extractChartSummary;
private extractLineBarSummary;
private extractPieSummary;
private extractGenericSummary;
convertAudio(prop: Obj, _id: string): string;
convertVideo(prop: Obj, _id: string): string;
convertTable(prop: Obj): string;
private extractTableCellValue;
private extractTextStyle;
private applyTextStyle;
private getImageToken;
}
export {};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Utility functions for card content conversion.
*/
export declare function escapeAttr(s: string): string;
export declare function formatMillisecondsToISO8601(milliseconds: string): string;
export declare function normalizeTimeFormat(input: string): string;
@@ -0,0 +1,47 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Utility functions for card content conversion.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.escapeAttr = escapeAttr;
exports.formatMillisecondsToISO8601 = formatMillisecondsToISO8601;
exports.normalizeTimeFormat = normalizeTimeFormat;
function escapeAttr(s) {
return s.replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function formatMillisecondsToISO8601(milliseconds) {
const ms = parseInt(milliseconds, 10);
if (isNaN(ms))
return '';
return new Date(ms).toISOString();
}
function normalizeTimeFormat(input) {
if (!input)
return '';
const num = parseInt(input, 10);
if (!isNaN(num) && String(num) === input.trim()) {
if (input.length >= 13) {
return new Date(num).toISOString();
}
else if (input.length >= 10) {
return new Date(num * 1000).toISOString();
}
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(input)) {
return input;
}
const dtMatch = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)$/.exec(input);
if (dtMatch) {
const d = new Date(`${dtMatch[1]}T${dtMatch[2]}`);
if (!isNaN(d.getTime()))
return d.toISOString();
}
if (/^\d{4}-\d{2}-\d{2}$/.test(input))
return input;
if (/^\d{2}:\d{2}(:\d{2})?$/.test(input))
return input;
return input;
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Entry point for the interactive (card) message converter.
*/
import type { ContentConverterFn } from '../types';
export declare const convertInteractive: ContentConverterFn;
@@ -0,0 +1,25 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Entry point for the interactive (card) message converter.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertInteractive = void 0;
const utils_1 = require("../utils.js");
const card_converter_1 = require("./card-converter.js");
const legacy_1 = require("./legacy.js");
const convertInteractive = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
if (!parsed) {
return { content: '[interactive card]', resources: [] };
}
if (typeof parsed.json_card === 'string') {
const converter = new card_converter_1.CardConverter(card_converter_1.MODE.Concise);
const result = converter.convert(parsed);
return { content: result.content || '[interactive card]', resources: [] };
}
return (0, legacy_1.convertLegacyCard)(parsed);
};
exports.convertInteractive = convertInteractive;
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy card converter for non-raw_card_content format.
*/
import type { Obj } from './types';
export declare function convertLegacyCard(parsed: Obj): {
content: string;
resources: never[];
};
@@ -0,0 +1,60 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Legacy card converter for non-raw_card_content format.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertLegacyCard = convertLegacyCard;
function convertLegacyCard(parsed) {
const texts = [];
const header = parsed.header;
if (header) {
const title = header.title;
if (title && typeof title.content === 'string') {
texts.push(`**${title.content}**`);
}
}
const body = parsed.body;
const elements = (parsed.elements ?? body?.elements ?? []);
extractTexts(elements, texts);
const content = texts.length > 0 ? texts.join('\n') : '[interactive card]';
return { content, resources: [] };
}
function extractTexts(elements, out) {
if (!Array.isArray(elements))
return;
for (const el of elements) {
if (typeof el !== 'object' || el == null)
continue;
const elem = el;
if (elem.tag === 'markdown' && typeof elem.content === 'string') {
out.push(elem.content);
continue;
}
if (elem.tag === 'div' || elem.tag === 'plain_text' || elem.tag === 'lark_md') {
const text = elem.text;
if (text?.content && typeof text.content === 'string') {
out.push(text.content);
}
if (typeof elem.content === 'string') {
out.push(elem.content);
}
}
if (elem.tag === 'column_set') {
const columns = elem.columns;
if (columns) {
for (const col of columns) {
const colObj = col;
if (colObj.elements) {
extractTexts(colObj.elements, out);
}
}
}
}
if (elem.elements) {
extractTexts(elem.elements, out);
}
}
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Types and constants for the interactive (card) converter.
*/
export type Obj = Record<string, unknown>;
export interface RawCardContent {
json_card: string;
json_attachment?: string;
card_schema?: number;
}
export interface ConvertCardResult {
content: string;
schema: number;
}
export interface TextStyle {
bold: boolean;
italic: boolean;
strikethrough: boolean;
}
export declare const EMOJI_MAP: Record<string, string>;
export declare const CHART_TYPE_NAMES: Record<string, string>;
@@ -0,0 +1,27 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Types and constants for the interactive (card) converter.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CHART_TYPE_NAMES = exports.EMOJI_MAP = void 0;
exports.EMOJI_MAP = {
OK: '👌',
THUMBSUP: '👍',
SMILE: '😊',
HEART: '❤️',
CLAP: '👏',
FIRE: '🔥',
PARTY: '🎉',
THINK: '🤔',
};
exports.CHART_TYPE_NAMES = {
bar: '柱状图',
line: '折线图',
pie: '饼图',
area: '面积图',
radar: '雷达图',
scatter: '散点图',
};
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "location" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertLocation: ContentConverterFn;
@@ -0,0 +1,23 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "location" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertLocation = void 0;
const utils_1 = require("./utils.js");
const convertLocation = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const name = parsed?.name ?? '';
const lat = parsed?.latitude ?? '';
const lng = parsed?.longitude ?? '';
const nameAttr = name ? ` name="${name}"` : '';
const coordsAttr = lat && lng ? ` coords="lat:${lat},lng:${lng}"` : '';
return {
content: `<location${nameAttr}${coordsAttr}/>`,
resources: [],
};
};
exports.convertLocation = convertLocation;
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "merge_forward" message type.
*
* Unlike other converters this is async — it fetches sub-messages via
* the Feishu IM API and recursively expands nested merge_forward messages.
*
* The API returns ALL nested sub-messages in a single flat `items`
* array with `upper_message_id` pointing to the parent container.
* We build a tree from this flat list and recursively format it —
* only one API call is needed regardless of nesting depth.
*
* This module is a pure "data → format" converter: all API capabilities
* (`fetchSubMessages`, `batchResolveNames`, `resolveUserName`) are
* injected via callbacks in `ConvertContext`. Callers are responsible
* for creating the appropriate callbacks (UAT / TAT / event push).
*/
import type { ContentConverterFn } from './types';
/**
* Recursively expand a merge_forward message.
*
* Output format aligns with the Go reference implementation:
* ```
* <forwarded_messages>
* [RFC3339] sender_id:
* message content
* </forwarded_messages>
* ```
*/
export declare const convertMergeForward: ContentConverterFn;
@@ -0,0 +1,235 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "merge_forward" message type.
*
* Unlike other converters this is async — it fetches sub-messages via
* the Feishu IM API and recursively expands nested merge_forward messages.
*
* The API returns ALL nested sub-messages in a single flat `items`
* array with `upper_message_id` pointing to the parent container.
* We build a tree from this flat list and recursively format it —
* only one API call is needed regardless of nesting depth.
*
* This module is a pure "data → format" converter: all API capabilities
* (`fetchSubMessages`, `batchResolveNames`, `resolveUserName`) are
* injected via callbacks in `ConvertContext`. Callers are responsible
* for creating the appropriate callbacks (UAT / TAT / event push).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertMergeForward = void 0;
const lark_logger_1 = require("../../core/lark-logger.js");
const content_converter_helpers_1 = require("./content-converter-helpers.js");
const log = (0, lark_logger_1.larkLogger)('converters/merge-forward');
/**
* Recursively expand a merge_forward message.
*
* Output format aligns with the Go reference implementation:
* ```
* <forwarded_messages>
* [RFC3339] sender_id:
* message content
* </forwarded_messages>
* ```
*/
const convertMergeForward = async (_raw, ctx) => {
const { accountId, messageId, resolveUserName, batchResolveNames, fetchSubMessages, convertMessageContent } = ctx;
if (!fetchSubMessages) {
return { content: '<forwarded_messages/>', resources: [] };
}
const content = await expand(accountId, messageId, resolveUserName, batchResolveNames, fetchSubMessages, convertMessageContent);
return { content, resources: [] };
};
exports.convertMergeForward = convertMergeForward;
// ---------------------------------------------------------------------------
// Single-API-call expansion with tree building
// ---------------------------------------------------------------------------
async function expand(accountId, messageId, resolveUserName, batchResolveNames, fetchSubMessages, convertContent) {
// --- Phase 1: Fetch (single API call via callback) ---
let items;
try {
items = await fetchSubMessages(messageId);
}
catch (error) {
log.error('fetch sub-messages failed', {
messageId,
error: error instanceof Error ? error.message : String(error),
});
return '<forwarded_messages/>';
}
if (items.length === 0) {
return '<forwarded_messages/>';
}
// --- Phase 2: Build children map ---
const childrenMap = buildChildrenMap(items, messageId);
// --- Phase 2.5: Batch resolve sender names (via callback) ---
const senderIds = collectSenderIds(items, messageId);
if (senderIds.length > 0 && batchResolveNames) {
try {
await batchResolveNames(senderIds);
}
catch (err) {
log.debug('batchResolveNames failed (best-effort)', {
error: err instanceof Error ? err.message : String(err),
});
}
}
// --- Phase 3: Format tree recursively ---
return formatSubTree(messageId, childrenMap, accountId, resolveUserName, convertContent);
}
// ---------------------------------------------------------------------------
// Tree building
// ---------------------------------------------------------------------------
/**
* Build a map from parent message ID → ordered child items.
*
* The API returns a flat `items` array where each item may carry an
* `upper_message_id` pointing to its parent container. Items without
* `upper_message_id` are direct children of the root container.
*
* The root container message itself (matching `rootMessageId`) is skipped.
*/
function buildChildrenMap(items, rootMessageId) {
const map = new Map();
for (const item of items) {
// Skip the root container message itself
if (item.message_id === rootMessageId && !item.upper_message_id) {
continue;
}
const parentId = item.upper_message_id ?? rootMessageId;
let children = map.get(parentId);
if (!children) {
children = [];
map.set(parentId, children);
}
children.push(item);
}
// Sort each group by create_time ascending
for (const children of map.values()) {
children.sort((a, b) => {
const ta = parseInt(String(a.create_time ?? '0'), 10);
const tb = parseInt(String(b.create_time ?? '0'), 10);
return ta - tb;
});
}
return map;
}
// ---------------------------------------------------------------------------
// Sender ID collection
// ---------------------------------------------------------------------------
/**
* Collect all unique sender IDs from non-root items for batch name resolution.
*/
function collectSenderIds(items, rootMessageId) {
const ids = new Set();
for (const item of items) {
// Skip the root container
if (item.message_id === rootMessageId && !item.upper_message_id) {
continue;
}
if (item.sender?.sender_type === 'user') {
const senderId = item.sender.id;
if (senderId) {
ids.add(senderId);
}
}
}
return [...ids];
}
// ---------------------------------------------------------------------------
// Recursive tree formatting
// ---------------------------------------------------------------------------
/**
* Recursively format a sub-tree of messages rooted at `parentId`.
*
* For `merge_forward` children this recurses into `formatSubTree`
* directly (no additional API calls). For other message types it
* delegates to `convertMessageContent`.
*/
async function formatSubTree(parentId, childrenMap, accountId, resolveUserName, convertContent) {
const children = childrenMap.get(parentId);
if (!children || children.length === 0) {
return '<forwarded_messages/>';
}
const parts = [];
for (const item of children) {
try {
const msgType = item.msg_type ?? 'text';
const senderId = item.sender?.id ?? 'unknown';
const createTime = item.create_time ? parseInt(String(item.create_time), 10) : undefined;
const timestamp = createTime ? formatTimestamp(createTime) : 'unknown';
const rawContent = item.body?.content ?? '{}';
let content;
if (msgType === 'merge_forward') {
// Recurse into nested merge_forward via the tree — no API call
const nestedId = item.message_id;
if (nestedId) {
content = await formatSubTree(nestedId, childrenMap, accountId, resolveUserName, convertContent);
}
else {
content = '<forwarded_messages/>';
}
}
else {
// Delegate to the unified converter system.
// Do NOT pass cfg/account here — sub-converters for non-merge_forward
// types don't need it, and passing it would cause nested
// merge_forward to re-enter expand() via convertMessageContent.
const subCtx = {
...(0, content_converter_helpers_1.buildConvertContextFromItem)(item, parentId, accountId),
accountId,
resolveUserName,
convertMessageContent: convertContent,
};
if (!convertContent) {
content = rawContent;
}
else {
content = (await convertContent(rawContent, msgType, subCtx)).content;
}
}
const displayName = resolveUserName?.(senderId) ?? senderId;
const indented = indentLines(content, ' ');
parts.push(`[${timestamp}] ${displayName}:\n${indented}`);
}
catch (err) {
log.warn('failed to convert sub-message', {
messageId: item.message_id,
msgType: item.msg_type ?? 'unknown',
error: err instanceof Error ? err.message : String(err),
});
}
}
if (parts.length === 0) {
return '<forwarded_messages/>';
}
return `<forwarded_messages>\n${parts.join('\n')}\n</forwarded_messages>`;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert a millisecond timestamp to RFC 3339 format with +08:00 offset
* (Beijing time).
*/
function formatTimestamp(ms) {
const date = new Date(ms);
const utcMs = date.getTime() + date.getTimezoneOffset() * 60_000;
const bjDate = new Date(utcMs + 8 * 3600_000);
const y = bjDate.getFullYear();
const mo = String(bjDate.getMonth() + 1).padStart(2, '0');
const d = String(bjDate.getDate()).padStart(2, '0');
const h = String(bjDate.getHours()).padStart(2, '0');
const mi = String(bjDate.getMinutes()).padStart(2, '0');
const s = String(bjDate.getSeconds()).padStart(2, '0');
return `${y}-${mo}-${d}T${h}:${mi}:${s}+08:00`;
}
/** Add a prefix indent to every line of text. */
function indentLines(text, indent) {
return text
.split('\n')
.map((line) => `${indent}${line}`)
.join('\n');
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "post" (rich text) message type.
*
* Preserves structure as Markdown: links as `[text](href)`,
* images as `![image](key)`, code blocks, and mention resolution.
*/
import type { ContentConverterFn } from './types';
export declare const convertPost: ContentConverterFn;
@@ -0,0 +1,139 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "post" (rich text) message type.
*
* Preserves structure as Markdown: links as `[text](href)`,
* images as `![image](key)`, code blocks, and mention resolution.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertPost = void 0;
const content_converter_helpers_1 = require("./content-converter-helpers.js");
const utils_1 = require("./utils.js");
/** Preferred locale order for multi-language post unwrapping. */
const LOCALE_PRIORITY = ['zh_cn', 'en_us', 'ja_jp'];
/**
* Unwrap a parsed post object that may be locale-wrapped.
*
* Feishu post messages come in two shapes:
* - Flat: `{ title, content }`
* - Locale: `{ zh_cn: { title, content }, en_us: { title, content } }`
*/
function unwrapLocale(parsed) {
if ('title' in parsed || 'content' in parsed) {
return parsed;
}
for (const locale of LOCALE_PRIORITY) {
const localeData = parsed[locale];
if (localeData != null && typeof localeData === 'object') {
return localeData;
}
}
const firstKey = Object.keys(parsed)[0];
if (firstKey) {
const firstValue = parsed[firstKey];
if (firstValue != null && typeof firstValue === 'object') {
return firstValue;
}
}
return undefined;
}
const convertPost = (raw, ctx) => {
const rawParsed = (0, utils_1.safeParse)(raw);
if (rawParsed == null || typeof rawParsed !== 'object') {
return { content: '[rich text message]', resources: [] };
}
const parsed = unwrapLocale(rawParsed);
if (!parsed) {
return { content: '[rich text message]', resources: [] };
}
const resources = [];
const lines = [];
// Title
if (parsed.title) {
lines.push(`**${parsed.title}**`, '');
}
const contentBlocks = parsed.content ?? [];
for (const paragraph of contentBlocks) {
if (!Array.isArray(paragraph))
continue;
let line = '';
for (const el of paragraph) {
line += renderElement(el, ctx, resources);
}
lines.push(line);
}
let content = lines.join('\n').trim() || '[rich text message]';
content = (0, content_converter_helpers_1.resolveMentions)(content, ctx);
return { content, resources };
};
exports.convertPost = convertPost;
function renderElement(el, ctx, resources) {
switch (el.tag) {
case 'text': {
let text = el.text ?? '';
text = applyStyle(text, el.style);
return text;
}
case 'a': {
const text = el.text ?? el.href ?? '';
return el.href ? `[${text}](${el.href})` : text;
}
case 'at': {
// At-mention in post — use placeholder key if available via context,
// otherwise fall back to @user_name.
const userId = el.user_id ?? '';
if (userId === 'all')
return '@all';
const name = el.user_name ?? userId;
// O(1) lookup via reverse map
const info = ctx.mentionsByOpenId.get(userId);
if (info) {
// Let resolveMentions handle it — return the placeholder key
return info.key;
}
return `@${name}`;
}
case 'img': {
if (el.image_key) {
resources.push({ type: 'image', fileKey: el.image_key });
return `![image](${el.image_key})`;
}
return '';
}
case 'media': {
if (el.file_key) {
resources.push({ type: 'file', fileKey: el.file_key });
return `<file key="${el.file_key}"/>`;
}
return '';
}
case 'code_block': {
const lang = el.language ?? '';
const code = el.text ?? '';
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
}
case 'hr':
return '\n---\n';
default:
return el.text ?? '';
}
}
function applyStyle(text, style) {
if (!style || style.length === 0)
return text;
let result = text;
if (style.includes('bold'))
result = `**${result}**`;
if (style.includes('italic'))
result = `*${result}*`;
if (style.includes('underline'))
result = `<u>${result}</u>`;
if (style.includes('lineThrough'))
result = `~~${result}~~`;
if (style.includes('codeInline'))
result = `\`${result}\``;
return result;
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "share_chat" and "share_user" message types.
*/
import type { ContentConverterFn } from './types';
export declare const convertShareChat: ContentConverterFn;
export declare const convertShareUser: ContentConverterFn;
@@ -0,0 +1,28 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "share_chat" and "share_user" message types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertShareUser = exports.convertShareChat = void 0;
const utils_1 = require("./utils.js");
const convertShareChat = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const chatId = parsed?.chat_id ?? '';
return {
content: `<group_card id="${chatId}"/>`,
resources: [],
};
};
exports.convertShareChat = convertShareChat;
const convertShareUser = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const userId = parsed?.user_id ?? '';
return {
content: `<contact_card id="${userId}"/>`,
resources: [],
};
};
exports.convertShareUser = convertShareUser;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "sticker" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertSticker: ContentConverterFn;
@@ -0,0 +1,22 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "sticker" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertSticker = void 0;
const utils_1 = require("./utils.js");
const convertSticker = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const fileKey = parsed?.file_key;
if (!fileKey) {
return { content: '[sticker]', resources: [] };
}
return {
content: `<sticker key="${fileKey}"/>`,
resources: [{ type: 'sticker', fileKey }],
};
};
exports.convertSticker = convertSticker;
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "system" message type.
*
* System messages use a template string with placeholders like
* `{from_user}`, `{to_chatters}`, `{divider_text}` that are replaced
* with actual values from the message body.
*/
import type { ContentConverterFn } from './types';
export declare const convertSystem: ContentConverterFn;
@@ -0,0 +1,36 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "system" message type.
*
* System messages use a template string with placeholders like
* `{from_user}`, `{to_chatters}`, `{divider_text}` that are replaced
* with actual values from the message body.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertSystem = void 0;
const utils_1 = require("./utils.js");
const convertSystem = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
if (!parsed?.template) {
return { content: '[system message]', resources: [] };
}
let content = parsed.template;
const replacements = {
'{from_user}': parsed.from_user?.length ? parsed.from_user.filter(Boolean).join(', ') : undefined,
'{to_chatters}': parsed.to_chatters?.length ? parsed.to_chatters.filter(Boolean).join(', ') : undefined,
'{divider_text}': parsed.divider_text?.text,
};
for (const [placeholder, value] of Object.entries(replacements)) {
if (value != null) {
content = content.replaceAll(placeholder, value);
}
else {
content = content.replaceAll(placeholder, '');
}
}
return { content: content.trim(), resources: [] };
};
exports.convertSystem = convertSystem;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "text" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertText: ContentConverterFn;
@@ -0,0 +1,18 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "text" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertText = void 0;
const content_converter_helpers_1 = require("./content-converter-helpers.js");
const utils_1 = require("./utils.js");
const convertText = (raw, ctx) => {
const parsed = (0, utils_1.safeParse)(raw);
const text = parsed?.text ?? raw;
const content = (0, content_converter_helpers_1.resolveMentions)(text, ctx);
return { content, resources: [] };
};
exports.convertText = convertText;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "todo" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertTodo: ContentConverterFn;
@@ -0,0 +1,45 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "todo" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertTodo = void 0;
const utils_1 = require("./utils.js");
/** Extract plain text from post-style content blocks. */
function extractPlainText(content) {
const lines = [];
for (const paragraph of content) {
if (!Array.isArray(paragraph))
continue;
let line = '';
for (const el of paragraph) {
if (el.text)
line += el.text;
}
lines.push(line);
}
return lines.join('\n').trim();
}
const convertTodo = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const parts = [];
// Build title from summary.title and summary.content
const title = parsed?.summary?.title ?? '';
const body = parsed?.summary?.content ? extractPlainText(parsed.summary.content) : '';
const fullTitle = [title, body].filter(Boolean).join('\n');
if (fullTitle) {
parts.push(fullTitle);
}
if (parsed?.due_time) {
parts.push(`Due: ${(0, utils_1.millisToDatetime)(parsed.due_time)}`);
}
const inner = parts.join('\n') || '[todo]';
return {
content: `<todo>\n${inner}\n</todo>`,
resources: [],
};
};
exports.convertTodo = convertTodo;
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared types for the content converter system.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { LarkAccount } from '../../core/types';
import type { MentionInfo, ResourceDescriptor } from '../types';
/**
* Shape of a message item returned by the Feishu IM API.
*
* Used by `buildConvertContextFromItem` (content-converter) and the
* `merge_forward` converter to process sub-messages and history items
* without resorting to `any`.
*/
export interface ApiMessageItem {
message_id?: string;
msg_type?: string;
create_time?: string;
upper_message_id?: string;
body?: {
content?: string;
};
sender?: {
id?: string;
sender_type?: string;
};
mentions?: Array<{
key: string;
id: unknown;
name?: string;
}>;
parent_id?: string;
thread_id?: string;
deleted?: boolean;
updated?: boolean;
}
/** Context passed to every converter function. */
export interface ConvertContext {
/** Map from placeholder key ("@_user_X") to structured mention info. */
mentions: Map<string, MentionInfo>;
/** Reverse map from openId to MentionInfo for O(1) lookup. */
mentionsByOpenId: Map<string, MentionInfo>;
messageId: string;
botOpenId?: string;
/** Plugin config — retained for non-converter downstream consumers. */
cfg?: ClawdbotConfig;
/**
* Pre-resolved account — retained for non-converter downstream consumers.
* merge_forward no longer reads this; it uses injected callbacks instead.
*/
account?: LarkAccount;
/** Account identifier for multi-account setups. */
accountId?: string;
/** Synchronous lookup of cached user display name by openId. */
resolveUserName?: (openId: string) => string | undefined;
/**
* Async batch name resolution callback.
*
* Called by merge_forward to resolve sub-message sender names.
* The callback should populate whatever cache `resolveUserName` reads from.
* All callers must inject this; merge_forward has no internal fallback.
*/
batchResolveNames?: (openIds: string[]) => Promise<void>;
/**
* Async callback to fetch sub-messages of a merge_forward container.
*
* Returns the flat items array from the IM API response.
* All callers must inject this; merge_forward has no internal fallback
* and returns `<forwarded_messages/>` when not provided.
*/
fetchSubMessages?: (messageId: string) => Promise<ApiMessageItem[]>;
/**
* Recursive dispatcher used by converters such as merge_forward.
*
* Injected by `convertMessageContent()` so converter modules do not need
* to import the main dispatcher directly.
*/
convertMessageContent?: (raw: string, messageType: string, ctx: ConvertContext) => Promise<ConvertResult>;
/** 是否删除机器人 mention(事件推送场景=true,历史消息读取=false */
stripBotMentions?: boolean;
}
/** Result produced by a converter function. */
export interface ConvertResult {
/** AI-friendly formatted text. */
content: string;
/** Resource descriptors (images, files, audio, video, stickers). */
resources: ResourceDescriptor[];
}
/**
* Converter function for a single message type.
*
* May return a ConvertResult synchronously or a Promise for types that
* require async operations (e.g. merge_forward expansion via API).
*/
export type ContentConverterFn = (raw: string, ctx: ConvertContext) => ConvertResult | Promise<ConvertResult>;
/**
* Element within a Feishu "post" (rich text) content block.
*
* Shared by the `post` and `todo` converters.
*/
export interface PostElement {
tag: string;
text?: string;
href?: string;
image_key?: string;
file_key?: string;
user_id?: string;
user_name?: string;
style?: string[];
language?: string;
un_escape?: boolean;
}
@@ -0,0 +1,8 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared types for the content converter system.
*/
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Fallback converter for unsupported message types.
*/
import type { ContentConverterFn } from './types';
export declare const convertUnknown: ContentConverterFn;
@@ -0,0 +1,20 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Fallback converter for unsupported message types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertUnknown = void 0;
const utils_1 = require("./utils.js");
const convertUnknown = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
if (parsed != null && typeof parsed === 'object' && 'text' in parsed) {
const text = parsed.text;
if (typeof text === 'string')
return { content: text, resources: [] };
}
return { content: '[unsupported message]', resources: [] };
};
exports.convertUnknown = convertUnknown;
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared utilities for content converters.
*/
/** Escape a string for safe use inside a RegExp. */
export declare function escapeRegExp(str: string): string;
/**
* Safely parse a JSON string, returning undefined on failure.
*/
export declare function safeParse(raw: string): unknown | undefined;
/**
* Format a duration in milliseconds to a human-readable string.
*
* Examples: 1500 → "1.5s", 65000 → "65s"
*/
export declare function formatDuration(ms: number): string;
/**
* Convert a millisecond timestamp to "YYYY-MM-DD HH:mm" in UTC+8 (Beijing time).
*/
export declare function millisToDatetime(ms: string | number): string;
@@ -0,0 +1,57 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared utilities for content converters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.escapeRegExp = escapeRegExp;
exports.safeParse = safeParse;
exports.formatDuration = formatDuration;
exports.millisToDatetime = millisToDatetime;
/** Escape a string for safe use inside a RegExp. */
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Safely parse a JSON string, returning undefined on failure.
*/
function safeParse(raw) {
try {
return JSON.parse(raw);
}
catch {
return undefined;
}
}
/**
* Format a duration in milliseconds to a human-readable string.
*
* Examples: 1500 → "1.5s", 65000 → "65s"
*/
function formatDuration(ms) {
const seconds = ms / 1000;
if (seconds < 1)
return `${ms}ms`;
if (Number.isInteger(seconds))
return `${seconds}s`;
return `${seconds.toFixed(1)}s`;
}
/**
* Convert a millisecond timestamp to "YYYY-MM-DD HH:mm" in UTC+8 (Beijing time).
*/
function millisToDatetime(ms) {
const num = Number(ms);
if (!Number.isFinite(num))
return String(ms);
// UTC+8 offset in milliseconds
const utc8Offset = 8 * 60 * 60 * 1000;
const d = new Date(num + utc8Offset);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
const hour = String(d.getUTCHours()).padStart(2, '0');
const minute = String(d.getUTCMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "video_chat" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertVideoChat: ContentConverterFn;
@@ -0,0 +1,31 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "video_chat" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertVideoChat = void 0;
const utils_1 = require("./utils.js");
const convertVideoChat = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const topic = parsed?.topic ?? '';
const meetingNo = parsed?.meet_number?.trim() ?? '';
const parts = [];
if (topic) {
parts.push(`Topic: ${topic}`);
}
if (parsed?.start_time) {
parts.push(`Start time: ${(0, utils_1.millisToDatetime)(parsed.start_time)}`);
}
if (meetingNo) {
parts.push(`Meeting number: ${meetingNo}`);
}
const inner = parts.join('\n') || '[video chat]';
return {
content: `<meeting>${inner}</meeting>`,
resources: [],
};
};
exports.convertVideoChat = convertVideoChat;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "video" and "media" message types.
*/
import type { ContentConverterFn } from './types';
export declare const convertVideo: ContentConverterFn;
@@ -0,0 +1,35 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "video" and "media" message types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertVideo = void 0;
const utils_1 = require("./utils.js");
const convertVideo = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const fileKey = parsed?.file_key;
if (!fileKey) {
return { content: '[video]', resources: [] };
}
const fileName = parsed?.file_name ?? '';
const duration = parsed?.duration;
const coverKey = parsed?.image_key;
const nameAttr = fileName ? ` name="${fileName}"` : '';
const durationAttr = duration != null ? ` duration="${(0, utils_1.formatDuration)(duration)}"` : '';
return {
content: `<video key="${fileKey}"${nameAttr}${durationAttr}/>`,
resources: [
{
type: 'video',
fileKey,
fileName: fileName || undefined,
duration: duration ?? undefined,
coverImageKey: coverKey ?? undefined,
},
],
};
};
exports.convertVideo = convertVideo;
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "vote" message type.
*/
import type { ContentConverterFn } from './types';
export declare const convertVote: ContentConverterFn;
@@ -0,0 +1,28 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Converter for "vote" message type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertVote = void 0;
const utils_1 = require("./utils.js");
const convertVote = (raw) => {
const parsed = (0, utils_1.safeParse)(raw);
const topic = parsed?.topic ?? '';
const options = parsed?.options ?? [];
const parts = [];
if (topic) {
parts.push(topic);
}
for (const opt of options) {
parts.push(`${opt}`);
}
const inner = parts.join('\n') || '[vote]';
return {
content: `<vote>\n${inner}\n</vote>`,
resources: [],
};
};
exports.convertVote = convertVote;
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reply-routing decisions for the Feishu dispatch path.
*
* In bot-to-bot group scenarios, Feishu will pull thread-style replies into
* a hidden "topic view" that group members cannot see (#32980). The peer bot
* keeps receiving messages but humans in the chat see nothing — a silent
* failure mode that turns bot↔bot chat into a black hole.
*
* This module centralizes the three signals that decide where a reply lands:
* 1. isGroup — DMs never have the topic-view trap
* 2. senderIsBot — only bot→bot triggers it
* 3. dc.isThread — inbound was a thread reply (also inferred from
* root_id in topic groups when threadSession=true)
*
* Output: a routing object consumed by every outbound call site (main
* dispatcher + i18n command card + i18n command text fallback), so the
* three call sites never drift out of sync again.
*/
import type { MentionInfo } from '../types';
import type { DispatchContext } from './dispatch-context';
/** The peer a reply must explicitly @-mention so it actually reaches them. */
export interface BotPeerTarget {
peerOpenId: string;
peerName: string;
}
/**
* Decide which peer (if any) an outbound reply must be guaranteed to
* @-mention, so the deterministic `ensureMention` backstop can wake them up.
*
* Deliberately INDEPENDENT of `suppressForBotPeer` (which only governs
* thread-vs-main routing): the addressee must be resolvable even on a
* human-orchestrated kickoff, where the triggering sender is a person but the
* conversation is meant to continue between bots.
*
* 1. Bot sender → @ the sender back, continuing the exchange.
* 2. Otherwise (e.g. a human kicking off a bot debate) → if the inbound
* message @-mentions exactly ONE party other than ourselves, treat that
* party as the designated peer. Zero / multiple non-self mentions are
* ambiguous, so we add no forced @ (avoids spamming unrelated members).
*
* Group-only: bot-at-bot @ delivery semantics don't apply to DMs.
*/
export declare function resolveBotPeerForMention(params: {
isGroup: boolean;
senderIsBot?: boolean;
senderId?: string;
senderName?: string;
mentions: MentionInfo[];
botOpenId?: string;
}): BotPeerTarget | undefined;
export interface FeishuReplyRouting {
/** Whether to send the reply as a thread-scoped message. */
replyInThread: boolean;
/** Effective thread_id when replying in-thread; undefined otherwise. */
threadId: string | undefined;
/** True when the peer is a bot in a group chat: suppress thread-mode reply
* so the message lands in the main chat (avoiding the hidden topic view,
* #32980). Consumers may also use this signal for additional bot-peer-
* specific behavior (e.g. ensureMention in outbound-mention). */
suppressForBotPeer: boolean;
}
/**
* Resolve reply routing for the current dispatch, performing two tasks:
*
* 1. **Topic-group thread inference (may mutate `dc`).** In topic groups
* (chat_mode=topic), reply events may carry `root_id` without
* `thread_id`. When `threadSession` is enabled and the chat is
* thread-capable, treat `root_id` as a synthetic `threadId` so replies
* stay inside the topic instead of creating a new top-level message.
* This step mutates `dc.isThread` and `dc.ctx.threadId` so subsequent
* code (session-key resolution, sentinel scoping, history scoping)
* observes the same routing decision.
*
* 2. **Reply routing decision (pure).** Computes `replyInThread` and
* `suppressForBotPeer` from the post-inference state. In bot→bot group
* chats `replyInThread` is forced to `false` regardless of the inbound
* shape, preventing the topic-view trap.
*/
export declare function resolveFeishuReplyRouting(dc: DispatchContext, opts?: {
replyInThreadConfig?: boolean;
}): Promise<FeishuReplyRouting>;
@@ -0,0 +1,117 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reply-routing decisions for the Feishu dispatch path.
*
* In bot-to-bot group scenarios, Feishu will pull thread-style replies into
* a hidden "topic view" that group members cannot see (#32980). The peer bot
* keeps receiving messages but humans in the chat see nothing — a silent
* failure mode that turns bot↔bot chat into a black hole.
*
* This module centralizes the three signals that decide where a reply lands:
* 1. isGroup — DMs never have the topic-view trap
* 2. senderIsBot — only bot→bot triggers it
* 3. dc.isThread — inbound was a thread reply (also inferred from
* root_id in topic groups when threadSession=true)
*
* Output: a routing object consumed by every outbound call site (main
* dispatcher + i18n command card + i18n command text fallback), so the
* three call sites never drift out of sync again.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveBotPeerForMention = resolveBotPeerForMention;
exports.resolveFeishuReplyRouting = resolveFeishuReplyRouting;
const chat_info_cache_1 = require("../../core/chat-info-cache.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const log = (0, lark_logger_1.larkLogger)('inbound/bot-content');
/**
* Decide which peer (if any) an outbound reply must be guaranteed to
* @-mention, so the deterministic `ensureMention` backstop can wake them up.
*
* Deliberately INDEPENDENT of `suppressForBotPeer` (which only governs
* thread-vs-main routing): the addressee must be resolvable even on a
* human-orchestrated kickoff, where the triggering sender is a person but the
* conversation is meant to continue between bots.
*
* 1. Bot sender → @ the sender back, continuing the exchange.
* 2. Otherwise (e.g. a human kicking off a bot debate) → if the inbound
* message @-mentions exactly ONE party other than ourselves, treat that
* party as the designated peer. Zero / multiple non-self mentions are
* ambiguous, so we add no forced @ (avoids spamming unrelated members).
*
* Group-only: bot-at-bot @ delivery semantics don't apply to DMs.
*/
function resolveBotPeerForMention(params) {
if (!params.isGroup)
return undefined;
// 1. Bot sender → keep the ping-pong going by @-ing them back.
if (params.senderIsBot && params.senderId) {
return { peerOpenId: params.senderId, peerName: params.senderName ?? params.senderId };
}
// 2. Human-orchestrated kickoff → the single non-self @-mentioned party.
const seen = new Set();
const others = [];
for (const m of params.mentions) {
if (!m.openId || m.isBot || m.openId === params.botOpenId)
continue;
if (seen.has(m.openId))
continue;
seen.add(m.openId);
others.push(m);
}
if (others.length === 1) {
return { peerOpenId: others[0].openId, peerName: others[0].name || others[0].openId };
}
return undefined;
}
/**
* Resolve reply routing for the current dispatch, performing two tasks:
*
* 1. **Topic-group thread inference (may mutate `dc`).** In topic groups
* (chat_mode=topic), reply events may carry `root_id` without
* `thread_id`. When `threadSession` is enabled and the chat is
* thread-capable, treat `root_id` as a synthetic `threadId` so replies
* stay inside the topic instead of creating a new top-level message.
* This step mutates `dc.isThread` and `dc.ctx.threadId` so subsequent
* code (session-key resolution, sentinel scoping, history scoping)
* observes the same routing decision.
*
* 2. **Reply routing decision (pure).** Computes `replyInThread` and
* `suppressForBotPeer` from the post-inference state. In bot→bot group
* chats `replyInThread` is forced to `false` regardless of the inbound
* shape, preventing the topic-view trap.
*/
async function resolveFeishuReplyRouting(dc, opts = {}) {
// Step 1: topic-group thread inference (async + side effects on dc)
if (!dc.isThread &&
dc.isGroup &&
dc.ctx.rootId &&
dc.account.config?.threadSession === true) {
const threadCapable = await (0, chat_info_cache_1.isThreadCapableGroup)({
cfg: dc.accountScopedCfg,
chatId: dc.ctx.chatId,
accountId: dc.account.accountId,
});
if (threadCapable) {
log.info(`inferred thread from root_id=${dc.ctx.rootId} in topic group ${dc.ctx.chatId}`);
dc.isThread = true;
dc.ctx = { ...dc.ctx, threadId: dc.ctx.rootId };
}
}
// Step 2: bot-peer suppression decision (pure read of dc state).
//
// We force a bot→bot reply out of the thread only when it would otherwise
// snowball an *auto-detected* threadReply into a hidden topic view (#32980).
// Two escape hatches keep parity with openclaw core (PR #89783):
// - isTopicSession: a deliberate topic session (threadSession enabled +
// inbound is in a thread) is human-visible by design — keep it threaded.
// - replyInThread config: operators can opt in per-group/account.
const isTopicSession = dc.isThread && dc.account.config?.threadSession === true;
const configReplyInThread = opts.replyInThreadConfig === true;
const suppressForBotPeer = dc.isGroup && !!dc.ctx.senderIsBot && !isTopicSession && !configReplyInThread;
const replyInThread = !suppressForBotPeer && dc.isThread;
const threadId = replyInThread ? dc.ctx.threadId : undefined;
return { replyInThread, threadId, suppressForBotPeer };
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Cross-bot loop guard for bot-at-bot (ping-pong) conversations.
*
* Background: when two different bots @-mention each other in a group, each
* reply wakes the other, which replies again — an endless debate. The
* existing self-echo filter only drops a bot's *own* echo; it does nothing
* for A↔B loops. This module adds a deterministic hard brake: count the
* consecutive turns whose sender is a bot per (chat, thread), and stop
* auto-replying once the count exceeds a cap. Any human turn resets the
* counter, so a new human-driven exchange starts fresh.
*
* State is process-local and best-effort (each bot process keeps its own
* counter for the peer's messages it receives). Idle conversations decay so
* a long-quiet chat doesn't carry a stale count into a new exchange.
*/
/** Max consecutive bot-originated turns before auto-reply is suppressed. */
export declare const MAX_CONSECUTIVE_BOT_TURNS = 10;
/** Idle window after which a conversation's counter is considered stale. */
export declare const BOT_LOOP_IDLE_RESET_MS: number;
export interface BotTurnVerdict {
/** False once the consecutive bot-turn count exceeds the cap. */
allowed: boolean;
/** The current consecutive bot-turn count after this turn. */
count: number;
/** The configured cap, for logging. */
limit: number;
}
/**
* Record one bot-originated turn for the given conversation and decide
* whether the bot should still auto-reply.
*
* Increments the consecutive-bot-turn counter (resetting first if the
* conversation has been idle past the decay window), then returns
* `allowed: false` once the count exceeds {@link MAX_CONSECUTIVE_BOT_TURNS}.
*/
export declare function noteBotTurnAndCheck(chatId: string, threadId?: string, now?: number): BotTurnVerdict;
/**
* Reset the consecutive bot-turn counter for a conversation. Called on every
* human turn so a human stepping in always re-arms the debate budget.
*/
export declare function resetBotLoop(chatId: string, threadId?: string): void;
/** Clear all loop state. Intended for tests. */
export declare function resetAllBotLoops(): void;
/** Number of tracked conversations. Intended for tests. */
export declare function botLoopStateSize(): number;
@@ -0,0 +1,89 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Cross-bot loop guard for bot-at-bot (ping-pong) conversations.
*
* Background: when two different bots @-mention each other in a group, each
* reply wakes the other, which replies again — an endless debate. The
* existing self-echo filter only drops a bot's *own* echo; it does nothing
* for A↔B loops. This module adds a deterministic hard brake: count the
* consecutive turns whose sender is a bot per (chat, thread), and stop
* auto-replying once the count exceeds a cap. Any human turn resets the
* counter, so a new human-driven exchange starts fresh.
*
* State is process-local and best-effort (each bot process keeps its own
* counter for the peer's messages it receives). Idle conversations decay so
* a long-quiet chat doesn't carry a stale count into a new exchange.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.BOT_LOOP_IDLE_RESET_MS = exports.MAX_CONSECUTIVE_BOT_TURNS = void 0;
exports.noteBotTurnAndCheck = noteBotTurnAndCheck;
exports.resetBotLoop = resetBotLoop;
exports.resetAllBotLoops = resetAllBotLoops;
exports.botLoopStateSize = botLoopStateSize;
/** Max consecutive bot-originated turns before auto-reply is suppressed. */
exports.MAX_CONSECUTIVE_BOT_TURNS = 10;
/** Idle window after which a conversation's counter is considered stale. */
exports.BOT_LOOP_IDLE_RESET_MS = 10 * 60 * 1000; // 10 min
// `${chatId}:${threadId ?? ''}` -> consecutive bot-turn state
const states = new Map();
// Timestamp of the last stale-entry sweep, to bound sweep frequency.
let lastSweepAt = 0;
function loopKey(chatId, threadId) {
return `${chatId}:${threadId ?? ''}`;
}
/**
* Evict entries idle past the decay window. Called opportunistically from
* noteBotTurnAndCheck (at most once per idle window) so the Map can't grow
* unbounded for bot-only chats that never see a human turn to reset them.
* Dropping a stale entry is equivalent to leaving it: the next access would
* reset its count to 1 via the freshness check anyway.
*/
function sweepStale(now) {
if (now - lastSweepAt < exports.BOT_LOOP_IDLE_RESET_MS)
return;
lastSweepAt = now;
for (const [key, state] of states) {
if (now - state.updatedAt > exports.BOT_LOOP_IDLE_RESET_MS)
states.delete(key);
}
}
/**
* Record one bot-originated turn for the given conversation and decide
* whether the bot should still auto-reply.
*
* Increments the consecutive-bot-turn counter (resetting first if the
* conversation has been idle past the decay window), then returns
* `allowed: false` once the count exceeds {@link MAX_CONSECUTIVE_BOT_TURNS}.
*/
function noteBotTurnAndCheck(chatId, threadId, now = Date.now()) {
sweepStale(now);
const key = loopKey(chatId, threadId);
const prev = states.get(key);
const fresh = prev && now - prev.updatedAt <= exports.BOT_LOOP_IDLE_RESET_MS;
const count = (fresh ? prev.count : 0) + 1;
states.set(key, { count, updatedAt: now });
return {
allowed: count <= exports.MAX_CONSECUTIVE_BOT_TURNS,
count,
limit: exports.MAX_CONSECUTIVE_BOT_TURNS,
};
}
/**
* Reset the consecutive bot-turn counter for a conversation. Called on every
* human turn so a human stepping in always re-arms the debate budget.
*/
function resetBotLoop(chatId, threadId) {
states.delete(loopKey(chatId, threadId));
}
/** Clear all loop state. Intended for tests. */
function resetAllBotLoops() {
states.clear();
lastSweepAt = 0;
}
/** Number of tracked conversations. Intended for tests. */
function botLoopStateSize() {
return states.size;
}
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Drive comment event context resolution.
*
* Resolves the full context for a `drive.notice.comment_add_v1` event:
* document title, comment quoted text, and reply chain.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuDriveCommentEvent } from '../types';
/** Resolved context for a Drive comment event. */
export interface CommentEventTurn {
/** Document title (best-effort, may be undefined). */
docTitle?: string;
/** File type of the document. */
fileType: string;
/** File token of the document. */
fileToken: string;
/** Root comment ID. */
commentId: string;
/** Reply ID (if this event is a reply). */
replyId?: string;
/** Quoted text from the comment (the content being commented on). */
quotedText?: string;
/** The text of the triggering comment/reply. */
commentText?: string;
/** Reply chain context (previous replies in the thread). */
replyChainContext?: string;
/** Whether the bot was @-mentioned. */
isMentioned?: boolean;
/** Whether the source comment is a whole-document comment. */
isWholeComment?: boolean;
/** Surface prompt for the agent (context + instructions). */
prompt: string;
/** Short preview of the prompt. */
preview: string;
}
/**
* Infer whether a Drive comment thread is a whole-document comment.
*
* The explicit `is_whole` flag is authoritative when present. When the API
* omits it, the root comment's quoted anchor is the best fallback signal:
* whole-document comments have no quote, while anchored comments do.
*
* Note that this inference is about the root thread, so it must behave the
* same for both root-comment events and reply events.
*/
export declare function inferIsWholeComment(params: {
explicitIsWhole?: boolean;
quotedText?: string;
}): boolean;
/**
* Resolve the full context for a Drive comment event.
*
* Fetches document metadata, comment content, and reply chain.
*/
export declare function resolveDriveCommentEventTurn(params: {
cfg: ClawdbotConfig;
event: FeishuDriveCommentEvent;
accountId?: string;
}): Promise<CommentEventTurn | null>;
/**
* Parse the raw webhook payload for a `drive.notice.comment_add_v1` event.
*
* The SDK flattens the v2 envelope, so the event data may be at the
* top level or nested under `event`. User info and timestamp live inside
* `notice_meta` in the real event structure, with fallback to top-level
* fields for compatibility with different SDK flattening styles.
*/
/**
* Normalize a Drive comment event into a canonical shape.
*
* Real event structures vary:
* - **notice_meta style**: file_token, file_type, from_user_id, timestamp
* all live inside `notice_meta`; top-level only has comment_id/reply_id.
* - **SDK-flattened style**: fields may be hoisted to top level.
*
* This parser checks `notice_meta.*` first, then falls back to top-level
* fields, so the handler can consume a single canonical shape.
*/
export declare function parseFeishuDriveCommentNoticeEventPayload(data: unknown): FeishuDriveCommentEvent | null;
@@ -0,0 +1,353 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Drive comment event context resolution.
*
* Resolves the full context for a `drive.notice.comment_add_v1` event:
* document title, comment quoted text, and reply chain.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.inferIsWholeComment = inferIsWholeComment;
exports.resolveDriveCommentEventTurn = resolveDriveCommentEventTurn;
exports.parseFeishuDriveCommentNoticeEventPayload = parseFeishuDriveCommentNoticeEventPayload;
const lark_client_1 = require("../../core/lark-client.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const logger = (0, lark_logger_1.larkLogger)('inbound/comment-context');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Extract plain text from comment element arrays.
*/
function extractElementText(elements) {
if (!Array.isArray(elements))
return '';
return elements
.map((el) => {
if (el.type === 'text_run' && el.text_run?.text)
return el.text_run.text;
if (el.type === 'person' && el.person?.user_id)
return `@${el.person.user_id}`;
if (el.type === 'docs_link' && el.docs_link?.url)
return el.docs_link.url;
// Fallback for simplified element formats
if (el.text)
return el.text;
return '';
})
.join('');
}
/**
* Extract text from a comment reply object.
*/
function extractReplyText(reply) {
if (!reply?.content?.elements)
return '';
return extractElementText(reply.content.elements);
}
/**
* Infer whether a Drive comment thread is a whole-document comment.
*
* The explicit `is_whole` flag is authoritative when present. When the API
* omits it, the root comment's quoted anchor is the best fallback signal:
* whole-document comments have no quote, while anchored comments do.
*
* Note that this inference is about the root thread, so it must behave the
* same for both root-comment events and reply events.
*/
function inferIsWholeComment(params) {
if (typeof params.explicitIsWhole === 'boolean') {
return params.explicitIsWhole;
}
return !params.quotedText?.trim();
}
/**
* Fetch document title via Drive file meta API.
*/
async function fetchDocTitle(params) {
const { cfg, fileToken, fileType, accountId } = params;
try {
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId);
const res = await client.sdk.drive.v1.fileMeta.batchQuery({
data: {
request_docs: [{ doc_token: fileToken, doc_type: fileType }],
with_url: false,
},
});
const meta = res?.data?.metas?.[0];
return meta?.title || undefined;
}
catch (err) {
logger.warn(`failed to fetch doc title: ${err}`);
return undefined;
}
}
/**
* Fetch a single comment with its replies.
*/
async function fetchComment(params) {
const { cfg, fileToken, fileType, commentId, accountId } = params;
try {
const client = lark_client_1.LarkClient.fromCfg(cfg, accountId);
// Paginate through comments to find the target comment.
// Cap at MAX_COMMENT_PAGES to avoid excessive OAPI calls on
// documents with thousands of comments.
const MAX_COMMENT_PAGES = 5; // 5 × 100 = 500 comments max scan
let comment = undefined;
let commentPageToken;
let commentHasMore = true;
let commentPages = 0;
while (commentHasMore && !comment && commentPages < MAX_COMMENT_PAGES) {
commentPages++;
const res = await client.sdk.drive.v1.fileComment.list({
path: { file_token: fileToken },
params: {
file_type: fileType,
page_size: 100,
page_token: commentPageToken,
user_id_type: 'open_id',
},
});
const data = res?.data;
const items = data?.items ?? [];
comment = items.find((c) => c.comment_id === commentId);
commentHasMore = data?.has_more ?? false;
commentPageToken = data?.page_token;
}
if (!comment)
return undefined;
// Fetch complete replies
const replies = [];
let pageToken;
let hasMore = true;
while (hasMore) {
const replyRes = await client.sdk.drive.v1.fileCommentReply.list({
path: { file_token: fileToken, comment_id: commentId },
params: {
file_type: fileType,
page_token: pageToken,
page_size: 50,
user_id_type: 'open_id',
},
});
const replyData = replyRes?.data;
if (replyData?.items) {
replies.push(...replyData.items);
hasMore = replyData.has_more ?? false;
pageToken = replyData.page_token;
}
else {
break;
}
}
return { comment, replies };
}
catch (err) {
logger.warn(`failed to fetch comment: ${err}`);
return undefined;
}
}
// ---------------------------------------------------------------------------
// Surface prompt
// ---------------------------------------------------------------------------
/**
* Build the surface prompt sent to the agent for a Drive comment event.
*
* Includes document context (title, quoted text, comment text) and
* behavioural instructions aligned with the upstream PR.
*/
function buildDriveCommentSurfacePrompt(params) {
const documentLabel = params.documentTitle
? `"${params.documentTitle}"`
: `${params.fileType} document ${params.fileToken}`;
const actionLabel = params.noticeType === 'add_reply' ? 'reply' : 'comment';
const firstLine = params.targetReplyText
? `The user added a ${actionLabel} in ${documentLabel}: ${params.targetReplyText}`
: `The user added a ${actionLabel} in ${documentLabel}.`;
const lines = [firstLine];
if (params.noticeType === 'add_reply' &&
params.rootCommentText &&
params.rootCommentText !== params.targetReplyText) {
lines.push(`Original comment: ${params.rootCommentText}`);
}
if (params.quoteText) {
lines.push(`Quoted content: ${params.quoteText}`);
}
if (params.isMentioned === true) {
lines.push('This comment mentioned you.');
}
lines.push(`Event type: ${params.noticeType}`, `file_token: ${params.fileToken}`, `file_type: ${params.fileType}`, `comment_id: ${params.commentId}`);
if (params.replyId?.trim()) {
lines.push(`reply_id: ${params.replyId.trim()}`);
}
lines.push('This is a Feishu document comment-thread event, not a Feishu IM conversation. Your final text reply will be posted automatically to the current comment thread and will not be sent as an instant message.', 'If you need to inspect or handle the comment thread, prefer the feishu_drive tools: use list_comments / list_comment_replies to inspect comments, and use reply_comment/add_comment to notify the user after modifying the document.', 'If the comment asks you to modify document content, such as adding, inserting, replacing, or deleting text, tables, or headings, you must first use feishu_doc to actually modify the document. Do not reply with only "done", "I\'ll handle it", or a restated plan without calling tools.', 'If the comment quotes document content, that quoted text is usually the edit anchor. For requests like "insert xxx below this content", first locate the position around the quoted content, then use feishu_doc to make the change.', 'If the comment asks you to summarize, explain, rewrite, translate, refine, continue, or review the document content "below", "above", "this paragraph", "this section", or the quoted content, you must also treat the quoted content as the primary target anchor instead of defaulting to the whole document.', 'For requests like "summarize the content below", "explain this section", or "continue writing from here", first locate the relevant document fragment based on the comment\'s quoted content. If the quote is not sufficient to support the answer, then use feishu_doc.read or feishu_doc.list_blocks to read nearby context.', 'Do not guess document content based only on the comment text, and do not output a vague summary before reading enough context. Unless the user explicitly asks to summarize the entire document, default to handling only the local scope related to the quoted content.', 'When document edits are involved, first use feishu_doc.read or feishu_doc.list_blocks to confirm the context, then use feishu_doc writing or updating capabilities to complete the change. After the edit succeeds, notify the user through feishu_drive.reply_comment.', 'If the document edit fails or you cannot locate the anchor, do not pretend it succeeded. Reply clearly in the comment thread with the reason for failure or the missing information.', 'If this is a reading-comprehension task, such as summarization, explanation, or extraction, you may directly output the final answer text after confirming the context. The system will automatically reply with that answer in the current comment thread.', 'When you produce a user-visible reply, keep it in the same language as the user\'s original comment or reply unless they explicitly ask for another language.', 'If you have already completed the user-visible action through feishu_drive.reply_comment or feishu_drive.add_comment, output NO_REPLY at the end to avoid duplicate sending.', 'If the user directly asks a question in the comment and a plain text answer is sufficient, output the answer text directly. The system will automatically reply with your final answer in the current comment thread.', 'If you determine that the current comment does not require any user-visible action, output NO_REPLY at the end.');
lines.push(`Decide what to do next based on this document ${actionLabel} event.`);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Main resolution
// ---------------------------------------------------------------------------
/**
* Resolve the full context for a Drive comment event.
*
* Fetches document metadata, comment content, and reply chain.
*/
async function resolveDriveCommentEventTurn(params) {
const { cfg, event, accountId } = params;
const fileToken = event.file_token;
const fileType = event.file_type ?? 'docx';
const commentId = event.comment_id;
if (!fileToken || !commentId) {
logger.warn('missing file_token or comment_id in comment event');
return null;
}
// Fetch document title and comment context in parallel
const [docTitle, commentData] = await Promise.all([
fetchDocTitle({ cfg, fileToken, fileType, accountId }),
fetchComment({ cfg, fileToken, fileType, commentId, accountId }),
]);
let quotedText;
let commentText;
let replyChainContext;
let isWholeComment = false;
if (commentData) {
// Extract quoted text from the root comment
if (commentData.comment?.quote) {
quotedText = String(commentData.comment.quote);
}
isWholeComment = inferIsWholeComment({
explicitIsWhole: commentData.comment?.is_whole,
quotedText,
});
// Determine the triggering text
if (event.reply_id && commentData.replies.length > 0) {
// This is a reply event — find the specific reply
const targetReply = commentData.replies.find((r) => r.reply_id === event.reply_id);
commentText = targetReply ? extractReplyText(targetReply) : undefined;
// Build reply chain context (all replies before the target)
const chainReplies = commentData.replies.filter((r) => r.reply_id !== event.reply_id);
if (chainReplies.length > 0) {
replyChainContext = chainReplies
.map((r) => {
const sender = r.user_id?.open_id ?? 'unknown';
const text = extractReplyText(r);
return `[${sender}]: ${text}`;
})
.join('\n');
}
}
else {
// This is a root comment event
const rootReply = commentData.comment?.reply_list?.replies?.[0];
commentText = rootReply ? extractReplyText(rootReply) : undefined;
}
}
// Determine notice type and root comment text for prompt building
const noticeType = event.reply_id ? 'add_reply' : 'add_comment';
let rootCommentText;
if (commentData) {
const rootReply = commentData.comment?.reply_list?.replies?.[0];
rootCommentText = rootReply ? extractReplyText(rootReply) : undefined;
}
const isMentioned = event.notice_meta?.is_mentioned ?? event.is_mention;
const prompt = buildDriveCommentSurfacePrompt({
noticeType,
fileType,
fileToken,
commentId,
replyId: event.reply_id,
isMentioned,
documentTitle: docTitle,
quoteText: quotedText,
rootCommentText,
targetReplyText: commentText,
});
const preview = prompt.replace(/\s+/g, ' ').slice(0, 160);
return {
docTitle,
fileType,
fileToken,
commentId,
replyId: event.reply_id,
quotedText,
commentText,
replyChainContext,
isMentioned,
isWholeComment,
prompt,
preview,
};
}
// ---------------------------------------------------------------------------
// Event payload parsing
// ---------------------------------------------------------------------------
/**
* Parse the raw webhook payload for a `drive.notice.comment_add_v1` event.
*
* The SDK flattens the v2 envelope, so the event data may be at the
* top level or nested under `event`. User info and timestamp live inside
* `notice_meta` in the real event structure, with fallback to top-level
* fields for compatibility with different SDK flattening styles.
*/
/**
* Normalize a Drive comment event into a canonical shape.
*
* Real event structures vary:
* - **notice_meta style**: file_token, file_type, from_user_id, timestamp
* all live inside `notice_meta`; top-level only has comment_id/reply_id.
* - **SDK-flattened style**: fields may be hoisted to top level.
*
* This parser checks `notice_meta.*` first, then falls back to top-level
* fields, so the handler can consume a single canonical shape.
*/
function parseFeishuDriveCommentNoticeEventPayload(data) {
if (!data || typeof data !== 'object')
return null;
const raw = data;
// Handle both flattened and nested event formats
const event = (raw.event ?? raw);
// notice_meta is the primary source for most fields in real events
const noticeMeta = (event.notice_meta ?? raw.notice_meta);
// file_token: notice_meta > top-level event > top-level raw
const fileToken = (noticeMeta?.file_token ?? event.file_token ?? raw.file_token);
// file_type: notice_meta > top-level
const fileType = (noticeMeta?.file_type ?? event.file_type ?? raw.file_type);
// comment_id / reply_id are typically at event top-level
const commentId = (event.comment_id ?? raw.comment_id);
const replyId = (event.reply_id ?? raw.reply_id);
if (!fileToken || !commentId)
return null;
// User info: notice_meta.from_user_id > top-level user_id
const metaUserId = noticeMeta?.from_user_id;
const fallbackUserId = (event.user_id ?? raw.user_id);
const userId = metaUserId ?? fallbackUserId;
// Timestamp: notice_meta.timestamp > top-level action_time
const timestamp = (noticeMeta?.timestamp ?? event.action_time ?? raw.action_time);
// Mention flag: notice_meta.is_mentioned > top-level is_mention
const isMentioned = (noticeMeta?.is_mentioned ?? event.is_mention ?? raw.is_mention);
// Build the canonical, normalized event
return {
app_id: (raw.app_id ?? event.app_id),
// Canonical fields — always populated from the best source
file_token: fileToken,
file_type: fileType,
comment_id: commentId,
reply_id: replyId,
// notice_meta preserved for debugging
notice_meta: noticeMeta
? {
from_user_id: userId,
file_token: fileToken,
file_type: fileType,
timestamp,
is_mentioned: isMentioned,
}
: undefined,
// Normalized top-level convenience fields (canonical)
is_mention: isMentioned,
user_id: userId,
action_time: timestamp,
};
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Drive comment event handler for the Lark/Feishu channel plugin.
*
* Handles `drive.notice.comment_add_v1` events by resolving comment
* context, enforcing access policies, building a synthetic
* {@link MessageContext}, and dispatching to the agent.
*
* Modeled after the reaction handler pattern (reaction-handler.ts):
* bypasses the 7-stage message pipeline and dispatches directly.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { FeishuDriveCommentEvent } from '../types';
/**
* Handle a Drive comment event.
*
* Resolves the comment context, checks access policies, builds a
* synthetic MessageContext, and dispatches to the agent.
*/
export declare function handleFeishuCommentEvent(params: {
cfg: ClawdbotConfig;
event: FeishuDriveCommentEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
}): Promise<void>;
@@ -0,0 +1,269 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Drive comment event handler for the Lark/Feishu channel plugin.
*
* Handles `drive.notice.comment_add_v1` events by resolving comment
* context, enforcing access policies, building a synthetic
* {@link MessageContext}, and dispatching to the agent.
*
* Modeled after the reaction handler pattern (reaction-handler.ts):
* bypasses the 7-stage message pipeline and dispatches directly.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleFeishuCommentEvent = handleFeishuCommentEvent;
const crypto = __importStar(require("node:crypto"));
const reply_history_1 = require("openclaw/plugin-sdk/reply-history");
const accounts_1 = require("../../core/accounts.js");
const comment_target_1 = require("../../core/comment-target.js");
const lark_client_1 = require("../../core/lark-client.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const user_name_cache_1 = require("./user-name-cache.js");
const dispatch_1 = require("./dispatch.js");
const gate_1 = require("./gate.js");
const policy_1 = require("./policy.js");
const gate_effects_1 = require("./gate-effects.js");
const comment_context_1 = require("./comment-context.js");
const logger = (0, lark_logger_1.larkLogger)('inbound/comment-handler');
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Handle a Drive comment event.
*
* Resolves the comment context, checks access policies, builds a
* synthetic MessageContext, and dispatches to the agent.
*/
async function handleFeishuCommentEvent(params) {
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
const log = runtime?.log ?? ((...args) => logger.info(args.map(String).join(' ')));
const error = runtime?.error ?? ((...args) => logger.error(args.map(String).join(' ')));
// The parser has already normalized all fields from notice_meta into
// canonical top-level fields, so we just read the canonical shape.
const senderOpenId = event.user_id?.open_id ?? '';
const senderUserId = event.user_id?.user_id;
const senderUnionId = event.user_id?.union_id;
const fileToken = event.file_token ?? '';
const fileType = (event.file_type ?? 'docx');
const commentId = event.comment_id ?? '';
if (!senderOpenId || !fileToken || !commentId) {
log(`feishu[${accountId}]: comment event missing required fields, skipping`);
return;
}
// ---- Self-comment filter ----
// Ignore comments/replies authored by the bot itself.
if (senderOpenId === botOpenId) {
log(`feishu[${accountId}]: ignoring self-authored comment on ${fileToken}`);
return;
}
// ---- Account resolution ----
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const accountFeishuCfg = account.config;
const accountScopedCfg = {
...cfg,
channels: { ...cfg.channels, feishu: accountFeishuCfg },
};
// ---- Access policy enforcement (DM-style) ----
// Comment events are user-to-bot interactions outside of IM, so we apply
// the same dmPolicy (open/allowlist/pairing) as DM messages.
// This mirrors the logic in gate.ts:checkDmGate.
const dmPolicy = accountFeishuCfg?.dmPolicy ?? 'pairing';
if (dmPolicy === 'disabled') {
log(`feishu[${accountId}]: comment event rejected (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== 'open') {
// Read both config allowlist and pairing store (matches gate.ts behavior)
const configAllowFrom = accountFeishuCfg?.allowFrom ?? [];
const storeAllowFrom = await (0, gate_1.readFeishuAllowFromStore)(account.accountId).catch(() => []);
const combinedAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const match = (0, policy_1.resolveFeishuAllowlistMatch)({
allowFrom: combinedAllowFrom,
senderId: senderOpenId,
});
// Also check user_id if available
const userIdMatch = senderUserId
? (0, policy_1.resolveFeishuAllowlistMatch)({
allowFrom: combinedAllowFrom,
senderId: senderUserId,
})
: { allowed: false };
if (!match.allowed && !userIdMatch.allowed) {
if (dmPolicy === 'pairing') {
// Create pairing request and send challenge (mirrors gate.ts:334).
// Prefer replying in the comment thread so the user sees the
// challenge in context; fall back to DM if comment reply fails.
log(`feishu[${accountId}]: comment sender not paired, creating pairing request`);
try {
const core = lark_client_1.LarkClient.runtime;
const { code } = await core.channel.pairing.upsertPairingRequest({
channel: 'feishu',
id: senderOpenId,
accountId: account.accountId,
});
const pairingText = core.channel.pairing.buildPairingReply({
channel: 'feishu',
idLine: senderOpenId,
code,
});
// Try comment thread reply first
let sentInThread = false;
try {
const client = lark_client_1.LarkClient.fromCfg(accountScopedCfg, accountId);
await client.sdk.request({
method: 'POST',
url: `/open-apis/drive/v1/files/${fileToken}/comments/${commentId}/replies`,
params: { file_type: fileType, user_id_type: 'open_id' },
data: {
content: {
elements: [{ type: 'text_run', text_run: { text: pairingText } }],
},
},
});
sentInThread = true;
}
catch {
// Comment reply failed — fall through to DM
}
// Fallback: send to DM
if (!sentInThread) {
await (0, gate_effects_1.sendPairingReply)({
senderId: senderOpenId,
chatId: senderOpenId,
accountId: account.accountId,
accountScopedCfg,
});
}
}
catch (pairingErr) {
log(`feishu[${accountId}]: pairing request failed: ${String(pairingErr)}`);
}
}
else {
log(`feishu[${accountId}]: comment event rejected (dmPolicy=${dmPolicy}, not in allowlist)`);
}
return;
}
}
// ---- Resolve comment context ----
const turn = await (0, comment_context_1.resolveDriveCommentEventTurn)({ cfg, event, accountId });
if (!turn) {
log(`feishu[${accountId}]: failed to resolve comment context, skipping`);
return;
}
// ---- Mention filter ----
// The event-level is_mentioned flag is not stable for Drive comments.
// Fall back to the resolved comment text, where @mentions are normalized
// into "@<open_id>" by extractElementText().
const eventMentioned = event.notice_meta?.is_mentioned ?? event.is_mention;
const textMentioned = Boolean(botOpenId) && Boolean(turn.commentText?.includes(`@${botOpenId}`));
if (eventMentioned !== true && !textMentioned) {
log(`feishu[${accountId}]: comment event not mentioning bot, skipping` +
` (eventFlag=${String(eventMentioned)}, textMention=${textMentioned})`);
return;
}
// ---- Build synthetic MessageContext ----
const commentTarget = (0, comment_target_1.buildFeishuCommentTarget)({
// Whole-document comment threads do not support in-thread replies
// through the replies API. For any event in that thread, fall back
// to creating a new whole-document comment instead.
deliveryMode: turn.isWholeComment ? 'create_whole' : 'reply',
fileType,
fileToken,
commentId,
});
const syntheticMessageId = `comment:${commentId}:${event.reply_id ?? 'root'}:${crypto.randomUUID()}`;
const syntheticText = turn.prompt;
let ctx = {
chatId: commentTarget, // Use comment target as the "chat" identifier
messageId: syntheticMessageId,
senderId: senderOpenId,
chatType: 'p2p', // Comment events are treated as direct interactions
content: syntheticText,
contentType: 'text',
resources: [],
mentions: [],
mentionAll: false,
rawMessage: {
message_id: syntheticMessageId,
chat_id: commentTarget,
chat_type: 'p2p',
message_type: 'text',
content: JSON.stringify({ text: syntheticText }),
create_time: event.action_time ?? String(Date.now()),
},
rawSender: {
sender_id: {
open_id: senderOpenId,
user_id: senderUserId,
union_id: senderUnionId,
},
sender_type: 'user',
},
};
// ---- Sender name resolution ----
const senderResult = await (0, user_name_cache_1.resolveUserName)({ account, openId: senderOpenId, log });
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
log(`feishu[${accountId}]: comment event on ${commentId}` +
`${event.reply_id ? ` (reply ${event.reply_id})` : ''}, dispatching to agent`);
logger.info(`comment event on ${commentId}` +
`${event.reply_id ? ` (reply ${event.reply_id})` : ''}`);
const historyLimit = Math.max(0, accountFeishuCfg?.historyLimit ?? accountScopedCfg.messages?.groupChat?.historyLimit ?? reply_history_1.DEFAULT_GROUP_HISTORY_LIMIT);
// ---- Dispatch to agent ----
try {
await (0, dispatch_1.dispatchToAgent)({
ctx,
permissionError: undefined,
mediaPayload: {},
quotedContent: undefined,
account,
accountScopedCfg,
runtime,
chatHistories,
historyLimit,
replyToMessageId: undefined, // No IM message to reply to
commandAuthorized: false,
skipTyping: true, // No IM typing indicator for comment events
});
}
catch (err) {
error(`feishu[${accountId}]: error dispatching comment event: ${String(err)}`);
}
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* FIFO-based message deduplication.
*
* Feishu WebSocket connections may redeliver messages on reconnect.
* This module tracks recently-seen message IDs and filters duplicates.
*
* Design choices:
* - FIFO eviction (not LRU) — message IDs are write-once/check-once,
* no hot/cold access pattern. FIFO naturally expires the oldest entry
* first, which matches the dedup semantics.
* - ES2015 `Map` preserves insertion order, giving us FIFO for free.
* - Periodic sweep leverages FIFO ordering: iterate from oldest and
* `break` at the first non-expired entry → O(expired), not O(n).
*/
export interface MessageDedupOpts {
/** Time-to-live for each entry in milliseconds (default: 5 min). */
ttlMs?: number;
/** Maximum number of tracked entries (default: 10 000). */
maxEntries?: number;
}
/**
* Check whether a message is too old to process.
*
* Feishu message `create_time` is a millisecond Unix timestamp encoded
* as a string. When a WebSocket reconnects after a long outage, stale
* messages may be redelivered — this function lets callers discard them
* before entering the full handling pipeline.
*/
export declare function isMessageExpired(createTimeStr: string | undefined, expiryMs?: number): boolean;
export declare class MessageDedup {
private readonly store;
private readonly ttlMs;
private readonly maxEntries;
private readonly sweepTimer;
constructor(opts?: MessageDedupOpts);
/**
* Try to record a message ID.
*
* @param id Unique message identifier (e.g. Feishu `message_id`).
* @param scope Optional scope prefix (e.g. accountId) to namespace IDs.
* @returns `true` if the message is **new**; `false` if it is a duplicate.
*/
tryRecord(id: string, scope?: string): boolean;
/** Current number of tracked entries (for diagnostics). */
get size(): number;
/** Remove all entries and stop the periodic sweep. */
clear(): void;
/** Stop the periodic sweep timer and clear all tracked entries. */
dispose(): void;
/**
* Sweep expired entries from the front of the map.
* Because entries are in insertion order (FIFO), we can stop as soon as
* we hit one that hasn't expired yet.
*/
private sweep;
}
@@ -0,0 +1,121 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* FIFO-based message deduplication.
*
* Feishu WebSocket connections may redeliver messages on reconnect.
* This module tracks recently-seen message IDs and filters duplicates.
*
* Design choices:
* - FIFO eviction (not LRU) — message IDs are write-once/check-once,
* no hot/cold access pattern. FIFO naturally expires the oldest entry
* first, which matches the dedup semantics.
* - ES2015 `Map` preserves insertion order, giving us FIFO for free.
* - Periodic sweep leverages FIFO ordering: iterate from oldest and
* `break` at the first non-expired entry → O(expired), not O(n).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageDedup = void 0;
exports.isMessageExpired = isMessageExpired;
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
const DEFAULT_MAX_ENTRIES = 5_000;
const SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// ---------------------------------------------------------------------------
// Message expiry check
// ---------------------------------------------------------------------------
const DEFAULT_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
/**
* Check whether a message is too old to process.
*
* Feishu message `create_time` is a millisecond Unix timestamp encoded
* as a string. When a WebSocket reconnects after a long outage, stale
* messages may be redelivered — this function lets callers discard them
* before entering the full handling pipeline.
*/
function isMessageExpired(createTimeStr, expiryMs = DEFAULT_EXPIRY_MS) {
if (!createTimeStr)
return false;
const createTime = parseInt(createTimeStr, 10);
if (Number.isNaN(createTime))
return false;
return Date.now() - createTime > expiryMs;
}
// ---------------------------------------------------------------------------
// Message deduplication
// ---------------------------------------------------------------------------
class MessageDedup {
store = new Map();
ttlMs;
maxEntries;
sweepTimer;
constructor(opts = {}) {
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
// Periodic sweep — relies on FIFO ordering so we can break early.
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
this.sweepTimer.unref();
}
/**
* Try to record a message ID.
*
* @param id Unique message identifier (e.g. Feishu `message_id`).
* @param scope Optional scope prefix (e.g. accountId) to namespace IDs.
* @returns `true` if the message is **new**; `false` if it is a duplicate.
*/
tryRecord(id, scope) {
const key = scope ? `${scope}:${id}` : id;
const now = Date.now();
const existing = this.store.get(key);
if (existing !== undefined) {
// Entry exists — check TTL.
if (now - existing < this.ttlMs) {
// Still within TTL → duplicate.
return false;
}
// Expired — remove so we can re-insert at the tail (refresh position).
this.store.delete(key);
}
// Enforce capacity via FIFO: drop the oldest entry.
if (this.store.size >= this.maxEntries) {
const oldest = this.store.keys().next().value;
if (oldest !== undefined) {
this.store.delete(oldest);
}
}
this.store.set(key, now);
return true;
}
/** Current number of tracked entries (for diagnostics). */
get size() {
return this.store.size;
}
/** Remove all entries and stop the periodic sweep. */
clear() {
clearInterval(this.sweepTimer);
this.store.clear();
}
/** Stop the periodic sweep timer and clear all tracked entries. */
dispose() {
clearInterval(this.sweepTimer);
this.store.clear();
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/**
* Sweep expired entries from the front of the map.
* Because entries are in insertion order (FIFO), we can stop as soon as
* we hit one that hasn't expired yet.
*/
sweep() {
const now = Date.now();
for (const [key, ts] of this.store) {
if (now - ts < this.ttlMs)
break;
this.store.delete(key);
}
}
}
exports.MessageDedup = MessageDedup;
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Pure construction functions for the agent dispatch pipeline.
*
* All functions in this module are side-effect-free: they build data
* structures (message bodies, envelope payloads, inbound context) but
* never perform I/O, send messages, or mutate external state.
*/
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { MessageContext } from '../types';
import type { LarkClient } from '../../core/lark-client';
import type { DispatchContext } from './dispatch-context';
import type { SentinelEntry } from './sentinel-store';
/**
* Build a `[System: ...]` mention annotation when the message @-mentions
* non-self-bot users or when the previous reply had unresolved mentions.
* Returns `undefined` when there is nothing to report.
*
* Sender identity / chat metadata are handled by the SDK's own
* `buildInboundUserContextPrefix` (via SenderId, SenderName, ReplyToBody,
* InboundHistory, etc.), so we only inject the mention data that the SDK
* does not natively support.
*/
export declare function buildMentionAnnotation(ctx: MessageContext, sentinels?: SentinelEntry[]): string | undefined;
/**
* Pure function: build the annotated message body with optional quote,
* speaker prefix, and mention annotation (for the envelope Body).
*
* Note: message_id and reply_to are now conveyed via system-event tags
* (msg:om_xxx, reply_to:om_yyy) instead of inline annotations, keeping
* the body cleaner and avoiding misleading heuristics for non-text
* message types (merge_forward, interactive cards, etc.).
*/
export declare function buildMessageBody(ctx: MessageContext, quotedContent?: string, sentinels?: SentinelEntry[]): string;
/**
* Build the BodyForAgent value: the clean message content plus an
* optional mention annotation.
*
* SDK >= 2026.2.10 changed the BodyForAgent fallback chain from
* `BodyForAgent ?? Body` to `BodyForAgent ?? CommandBody ?? RawBody ?? Body`,
* so annotations embedded only in Body never reach the AI. Setting
* BodyForAgent explicitly ensures the mention annotation survives.
*
* Sender identity, reply context, and chat history are NOT duplicated
* here — they are injected by the SDK's `buildInboundUserContextPrefix`
* via the standard fields (SenderId, SenderName, ReplyToBody,
* InboundHistory) that we pass in buildInboundPayload.
*
* Note: media file paths are substituted into `ctx.content` upstream
* (handler.ts -> substituteMediaPaths) before this function is called.
* The SDK's `detectAndLoadPromptImages` will discover image paths from
* the text and inject them as multimodal content blocks.
*/
export declare function buildBodyForAgent(ctx: MessageContext, sentinels?: SentinelEntry[]): string;
/**
* Unified call to `finalizeInboundContext`, eliminating the duplicated
* field-mapping between permission notification and main message paths.
*/
export declare function buildInboundPayload(dc: DispatchContext, opts: {
body: string;
bodyForAgent: string;
rawBody: string;
commandBody: string;
originatingTo?: string;
senderName: string;
senderId: string;
messageSid: string;
wasMentioned: boolean;
replyToBody?: string;
inboundHistory?: {
sender: string;
body: string;
timestamp: number;
}[];
extraFields?: Record<string, unknown>;
}): ReturnType<typeof LarkClient.runtime.channel.reply.finalizeInboundContext>;
/**
* Structured identity signals injected into the agent envelope so the LLM
* can tell "who is talking to me" apart — in particular whether the sender
* is a bot, and what the bot's own open_id is.
*
* BotOpenId is omitted when unknown (e.g. startup race before the bot info
* probe completes) to avoid surfacing an empty identity to the agent.
*/
export declare function buildFeishuIdentityFields(ctx: MessageContext, botOpenId?: string): Record<string, unknown>;
/**
* Build the effective group system prompt for a Feishu group chat.
*
* Always prepends bot-at-bot guidance (self-identity + @ semantics + loop
* hygiene) so the agent knows which open_id is itself, how Feishu @-delivery
* works, and when to stop; then appends any operator-configured group
* systemPrompt. Returns `undefined` only when there is nothing to inject.
*/
export declare function buildFeishuGroupSystemPrompt(configured: string | undefined, botOpenId?: string): string | undefined;
/**
* Format the agent envelope and prepend group chat history if applicable.
* Returns the combined body and the history key (undefined for DMs).
*/
export declare function buildEnvelopeWithHistory(dc: DispatchContext, messageBody: string, chatHistories: Map<string, HistoryEntry[]> | undefined, historyLimit: number): {
combinedBody: string;
historyKey: string | undefined;
};
@@ -0,0 +1,241 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Pure construction functions for the agent dispatch pipeline.
*
* All functions in this module are side-effect-free: they build data
* structures (message bodies, envelope payloads, inbound context) but
* never perform I/O, send messages, or mutate external state.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildMentionAnnotation = buildMentionAnnotation;
exports.buildMessageBody = buildMessageBody;
exports.buildBodyForAgent = buildBodyForAgent;
exports.buildInboundPayload = buildInboundPayload;
exports.buildFeishuIdentityFields = buildFeishuIdentityFields;
exports.buildFeishuGroupSystemPrompt = buildFeishuGroupSystemPrompt;
exports.buildEnvelopeWithHistory = buildEnvelopeWithHistory;
const reply_history_1 = require("openclaw/plugin-sdk/reply-history");
const chat_queue_1 = require("../../channel/chat-queue.js");
const mention_1 = require("./mention.js");
// ---------------------------------------------------------------------------
// Mention annotation
// ---------------------------------------------------------------------------
const MENTION_USAGE_HINT = 'To @mention in a reply, use `<at user_id="ou_xxx">Name</at>`; plain "@Name" won\'t notify.';
/**
* Build a `[System: ...]` mention annotation when the message @-mentions
* non-self-bot users or when the previous reply had unresolved mentions.
* Returns `undefined` when there is nothing to report.
*
* Sender identity / chat metadata are handled by the SDK's own
* `buildInboundUserContextPrefix` (via SenderId, SenderName, ReplyToBody,
* InboundHistory, etc.), so we only inject the mention data that the SDK
* does not natively support.
*/
function buildMentionAnnotation(ctx, sentinels) {
// When this bot itself was @-mentioned, tell the agent explicitly. The
// leading self-mention is stripped from the body, so without this the
// agent has no signal that it was the addressee and may mis-attribute
// instructions to another mentioned party.
const selfMention = ctx.mentions.find((m) => m.isBot);
const sections = [
selfMention
? `You (${selfMention.name}, open_id: ${selfMention.openId}) were directly @mentioned in this message; ` +
`the message body is addressed to you.`
: undefined,
formatMentionList((0, mention_1.nonBotMentions)(ctx)),
formatSentinelFeedback(sentinels),
].filter((s) => !!s);
if (sections.length === 0)
return undefined;
sections.push(MENTION_USAGE_HINT);
return `[System: ${sections.join(' ')}]`;
}
function formatMentionList(mentions) {
if (mentions.length === 0)
return undefined;
const details = mentions.map((t) => `${t.name} (open_id: ${t.openId})`).join(', ');
return (`This message @mentions the following users: ${details}. ` +
`Use these open_ids when performing actions involving these users.`);
}
function formatSentinelFeedback(sentinels) {
if (!sentinels || sentinels.length === 0)
return undefined;
const lines = sentinels.map((s) => {
if (s.reason === 'not_found') {
return `"@${s.name}" was not recognized in the chat`;
}
if (s.reason === 'ambiguous' && s.candidates && s.candidates.length > 0) {
const ids = s.candidates.map((c) => c.openId).join(' / ');
return `"@${s.name}" matched multiple users (${ids}); use explicit <at user_id="...">`;
}
return `"@${s.name}" failed to resolve`;
});
return `Previous reply had unresolved mentions: ${lines.join('; ')}.`;
}
// ---------------------------------------------------------------------------
// Message body builders
// ---------------------------------------------------------------------------
/**
* Pure function: build the annotated message body with optional quote,
* speaker prefix, and mention annotation (for the envelope Body).
*
* Note: message_id and reply_to are now conveyed via system-event tags
* (msg:om_xxx, reply_to:om_yyy) instead of inline annotations, keeping
* the body cleaner and avoiding misleading heuristics for non-text
* message types (merge_forward, interactive cards, etc.).
*/
function buildMessageBody(ctx, quotedContent, sentinels) {
let messageBody = ctx.content;
if (quotedContent) {
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
}
const speaker = ctx.senderName ?? ctx.senderId;
messageBody = `${speaker}: ${messageBody}`;
const mentionAnnotation = buildMentionAnnotation(ctx, sentinels);
if (mentionAnnotation) {
messageBody += `\n\n${mentionAnnotation}`;
}
return messageBody;
}
/**
* Build the BodyForAgent value: the clean message content plus an
* optional mention annotation.
*
* SDK >= 2026.2.10 changed the BodyForAgent fallback chain from
* `BodyForAgent ?? Body` to `BodyForAgent ?? CommandBody ?? RawBody ?? Body`,
* so annotations embedded only in Body never reach the AI. Setting
* BodyForAgent explicitly ensures the mention annotation survives.
*
* Sender identity, reply context, and chat history are NOT duplicated
* here — they are injected by the SDK's `buildInboundUserContextPrefix`
* via the standard fields (SenderId, SenderName, ReplyToBody,
* InboundHistory) that we pass in buildInboundPayload.
*
* Note: media file paths are substituted into `ctx.content` upstream
* (handler.ts -> substituteMediaPaths) before this function is called.
* The SDK's `detectAndLoadPromptImages` will discover image paths from
* the text and inject them as multimodal content blocks.
*/
function buildBodyForAgent(ctx, sentinels) {
const mentionAnnotation = buildMentionAnnotation(ctx, sentinels);
if (mentionAnnotation) {
return `${ctx.content}\n\n${mentionAnnotation}`;
}
return ctx.content;
}
// ---------------------------------------------------------------------------
// Inbound payload builder
// ---------------------------------------------------------------------------
/**
* Unified call to `finalizeInboundContext`, eliminating the duplicated
* field-mapping between permission notification and main message paths.
*/
function buildInboundPayload(dc, opts) {
return dc.core.channel.reply.finalizeInboundContext({
// extraFields first — fixed fields below always take precedence
...opts.extraFields,
Body: opts.body,
BodyForAgent: opts.bodyForAgent,
RawBody: opts.rawBody,
CommandBody: opts.commandBody,
From: dc.feishuFrom,
To: dc.feishuTo,
SessionKey: dc.threadSessionKey ?? dc.route.sessionKey,
AccountId: dc.route.accountId,
ChatType: dc.isGroup ? 'group' : 'direct',
GroupSubject: dc.isGroup ? dc.ctx.chatId : undefined,
SenderName: opts.senderName,
SenderId: opts.senderId,
Provider: 'feishu',
Surface: 'feishu',
MessageSid: opts.messageSid,
ReplyToBody: opts.replyToBody,
InboundHistory: opts.inboundHistory,
Timestamp: dc.ctx.createTime ?? Date.now(),
WasMentioned: opts.wasMentioned,
CommandAuthorized: dc.commandAuthorized,
OriginatingChannel: 'feishu',
OriginatingTo: opts.originatingTo ?? dc.feishuTo,
});
}
// ---------------------------------------------------------------------------
// Bot-at-Bot identity & guidance
// ---------------------------------------------------------------------------
/**
* Structured identity signals injected into the agent envelope so the LLM
* can tell "who is talking to me" apart — in particular whether the sender
* is a bot, and what the bot's own open_id is.
*
* BotOpenId is omitted when unknown (e.g. startup race before the bot info
* probe completes) to avoid surfacing an empty identity to the agent.
*/
function buildFeishuIdentityFields(ctx, botOpenId) {
return {
SenderIsBot: ctx.senderIsBot ?? false,
...(botOpenId ? { BotOpenId: botOpenId } : {}),
};
}
/** Static guidance about Feishu's bot-at-bot @ semantics and loop hygiene. */
const FEISHU_BOT_AT_BOT_GUIDANCE = 'On Feishu, another bot only receives a message when you explicitly @-mention it; ' +
'a plain message or a reply without an @ will NOT reach another bot. ' +
'When you need another bot to continue the work, @-mention it. ' +
'When no further action is needed, or you are asked to stop, do not reply — ' +
'this avoids endless bot-to-bot loops.';
/**
* Build the effective group system prompt for a Feishu group chat.
*
* Always prepends bot-at-bot guidance (self-identity + @ semantics + loop
* hygiene) so the agent knows which open_id is itself, how Feishu @-delivery
* works, and when to stop; then appends any operator-configured group
* systemPrompt. Returns `undefined` only when there is nothing to inject.
*/
function buildFeishuGroupSystemPrompt(configured, botOpenId) {
const parts = [];
if (botOpenId) {
parts.push(`Your own Feishu open_id is "${botOpenId}"; any @-mention of this open_id refers to you.`);
}
parts.push(FEISHU_BOT_AT_BOT_GUIDANCE);
const trimmedConfigured = configured?.trim();
if (trimmedConfigured) {
parts.push(trimmedConfigured);
}
const merged = parts.join('\n\n').trim();
return merged || undefined;
}
// ---------------------------------------------------------------------------
// Envelope + history builder
// ---------------------------------------------------------------------------
/**
* Format the agent envelope and prepend group chat history if applicable.
* Returns the combined body and the history key (undefined for DMs).
*/
function buildEnvelopeWithHistory(dc, messageBody, chatHistories, historyLimit) {
const body = dc.core.channel.reply.formatAgentEnvelope({
channel: 'Feishu',
from: dc.envelopeFrom,
timestamp: new Date(),
envelope: dc.envelopeOptions,
body: messageBody,
});
let combinedBody = body;
const historyKey = dc.isGroup ? (0, chat_queue_1.threadScopedKey)(dc.ctx.chatId, dc.isThread ? dc.ctx.threadId : undefined) : undefined;
if (dc.isGroup && historyKey && chatHistories) {
combinedBody = (0, reply_history_1.buildPendingHistoryContextFromMap)({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) => dc.core.channel.reply.formatAgentEnvelope({
channel: 'Feishu',
from: `${dc.ctx.chatId}:${entry.sender}`,
timestamp: entry.timestamp,
body: entry.body,
envelope: dc.envelopeOptions,
}),
});
}
return { combinedBody, historyKey };
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* System command and permission notification dispatch for inbound messages.
*
* Handles control commands (/help, /reset, etc.) via plain-text delivery
* and permission-error notifications via the streaming card flow.
*/
import type { LarkClient } from '../../core/lark-client';
import type { PermissionError } from './permission';
import type { DispatchContext } from './dispatch-context';
/**
* Dispatch a permission-error notification to the agent so it can
* inform the user about the missing Feishu API scope.
*/
export declare function dispatchPermissionNotification(dc: DispatchContext, permissionError: PermissionError, replyToMessageId?: string): Promise<void>;
/**
* Dispatch a system command (/help, /reset, etc.) via plain-text delivery.
* No streaming card, no "Processing..." state.
*/
export declare function dispatchSystemCommand(dc: DispatchContext, ctxPayload: ReturnType<typeof LarkClient.runtime.channel.reply.finalizeInboundContext>, replyToMessageId?: string): Promise<void>;
@@ -0,0 +1,131 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* System command and permission notification dispatch for inbound messages.
*
* Handles control commands (/help, /reset, etc.) via plain-text delivery
* and permission-error notifications via the streaming card flow.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.dispatchPermissionNotification = dispatchPermissionNotification;
exports.dispatchSystemCommand = dispatchSystemCommand;
const lark_logger_1 = require("../../core/lark-logger.js");
const lark_ticket_1 = require("../../core/lark-ticket.js");
const reply_dispatcher_1 = require("../../card/reply-dispatcher.js");
const tool_use_trace_store_1 = require("../../card/tool-use-trace-store.js");
const send_1 = require("../outbound/send.js");
const dispatch_builders_1 = require("./dispatch-builders.js");
const log = (0, lark_logger_1.larkLogger)('inbound/dispatch-commands');
// ---------------------------------------------------------------------------
// Permission error notification
// ---------------------------------------------------------------------------
/**
* Dispatch a permission-error notification to the agent so it can
* inform the user about the missing Feishu API scope.
*/
async function dispatchPermissionNotification(dc, permissionError, replyToMessageId) {
const grantUrl = permissionError.grantUrl ?? '';
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
const permBody = dc.core.channel.reply.formatAgentEnvelope({
channel: 'Feishu',
from: dc.envelopeFrom,
timestamp: new Date(),
envelope: dc.envelopeOptions,
body: permissionNotifyBody,
});
const permCtx = (0, dispatch_builders_1.buildInboundPayload)(dc, {
body: permBody,
bodyForAgent: permissionNotifyBody,
rawBody: permissionNotifyBody,
commandBody: permissionNotifyBody,
senderName: 'system',
senderId: 'system',
messageSid: `${dc.ctx.messageId}:permission-error`,
wasMentioned: false,
});
(0, tool_use_trace_store_1.startToolUseTraceRun)(dc.threadSessionKey ?? dc.route.sessionKey);
const { dispatcher: permDispatcher, replyOptions: permReplyOptions, markDispatchIdle: markPermIdle, markFullyComplete: markPermComplete, } = (0, reply_dispatcher_1.createFeishuReplyDispatcher)({
cfg: dc.accountScopedCfg,
agentId: dc.route.agentId,
chatId: dc.ctx.chatId,
sessionKey: dc.threadSessionKey ?? dc.route.sessionKey,
replyToMessageId: replyToMessageId ?? dc.ctx.messageId,
accountId: dc.account.accountId,
chatType: dc.ctx.chatType,
replyInThread: dc.isThread,
toolUseDisplay: {
mode: 'off',
showToolUse: false,
showToolResultDetails: false,
showFullPaths: false,
},
});
dc.log(`feishu[${dc.account.accountId}]: dispatching permission error notification to agent`);
await dc.core.channel.reply.dispatchReplyFromConfig({
ctx: permCtx,
cfg: dc.accountScopedCfg,
dispatcher: permDispatcher,
replyOptions: permReplyOptions,
});
await permDispatcher.waitForIdle();
markPermComplete();
markPermIdle();
}
// ---------------------------------------------------------------------------
// System command dispatch
// ---------------------------------------------------------------------------
/**
* Dispatch a system command (/help, /reset, etc.) via plain-text delivery.
* No streaming card, no "Processing..." state.
*/
async function dispatchSystemCommand(dc, ctxPayload, replyToMessageId) {
let delivered = false;
const suppressToolDetails = isLifecycleSessionCommand(dc.ctx.content);
dc.log(`feishu[${dc.account.accountId}]: detected system command, using plain-text dispatch`);
log.info('system command detected, plain-text dispatch');
await dc.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: dc.accountScopedCfg,
dispatcherOptions: {
deliver: async (payload, info) => {
if (suppressToolDetails && info.kind === 'tool') {
return;
}
const text = payload.text?.trim() ?? '';
if (!text)
return;
await (0, send_1.sendMessageFeishu)({
cfg: dc.accountScopedCfg,
to: dc.ctx.chatId,
text,
replyToMessageId: replyToMessageId ?? dc.ctx.messageId,
accountId: dc.account.accountId,
replyInThread: dc.isThread,
});
delivered = true;
},
onSkip: (_payload, info) => {
if (info.reason !== 'silent') {
dc.log(`feishu[${dc.account.accountId}]: command reply skipped (reason=${info.reason})`);
}
},
onError: (err, info) => {
dc.error(`feishu[${dc.account.accountId}]: command ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {},
});
dc.log(`feishu[${dc.account.accountId}]: system command dispatched (delivered=${delivered})`);
log.info(`system command dispatched (delivered=${delivered}, elapsed=${(0, lark_ticket_1.ticketElapsed)()}ms)`);
}
function isLifecycleSessionCommand(text) {
if (!text)
return false;
const match = text.trim().match(/^\/([^\s@]+)/);
if (!match)
return false;
const command = match[1]?.toLowerCase();
return command === 'new' || command === 'reset';
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Dispatch context construction for the inbound agent dispatch pipeline.
*
* Derives all shared values needed by downstream dispatch helpers:
* logging, addressing, route resolution, thread session, and system
* event emission.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { MessageContext } from '../types';
import type { LarkAccount } from '../../core/types';
import { LarkClient } from '../../core/lark-client';
export interface DispatchContext {
ctx: MessageContext;
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg: ClawdbotConfig;
account: LarkAccount;
runtime: RuntimeEnv;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
core: typeof LarkClient.runtime;
isGroup: boolean;
isThread: boolean;
feishuFrom: string;
feishuTo: string;
envelopeFrom: string;
envelopeOptions: ReturnType<typeof LarkClient.runtime.channel.reply.resolveEnvelopeFormatOptions>;
route: ReturnType<typeof LarkClient.runtime.channel.routing.resolveAgentRoute>;
threadSessionKey?: string;
commandAuthorized?: boolean;
}
/**
* Provide a safe RuntimeEnv fallback when the caller did not supply one.
* Replaces the previous unsafe `runtime as RuntimeEnv` casts.
*/
export declare function ensureRuntime(runtime: RuntimeEnv | undefined): RuntimeEnv;
/**
* Derive all shared values needed by downstream helpers:
* logging, addressing, route resolution, and system event emission.
*/
export declare function buildDispatchContext(params: {
ctx: MessageContext;
account: LarkAccount;
accountScopedCfg: ClawdbotConfig;
runtime?: RuntimeEnv;
commandAuthorized?: boolean;
}): DispatchContext;
/**
* Resolve thread session key for thread-capable groups.
*
* Returns a thread-scoped session key when ALL conditions are met:
* 1. `threadSession` config is enabled on the account
* 2. The group is a topic group (chat_mode=topic) or uses thread
* message mode (group_message_type=thread)
*
* The group info is fetched via `im.chat.get` with a 1-hour LRU cache
* to minimise OAPI calls.
*/
export declare function resolveThreadSessionKey(params: {
accountScopedCfg: ClawdbotConfig;
account: LarkAccount;
chatId: string;
threadId: string;
baseSessionKey: string;
}): Promise<string | undefined>;
@@ -0,0 +1,153 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Dispatch context construction for the inbound agent dispatch pipeline.
*
* Derives all shared values needed by downstream dispatch helpers:
* logging, addressing, route resolution, thread session, and system
* event emission.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ensureRuntime = ensureRuntime;
exports.buildDispatchContext = buildDispatchContext;
exports.resolveThreadSessionKey = resolveThreadSessionKey;
const routing_1 = require("openclaw/plugin-sdk/routing");
const lark_client_1 = require("../../core/lark-client.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const chat_info_cache_1 = require("../../core/chat-info-cache.js");
const comment_target_1 = require("../../core/comment-target.js");
const log = (0, lark_logger_1.larkLogger)('inbound/dispatch-context');
// ---------------------------------------------------------------------------
// RuntimeEnv fallback
// ---------------------------------------------------------------------------
/**
* Provide a safe RuntimeEnv fallback when the caller did not supply one.
* Replaces the previous unsafe `runtime as RuntimeEnv` casts.
*/
function ensureRuntime(runtime) {
if (runtime)
return runtime;
return {
log: (...args) => log.info(args.map(String).join(' ')),
error: (...args) => log.error(args.map(String).join(' ')),
exit: (code) => process.exit(code),
};
}
// ---------------------------------------------------------------------------
// Context construction
// ---------------------------------------------------------------------------
/**
* Derive all shared values needed by downstream helpers:
* logging, addressing, route resolution, and system event emission.
*/
function buildDispatchContext(params) {
const { ctx, account, accountScopedCfg } = params;
const runtime = ensureRuntime(params.runtime);
const log = runtime.log;
const error = runtime.error;
const isComment = (0, comment_target_1.isCommentTarget)(ctx.chatId);
const isGroup = !isComment && ctx.chatType === 'group';
const isThread = isGroup && Boolean(ctx.threadId);
const core = lark_client_1.LarkClient.runtime;
const feishuFrom = `feishu:${ctx.senderId}`;
// Comment targets use the comment target string directly as the "To"
// so the outbound routing layer can detect it and route through Drive API.
const feishuTo = isComment
? ctx.chatId
: isGroup
? `chat:${ctx.chatId}`
: `user:${ctx.senderId}`;
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderId}` : ctx.senderId;
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(accountScopedCfg);
// ---- Route resolution ----
// Comment targets use the comment target as the peer ID so each
// comment thread gets its own session key.
const route = core.channel.routing.resolveAgentRoute({
cfg: accountScopedCfg,
channel: 'feishu',
accountId: account.accountId,
peer: isComment
? { kind: 'direct', id: ctx.chatId }
: {
kind: isGroup ? 'group' : 'direct',
id: isGroup ? ctx.chatId : ctx.senderId,
},
});
// ---- System event ----
const sender = ctx.senderName ? `${ctx.senderName} (${ctx.senderId})` : ctx.senderId;
const location = isComment ? `comment ${ctx.chatId}` : isGroup ? `group ${ctx.chatId}` : 'DM';
const tags = [];
tags.push(`msg:${ctx.messageId}`);
if (ctx.parentId)
tags.push(`reply_to:${ctx.parentId}`);
if (ctx.contentType !== 'text')
tags.push(ctx.contentType);
if (ctx.mentions.some((m) => m.isBot))
tags.push('@bot');
if (ctx.threadId)
tags.push(`thread:${ctx.threadId}`);
if (ctx.resources.length > 0) {
tags.push(`${ctx.resources.length} attachment(s)`);
}
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
core.system.enqueueSystemEvent(`Feishu[${account.accountId}] ${location} | ${sender}${tagStr}`, {
sessionKey: route.sessionKey,
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
});
return {
ctx,
accountScopedCfg,
account,
runtime,
log,
error,
core,
isGroup,
isThread,
feishuFrom,
feishuTo,
envelopeFrom,
envelopeOptions,
route,
threadSessionKey: undefined,
commandAuthorized: params.commandAuthorized,
};
}
// ---------------------------------------------------------------------------
// Thread session resolution
// ---------------------------------------------------------------------------
/**
* Resolve thread session key for thread-capable groups.
*
* Returns a thread-scoped session key when ALL conditions are met:
* 1. `threadSession` config is enabled on the account
* 2. The group is a topic group (chat_mode=topic) or uses thread
* message mode (group_message_type=thread)
*
* The group info is fetched via `im.chat.get` with a 1-hour LRU cache
* to minimise OAPI calls.
*/
async function resolveThreadSessionKey(params) {
const { accountScopedCfg, account, chatId, threadId, baseSessionKey } = params;
if (account.config?.threadSession !== true)
return undefined;
const threadCapable = await (0, chat_info_cache_1.isThreadCapableGroup)({
cfg: accountScopedCfg,
chatId,
accountId: account.accountId,
});
if (!threadCapable) {
log.info(`thread session skipped: group ${chatId} is not topic/thread mode`);
return undefined;
}
// 使用 SDK 标准函数,保证分隔符格式与 resolveThreadParentSessionKey 兼容
const { sessionKey } = (0, routing_1.resolveThreadSessionKeys)({
baseSessionKey,
threadId,
parentSessionKey: baseSessionKey,
normalizeThreadId: (id) => id, // 飞书 thread ID (omt_xxx) 区分大小写,不做 lowercase
});
return sessionKey;
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Agent dispatch for inbound Feishu messages.
*
* Builds the agent envelope, prepends chat history context, and
* dispatches through the appropriate reply path (system command
* vs. normal streaming/static flow).
*
* Implementation details are split across focused modules:
* - dispatch-context.ts — DispatchContext type, route/session/event
* - dispatch-builders.ts — pure payload/body/envelope construction
* - dispatch-commands.ts — system command & permission notification
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { MessageContext } from '../types';
import type { FeishuGroupConfig, LarkAccount } from '../../core/types';
import type { PermissionError } from './permission';
export declare function dispatchToAgent(params: {
ctx: MessageContext;
permissionError?: PermissionError;
mediaPayload: Record<string, unknown>;
/** Additional structured metadata for synthetic or event-driven inbound flows. */
extraInboundFields?: Record<string, unknown>;
quotedContent?: string;
account: LarkAccount;
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg: ClawdbotConfig;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
historyLimit: number;
/** Override the message ID used for reply threading. When set, the
* reply-dispatcher uses this ID for typing indicators and card replies
* instead of ctx.messageId (which may be a synthetic ID). */
replyToMessageId?: string;
/** When set, controls whether the sender is authorized to execute
* control commands. Computed by the handler via the SDK's access
* group command gating system. */
commandAuthorized?: boolean;
/** Per-group configuration for skills, systemPrompt, etc. */
groupConfig?: FeishuGroupConfig;
/** Default group configuration from the "*" wildcard entry. */
defaultGroupConfig?: FeishuGroupConfig;
/** When true, the reply dispatcher skips typing indicators. */
skipTyping?: boolean;
/** The receiving bot's own open_id, used for self-identity injection. */
botOpenId?: string;
}): Promise<void>;
@@ -0,0 +1,477 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Agent dispatch for inbound Feishu messages.
*
* Builds the agent envelope, prepends chat history context, and
* dispatches through the appropriate reply path (system command
* vs. normal streaming/static flow).
*
* Implementation details are split across focused modules:
* - dispatch-context.ts — DispatchContext type, route/session/event
* - dispatch-builders.ts — pure payload/body/envelope construction
* - dispatch-commands.ts — system command & permission notification
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.dispatchToAgent = dispatchToAgent;
const reply_history_1 = require("openclaw/plugin-sdk/reply-history");
const lark_logger_1 = require("../../core/lark-logger.js");
const lark_ticket_1 = require("../../core/lark-ticket.js");
const reply_dispatcher_1 = require("../../card/reply-dispatcher.js");
const chat_queue_1 = require("../../channel/chat-queue.js");
const tool_use_config_1 = require("../../card/tool-use-config.js");
const tool_use_trace_store_1 = require("../../card/tool-use-trace-store.js");
const abort_detect_1 = require("../../channel/abort-detect.js");
const bot_peer_context_1 = require("../outbound/bot-peer-context.js");
const comment_target_1 = require("../../core/comment-target.js");
const synthetic_target_1 = require("../../core/synthetic-target.js");
const targets_1 = require("../../core/targets.js");
const deliver_1 = require("../outbound/deliver.js");
const doctor_1 = require("../../commands/doctor.js");
const auth_1 = require("../../commands/auth.js");
const index_1 = require("../../commands/index.js");
const send_1 = require("../outbound/send.js");
const bot_content_1 = require("./bot-content.js");
const dispatch_commands_1 = require("./dispatch-commands.js");
const dispatch_builders_1 = require("./dispatch-builders.js");
const sentinel_store_1 = require("./sentinel-store.js");
const dispatch_context_1 = require("./dispatch-context.js");
const mention_1 = require("./mention.js");
const gate_1 = require("./gate.js");
const log = (0, lark_logger_1.larkLogger)('inbound/dispatch');
// ---------------------------------------------------------------------------
// Internal: normal message dispatch
// ---------------------------------------------------------------------------
/**
* Dispatch a normal (non-command) message via the streaming card flow.
* Cleans up consumed history entries after dispatch completes.
*
* Note: history cleanup is intentionally placed here and NOT in the
* system-command path — command handlers don't consume history context,
* so the entries should be preserved for the next normal message.
*/
/**
* Dispatch a comment-target message via the buffered block dispatcher.
*
* Comment targets cannot use the streaming card flow (IM APIs don't
* understand comment:... targets). Instead we use the SDK's buffered
* block dispatcher with a deliver callback that sends via the Drive
* comment reply API.
*/
async function dispatchCommentMessage(dc, ctxPayload, skillFilter) {
const effectiveSessionKey = dc.threadSessionKey ?? dc.route.sessionKey;
dc.log(`feishu[${dc.account.accountId}]: dispatching comment reply (session=${effectiveSessionKey})`);
log.info(`dispatching comment reply (session=${effectiveSessionKey})`);
let delivered = false;
await dc.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: dc.accountScopedCfg,
dispatcherOptions: {
deliver: async (payload) => {
const text = payload.text?.trim() ?? '';
if (!text || text === 'NO_REPLY')
return;
await (0, deliver_1.sendCommentReplyLark)({
cfg: dc.accountScopedCfg,
to: dc.ctx.chatId,
text,
accountId: dc.account.accountId,
});
delivered = true;
},
onSkip: (_payload, info) => {
if (info.reason !== 'silent') {
dc.log(`feishu[${dc.account.accountId}]: comment reply skipped (reason=${info.reason})`);
}
},
onError: (err, info) => {
dc.error(`feishu[${dc.account.accountId}]: comment ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
...(skillFilter ? { skillFilter } : {}),
},
});
dc.log(`feishu[${dc.account.accountId}]: comment dispatch complete (delivered=${delivered})`);
log.info(`comment dispatch complete (delivered=${delivered}, elapsed=${(0, lark_ticket_1.ticketElapsed)()}ms)`);
}
/**
* Dispatch a synthetic-target message via the buffered block dispatcher
* while discarding every delivered payload.
*
* Synthetic contexts (e.g. VC meeting-invited) trigger the agent for its
* side-effects (tool calls) — they do not correspond to a real IM chat,
* so any text / card the agent emits must be dropped instead of being
* sent as a DM to whatever open_id happens to be in ctx.chatId.
*/
async function dispatchSyntheticMessage(dc, ctxPayload, skillFilter) {
const effectiveSessionKey = dc.threadSessionKey ?? dc.route.sessionKey;
const isVcSynthetic = dc.ctx.chatId === synthetic_target_1.SYNTHETIC_VC_CHAT_ID;
let deliveredFinalToSender = false;
dc.log(`feishu[${dc.account.accountId}]: dispatching synthetic reply (session=${effectiveSessionKey}, target=${dc.ctx.chatId})`);
log.info(`dispatching synthetic reply (session=${effectiveSessionKey})`);
await dc.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: dc.accountScopedCfg,
dispatcherOptions: {
deliver: async (payload, info) => {
const text = payload.text?.trim() ?? '';
const preview = text.slice(0, 120);
// VC invited flows intentionally keep the synthetic target to avoid
// leaking intermediate tool output to IM, but the final business
// result should be explicitly notified to the inviter.
//
// Important: this DM is only a transport bridge for the final text.
// It does not rebind the inviter's DM conversation to the synthetic
// meeting-scoped session; later DM replies will still be routed by the
// normal OpenClaw DM session rules.
if (isVcSynthetic && info.kind === 'final' && text && text !== 'NO_REPLY' && !deliveredFinalToSender) {
deliveredFinalToSender = true;
try {
await (0, send_1.sendMessageFeishu)({
cfg: dc.accountScopedCfg,
to: dc.ctx.senderId,
text,
accountId: dc.account.accountId,
});
dc.log(`feishu[${dc.account.accountId}]: synthetic VC final delivered explicitly to sender=${dc.ctx.senderId}, preview="${preview}"`);
return;
}
catch (err) {
deliveredFinalToSender = false;
dc.error(`feishu[${dc.account.accountId}]: synthetic VC final delivery failed to sender=${dc.ctx.senderId}: ${String(err)}`);
}
}
if (info.kind === 'final') {
dc.log(`feishu[${dc.account.accountId}]: synthetic final payload dropped (target=${dc.ctx.chatId})`);
}
},
onSkip: (_payload, info) => {
if (info.reason !== 'silent') {
dc.log(`feishu[${dc.account.accountId}]: synthetic reply skipped (reason=${info.reason})`);
}
},
onError: (err, info) => {
dc.error(`feishu[${dc.account.accountId}]: synthetic ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
...(skillFilter ? { skillFilter } : {}),
},
});
dc.log(`feishu[${dc.account.accountId}]: synthetic dispatch complete (elapsed=${(0, lark_ticket_1.ticketElapsed)()}ms)`);
}
async function dispatchNormalMessage(dc, ctxPayload, routing, chatHistories, historyKey, historyLimit, replyToMessageId, skillFilter, skipTyping, botPeer) {
// Synthetic targets (e.g. VC meeting-invited) have no real IM peer to
// deliver replies to. Route them through the buffered block dispatcher
// with a deliver() that drops every payload — the agent still runs
// (tool calls, side-effects) but produces no outbound IM traffic.
if ((0, synthetic_target_1.isSyntheticTarget)(dc.ctx.chatId)) {
await dispatchSyntheticMessage(dc, ctxPayload, skillFilter);
return;
}
// Comment targets bypass the streaming card / IM flow entirely —
// route through the Drive comment reply API.
if ((0, comment_target_1.isCommentTarget)(dc.ctx.chatId)) {
await dispatchCommentMessage(dc, ctxPayload, skillFilter);
return;
}
// Abort messages should never create streaming cards — dispatch via the
// plain-text system-command path so the SDK's abort handler can reply
// without touching CardKit.
if ((0, abort_detect_1.isLikelyAbortText)(dc.ctx.content?.trim() ?? '')) {
dc.log(`feishu[${dc.account.accountId}]: abort message detected, using plain-text dispatch`);
log.info('abort message detected, using plain-text dispatch');
await (0, dispatch_commands_1.dispatchSystemCommand)(dc, ctxPayload, replyToMessageId);
return;
}
const effectiveSessionKey = dc.threadSessionKey ?? dc.route.sessionKey;
const toolUseDisplay = (0, tool_use_config_1.resolveToolUseDisplayConfig)({
cfg: dc.accountScopedCfg,
feishuCfg: dc.account.config,
agentId: dc.route.agentId,
sessionKey: effectiveSessionKey,
body: dc.ctx.content,
});
if (toolUseDisplay.showToolUse) {
(0, tool_use_trace_store_1.startToolUseTraceRun)(effectiveSessionKey);
}
else {
(0, tool_use_trace_store_1.clearToolUseTraceRun)(effectiveSessionKey);
}
const { dispatcher, replyOptions, markDispatchIdle, markFullyComplete, abortCard } = (0, reply_dispatcher_1.createFeishuReplyDispatcher)({
cfg: dc.accountScopedCfg,
agentId: dc.route.agentId,
chatId: dc.ctx.chatId,
sessionKey: effectiveSessionKey,
replyToMessageId: replyToMessageId ?? dc.ctx.messageId,
accountId: dc.account.accountId,
chatType: dc.ctx.chatType,
skipTyping,
replyInThread: routing.replyInThread,
threadId: routing.threadId,
toolUseDisplay,
});
// Create an AbortController so the abort fast-path can cancel the
// underlying LLM request (not just the streaming card UI).
const abortController = new AbortController();
// Register the active dispatcher so the monitor abort fast-path can
// terminate the streaming card before this task completes.
const queueKey = (0, chat_queue_1.buildQueueKey)(dc.account.accountId, dc.ctx.chatId, dc.ctx.threadId);
(0, chat_queue_1.registerActiveDispatcher)(queueKey, { abortCard, abortController });
dc.log(`feishu[${dc.account.accountId}]: dispatching to agent (session=${effectiveSessionKey})`);
log.info(`dispatching to agent (session=${effectiveSessionKey})`);
// Attach the resolved bot-peer (if any) so the outbound `ensureMention`
// backstop can guarantee an @ even when the LLM forgets. Resolved by the
// caller (dispatchToAgent) and decoupled from thread routing. Undefined →
// pure no-op.
const withBotPeer = botPeer
? (fn) => (0, bot_peer_context_1.runWithBotPeerContext)(botPeer, fn)
: (fn) => fn();
try {
const { queuedFinal, counts } = await withBotPeer(() => dc.core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: dc.accountScopedCfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal: abortController.signal,
...(skillFilter ? { skillFilter } : {}),
},
}));
// Wait for all enqueued deliver() calls in the SDK's sendChain to
// complete before marking the dispatch as done. Without this,
// dispatchReplyFromConfig() may return while the final deliver() is
// still pending in the Promise chain, causing markFullyComplete() to
// block it and leaving completedText incomplete — which in turn makes
// the streaming card's final update show truncated content.
//
// Run under withBotPeer too so any deliveries flushed during waitForIdle
// still see the peer context.
await withBotPeer(() => dispatcher.waitForIdle());
markFullyComplete();
markDispatchIdle();
// Clean up consumed history entries
if (dc.isGroup && historyKey && chatHistories) {
(0, reply_history_1.clearHistoryEntriesIfEnabled)({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
}
dc.log(`feishu[${dc.account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
log.info(`dispatch complete (replies=${counts.final}, elapsed=${(0, lark_ticket_1.ticketElapsed)()}ms)`);
}
finally {
(0, chat_queue_1.unregisterActiveDispatcher)(queueKey);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
async function dispatchToAgent(params) {
// 1. Derive shared context (including route resolution + system event)
const dc = (0, dispatch_context_1.buildDispatchContext)(params);
// 1a. Reply routing: handles topic-group thread inference (may mutate dc)
// and bot-peer suppression for bot→bot group scenarios (#32980).
// See src/messaging/inbound/bot-content.ts for the full rationale.
const replyInThreadConfig = params.groupConfig?.replyInThread ??
params.defaultGroupConfig?.replyInThread ??
dc.account.config?.replyInThread;
const routing = await (0, bot_content_1.resolveFeishuReplyRouting)(dc, { replyInThreadConfig });
// 1b. Resolve thread session isolation (async: may query group info API)
if (dc.isThread && dc.ctx.threadId) {
dc.threadSessionKey = await (0, dispatch_context_1.resolveThreadSessionKey)({
accountScopedCfg: dc.accountScopedCfg,
account: dc.account,
chatId: dc.ctx.chatId,
threadId: dc.ctx.threadId,
baseSessionKey: dc.route.sessionKey,
});
}
// Consume any pending mention sentinels for this thread. Take and
// delete is one shot per inbound — capture once, hand to both body
// builders below.
const sentinelKey = (0, chat_queue_1.threadScopedKey)(dc.ctx.chatId, dc.isThread ? dc.ctx.threadId : undefined);
const sentinels = (0, sentinel_store_1.getSentinelStore)(dc.account.accountId).consumeSentinels(sentinelKey);
// 3. Build annotated message body
const messageBody = (0, dispatch_builders_1.buildMessageBody)(params.ctx, params.quotedContent, sentinels);
// 4. Permission-error notification (optional side-effect).
// Isolated so a failure here does not block the main message dispatch.
// Skipped for comment targets: the streaming card dispatcher inside
// dispatchPermissionNotification sends via IM APIs which don't
// understand comment:... targets.
if (params.permissionError && !(0, comment_target_1.isCommentTarget)(dc.ctx.chatId)) {
try {
await (0, dispatch_commands_1.dispatchPermissionNotification)(dc, params.permissionError, params.replyToMessageId);
}
catch (err) {
dc.error(`feishu[${dc.account.accountId}]: permission notification failed, continuing: ${String(err)}`);
}
}
// 5. Build main envelope (with group chat history)
const { combinedBody, historyKey } = (0, dispatch_builders_1.buildEnvelopeWithHistory)(dc, messageBody, params.chatHistories, params.historyLimit);
// 6. Build BodyForAgent with mention annotation (if any).
// SDK >= 2026.2.10 no longer falls back to Body for BodyForAgent,
// so we must set it explicitly to preserve the annotation.
const bodyForAgent = (0, dispatch_builders_1.buildBodyForAgent)(params.ctx, sentinels);
// 7. Build InboundHistory for SDK metadata injection (>= 2026.2.10).
// The SDK's buildInboundUserContextPrefix renders these as structured
// JSON blocks; earlier SDK versions simply ignore unknown fields.
const threadHistoryKey = (0, chat_queue_1.threadScopedKey)(dc.ctx.chatId, dc.isThread ? dc.ctx.threadId : undefined);
const inboundHistory = dc.isGroup && params.chatHistories && params.historyLimit > 0
? (params.chatHistories.get(threadHistoryKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp ?? Date.now(),
}))
: undefined;
// 8. Build inbound context payload
const isBareNewOrReset = /^\/(?:new|reset)\s*$/i.test((params.ctx.content ?? '').trim());
const configuredGroupPrompt = dc.isGroup
? params.groupConfig?.systemPrompt?.trim() || params.defaultGroupConfig?.systemPrompt?.trim() || undefined
: undefined;
// In group chats, always inject bot-at-bot guidance (self open_id + @
// delivery rules + loop hygiene), merged with any operator-configured
// group prompt. Complements the deterministic ensureMention safety net.
const groupSystemPrompt = dc.isGroup
? (0, dispatch_builders_1.buildFeishuGroupSystemPrompt)(configuredGroupPrompt, params.botOpenId)
: undefined;
const originatingTo = isBareNewOrReset && dc.isThread
? (0, targets_1.encodeFeishuRouteTarget)({
target: dc.feishuTo,
replyToMessageId: params.replyToMessageId ?? params.ctx.messageId,
threadId: dc.ctx.threadId,
})
: undefined;
const ctxPayload = (0, dispatch_builders_1.buildInboundPayload)(dc, {
body: combinedBody,
bodyForAgent,
rawBody: params.ctx.content,
commandBody: params.ctx.content,
originatingTo,
senderName: params.ctx.senderName ?? params.ctx.senderId,
senderId: params.ctx.senderId,
messageSid: params.ctx.messageId,
wasMentioned: (0, mention_1.mentionedBot)(params.ctx) ||
(params.ctx.mentionAll &&
(0, gate_1.resolveRespondToMentionAll)({
groupConfig: params.groupConfig,
defaultConfig: params.defaultGroupConfig,
accountFeishuCfg: params.account.config,
})),
replyToBody: params.quotedContent,
inboundHistory,
extraFields: {
...params.mediaPayload,
...(params.extraInboundFields ?? {}),
...(0, dispatch_builders_1.buildFeishuIdentityFields)(params.ctx, params.botOpenId),
...(groupSystemPrompt ? { GroupSystemPrompt: groupSystemPrompt } : {}),
...(dc.ctx.threadId ? { MessageThreadId: dc.ctx.threadId } : {}),
},
});
// 9a. Intercept /feishu commands for i18n multi-locale card dispatch
// Must run BEFORE the SDK command check — the SDK does not recognise
// plugin-registered commands via isControlCommandMessage, so
// /feishu_* falls through to the AI agent otherwise.
// Skipped for comment targets: comment text won't match /feishu_*
// patterns in practice, and sendCardFeishu/sendMessageFeishu can't
// deliver to comment:... targets.
const contentTrimmed = (params.ctx.content ?? '').trim();
const isCommentFlow = (0, comment_target_1.isCommentTarget)(dc.ctx.chatId);
const isDoctorCommand = !isCommentFlow && /^\/feishu[_ ]doctor\s*$/i.test(contentTrimmed);
const isAuthCommand = !isCommentFlow && /^\/feishu[_ ](?:auth|onboarding)\s*$/i.test(contentTrimmed);
const isStartCommand = !isCommentFlow && /^\/feishu[_ ]start\s*$/i.test(contentTrimmed);
const isHelpCommand = !isCommentFlow && /^\/feishu(?:[_ ]help)?\s*$/i.test(contentTrimmed);
const i18nCommandName = isDoctorCommand
? 'doctor'
: isAuthCommand
? 'auth'
: isStartCommand
? 'start'
: isHelpCommand
? 'help'
: null;
if (i18nCommandName) {
dc.log(`feishu[${dc.account.accountId}]: ${i18nCommandName} command detected, using i18n dispatch`);
log.info(`${i18nCommandName} command detected, using i18n dispatch`);
try {
let i18nTexts;
if (isDoctorCommand) {
i18nTexts = await (0, doctor_1.runFeishuDoctorI18n)(dc.accountScopedCfg, dc.account.accountId);
}
else if (isAuthCommand) {
i18nTexts = await (0, auth_1.runFeishuAuthI18n)(dc.accountScopedCfg);
}
else if (isStartCommand) {
i18nTexts = (0, index_1.runFeishuStartI18n)(dc.accountScopedCfg);
}
else {
i18nTexts = (0, index_1.getFeishuHelpI18n)();
}
const card = (0, send_1.buildI18nMarkdownCard)(i18nTexts);
await (0, send_1.sendCardFeishu)({
cfg: dc.accountScopedCfg,
to: dc.ctx.chatId,
card,
replyToMessageId: params.replyToMessageId ?? dc.ctx.messageId,
accountId: dc.account.accountId,
replyInThread: routing.replyInThread,
});
}
catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
dc.error(`feishu[${dc.account.accountId}]: ${i18nCommandName} i18n dispatch failed: ${errMsg}`);
await (0, send_1.sendMessageFeishu)({
cfg: dc.accountScopedCfg,
to: dc.ctx.chatId,
text: `${i18nCommandName} failed: ${errMsg}`,
replyToMessageId: params.replyToMessageId ?? dc.ctx.messageId,
accountId: dc.account.accountId,
replyInThread: routing.replyInThread,
});
}
return;
}
// 8. Dispatch: system command vs. normal message
// Comment targets always go to normal dispatch — system command
// delivery uses sendMessageFeishu which can't reach comment threads.
const isCommand = !isCommentFlow &&
dc.core.channel.commands.isControlCommandMessage(params.ctx.content, params.accountScopedCfg);
// Resolve per-group skill filter (per-group > default "*")
const skillFilter = dc.isGroup ? (params.groupConfig?.skills ?? params.defaultGroupConfig?.skills) : undefined;
if (isCommand) {
await (0, dispatch_commands_1.dispatchSystemCommand)(dc, ctxPayload, params.replyToMessageId);
// /new and /reset explicitly start a new session — clear pending history
if (isBareNewOrReset && dc.isGroup && historyKey && params.chatHistories) {
(0, reply_history_1.clearHistoryEntriesIfEnabled)({
historyMap: params.chatHistories,
historyKey,
limit: params.historyLimit,
});
}
}
else {
// Normal message dispatch; history cleanup happens inside.
// System commands intentionally skip history cleanup — command handlers
// don't consume history context, so entries are preserved for the next
// normal message.
// A human asking the bots to stop ("中断对话", "stop talking", …) must NOT
// get a forced peer-@: the deterministic ensureMention backstop would
// re-wake the peer bot and defeat the interruption. Skip peer resolution
// on stop-intent; the kickoff/continue path ("你们辩论") is unaffected.
const botPeer = (0, abort_detect_1.isConversationStopIntent)(dc.ctx.content ?? '')
? undefined
: (0, bot_content_1.resolveBotPeerForMention)({
isGroup: dc.isGroup,
senderIsBot: dc.ctx.senderIsBot,
senderId: dc.ctx.senderId,
senderName: dc.ctx.senderName ?? undefined,
mentions: dc.ctx.mentions,
botOpenId: params.botOpenId,
});
await dispatchNormalMessage(dc, ctxPayload, routing, params.chatHistories, historyKey, params.historyLimit, params.replyToMessageId, skillFilter, params.skipTyping, botPeer);
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Context enrichment for inbound Feishu messages.
*
* Enrichment phases:
*
* - **resolveSenderInfo** (lightweight, before gate) — resolves sender
* display name and tracks permission errors.
* - **prefetchUserNames** (after gate, before content resolution) — batch
* pre-warm the account-scoped user-name cache for the sender and all
* non-bot mentions so that downstream merge_forward expansion and
* quoted-message formatting can read names synchronously.
* - **resolveMedia** (after gate) — downloads binary media attachments
* using ResourceDescriptors from the converter phase.
* - **resolveQuotedContent** (after gate) — fetches the replied-to
* message text for context.
*
* Note: merge_forward expansion for the primary message is now handled
* at parse time in {@link parseMessageEvent}. Quoted merge_forward
* messages are still expanded here via {@link resolveQuotedContent}.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuMediaInfo, MessageContext } from '../types';
import type { LarkAccount } from '../../core/types';
import type { PermissionError } from './permission';
/**
* Resolve the sender display name and track permission errors.
*
* This must run before the gate check because per-group sender
* allowlists may match on senderName.
*/
export declare function resolveSenderInfo(params: {
ctx: MessageContext;
account: LarkAccount;
log: (...args: unknown[]) => void;
}): Promise<{
ctx: MessageContext;
permissionError?: PermissionError;
}>;
/**
* Batch-prefetch user display names for the sender and all non-bot
* mentions. Mention names that are already known from the event payload
* are written into the cache for free.
*/
export declare function prefetchUserNames(params: {
ctx: MessageContext;
account: LarkAccount;
log: (...args: unknown[]) => void;
}): Promise<void>;
/** Result of media resolution: envelope payload + per-file mapping. */
export interface ResolveMediaResult {
payload: Record<string, unknown>;
mediaList: FeishuMediaInfo[];
}
/**
* Download and save binary media attachments (images, files, audio,
* video, stickers) from the inbound message.
*
* Uses ResourceDescriptors extracted by content converters during the
* parse phase — no re-parsing of rawMessage.content needed.
*
* Returns a payload object whose keys (`MediaPath`, `MediaType`, …)
* are spread directly into the agent envelope, plus the raw mediaList
* for content substitution.
*/
export declare function resolveMedia(params: {
ctx: MessageContext;
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg: ClawdbotConfig;
account: LarkAccount;
log: (...args: unknown[]) => void;
}): Promise<ResolveMediaResult>;
/**
* Replace Feishu file-key references in message content with actual
* local file paths after download.
*
* This is critical for:
* - **Images / stickers**: The SDK's `detectAndLoadPromptImages` scans
* the prompt text for local file paths with image extensions.
* - **Audio / video / files**: Gives the AI meaningful context about
* what was received (the SDK reads these via `MediaPath` directly,
* but the text body should still reflect the actual attachments).
*/
export declare function substituteMediaPaths(content: string, mediaList: FeishuMediaInfo[]): string;
/**
* Fetch the text content of the message that the user replied to.
*
* If the quoted message is itself a merge_forward, its sub-messages are
* fetched and formatted as a single text block.
*
* Returns `"senderName: content"` when the sender name is available so
* the AI knows who originally wrote the quoted message.
*/
export declare function resolveQuotedContent(params: {
ctx: MessageContext;
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg: ClawdbotConfig;
account: LarkAccount;
log: (...args: unknown[]) => void;
}): Promise<string | undefined>;
@@ -0,0 +1,236 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Context enrichment for inbound Feishu messages.
*
* Enrichment phases:
*
* - **resolveSenderInfo** (lightweight, before gate) — resolves sender
* display name and tracks permission errors.
* - **prefetchUserNames** (after gate, before content resolution) — batch
* pre-warm the account-scoped user-name cache for the sender and all
* non-bot mentions so that downstream merge_forward expansion and
* quoted-message formatting can read names synchronously.
* - **resolveMedia** (after gate) — downloads binary media attachments
* using ResourceDescriptors from the converter phase.
* - **resolveQuotedContent** (after gate) — fetches the replied-to
* message text for context.
*
* Note: merge_forward expansion for the primary message is now handled
* at parse time in {@link parseMessageEvent}. Quoted merge_forward
* messages are still expanded here via {@link resolveQuotedContent}.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveSenderInfo = resolveSenderInfo;
exports.prefetchUserNames = prefetchUserNames;
exports.resolveMedia = resolveMedia;
exports.substituteMediaPaths = substituteMediaPaths;
exports.resolveQuotedContent = resolveQuotedContent;
const fetch_1 = require("../outbound/fetch.js");
const permission_1 = require("./permission.js");
const user_name_cache_1 = require("./user-name-cache.js");
const media_resolver_1 = require("./media-resolver.js");
// ---------------------------------------------------------------------------
// Phase 1: Sender info (lightweight, before gate)
// ---------------------------------------------------------------------------
/**
* Resolve the sender display name and track permission errors.
*
* This must run before the gate check because per-group sender
* allowlists may match on senderName.
*/
async function resolveSenderInfo(params) {
const { account, log } = params;
let ctx = params.ctx;
// Bots and users have separate name-resolution endpoints. The contact API
// does not return bot info, so dispatch on senderIsBot. Both endpoints
// populate the same account-scoped cache (keyed by openId).
//
// Skip resolution for unknown sender_types (e.g. anonymous, missing) — the
// contact API would 4xx and the bot API would not match. This preserves the
// pre-bot-support behavior of only resolving names for `sender_type === 'user'`.
const senderType = ctx.rawSender?.sender_type;
if (!ctx.senderIsBot && senderType !== 'user') {
log(`sender_type is "${senderType ?? 'undefined'}", skipping name resolution`);
return { ctx };
}
const senderResult = ctx.senderIsBot
? await (0, user_name_cache_1.resolveBotName)({ account, openId: ctx.senderId, log })
: await (0, user_name_cache_1.resolveUserName)({ account, openId: ctx.senderId, log });
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
log(`sender resolved: ${senderResult.name}`);
}
else if (senderResult.permissionError) {
log(`sender resolve failed: permission error code=${senderResult.permissionError.code}`);
}
// Track permission errors (with cooldown)
let permissionError;
if (senderResult.permissionError) {
const appKey = account.appId ?? 'default';
const now = Date.now();
const lastNotified = permission_1.permissionErrorNotifiedAt.get(appKey) ?? 0;
if (now - lastNotified > permission_1.PERMISSION_ERROR_COOLDOWN_MS) {
permission_1.permissionErrorNotifiedAt.set(appKey, now);
permissionError = senderResult.permissionError;
}
}
return { ctx, permissionError };
}
// ---------------------------------------------------------------------------
// Phase 1.5: Batch pre-warm user name cache (after gate)
// ---------------------------------------------------------------------------
/**
* Batch-prefetch user display names for the sender and all non-bot
* mentions. Mention names that are already known from the event payload
* are written into the cache for free.
*/
async function prefetchUserNames(params) {
const { ctx, account, log } = params;
if (!account.configured)
return;
const cache = (0, user_name_cache_1.getUserNameCache)(account.accountId);
// Seed cache with mention names already present in the event payload
for (const m of ctx.mentions) {
if (!m.isBot && m.openId && m.name) {
cache.set(m.openId, m.name);
}
}
// Collect all openIds we care about
const openIds = new Set();
if (ctx.senderId)
openIds.add(ctx.senderId);
for (const m of ctx.mentions) {
if (!m.isBot && m.openId)
openIds.add(m.openId);
}
// Batch-resolve any that are still missing
const toResolve = cache.filterMissing([...openIds]);
if (toResolve.length > 0) {
await (0, user_name_cache_1.batchResolveUserNames)({ account, openIds: toResolve, log });
}
}
/**
* Download and save binary media attachments (images, files, audio,
* video, stickers) from the inbound message.
*
* Uses ResourceDescriptors extracted by content converters during the
* parse phase — no re-parsing of rawMessage.content needed.
*
* Returns a payload object whose keys (`MediaPath`, `MediaType`, …)
* are spread directly into the agent envelope, plus the raw mediaList
* for content substitution.
*/
async function resolveMedia(params) {
const { ctx, accountScopedCfg, account, log } = params;
const accountFeishuCfg = account.config;
const mediaMaxBytes = (accountFeishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024;
const mediaList = await (0, media_resolver_1.downloadResources)({
cfg: accountScopedCfg,
messageId: ctx.messageId,
resources: ctx.resources,
maxBytes: mediaMaxBytes,
log,
accountId: account.accountId,
});
if (mediaList.length > 0) {
log(`media resolved: ${mediaList.length} attachment(s)`);
}
return {
payload: (0, media_resolver_1.buildFeishuMediaPayload)(mediaList),
mediaList,
};
}
// ---------------------------------------------------------------------------
// Media content substitution
// ---------------------------------------------------------------------------
/**
* Replace Feishu file-key references in message content with actual
* local file paths after download.
*
* This is critical for:
* - **Images / stickers**: The SDK's `detectAndLoadPromptImages` scans
* the prompt text for local file paths with image extensions.
* - **Audio / video / files**: Gives the AI meaningful context about
* what was received (the SDK reads these via `MediaPath` directly,
* but the text body should still reflect the actual attachments).
*/
function substituteMediaPaths(content, mediaList) {
let result = content;
for (const media of mediaList) {
const { fileKey, path, resourceType } = media;
switch (resourceType) {
case 'image':
// ![image](img_v3_xxx) → local path (SDK detects image extensions)
result = result.replace(`![image](${fileKey})`, path);
break;
case 'sticker':
// <sticker key="xxx"/> → local path (treated like image)
result = result.replace(`<sticker key="${fileKey}"/>`, path);
break;
case 'audio': {
// <audio key="xxx" .../> → [Audio: /path/to/audio.opus ...]
const audioRe = new RegExp(`<audio key="${escapeRegExp(fileKey)}"[^/]*/>`);
result = result.replace(audioRe, `[Audio: ${path}]`);
break;
}
case 'file': {
// <file key="xxx" .../> → [File: /path/to/doc.pdf]
const fileRe = new RegExp(`<file key="${escapeRegExp(fileKey)}"[^/]*/>`);
result = result.replace(fileRe, `[File: ${path}]`);
break;
}
case 'video': {
// <video key="xxx" .../> → [Video: /path/to/video.mp4]
const videoRe = new RegExp(`<video key="${escapeRegExp(fileKey)}"[^/]*/>`);
result = result.replace(videoRe, `[Video: ${path}]`);
break;
}
}
}
return result;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// Phase 2b: Quoted / replied-to message (text context)
// ---------------------------------------------------------------------------
/**
* Fetch the text content of the message that the user replied to.
*
* If the quoted message is itself a merge_forward, its sub-messages are
* fetched and formatted as a single text block.
*
* Returns `"senderName: content"` when the sender name is available so
* the AI knows who originally wrote the quoted message.
*/
async function resolveQuotedContent(params) {
const { ctx, accountScopedCfg, account, log } = params;
if (!ctx.parentId)
return undefined;
try {
const quotedMsg = await (0, fetch_1.getMessageFeishu)({
cfg: accountScopedCfg,
messageId: ctx.parentId,
accountId: account.accountId,
expandForward: true,
});
if (!quotedMsg)
return undefined;
log(`feishu[${account.accountId}]: fetched quoted message: ${quotedMsg.content?.slice(0, 100)}`);
// Build quoted text with message_id prefix so AI can correlate
// file_key / image_key with the source message for resource download.
const prefix = `[message_id=${ctx.parentId}]`;
if (quotedMsg.senderName) {
return `${prefix} ${quotedMsg.senderName}: ${quotedMsg.content}`;
}
return `${prefix} ${quotedMsg.content}`;
}
catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
return undefined;
}
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Side-effect functions for the inbound message gate.
*
* Extracted from gate.ts to separate pure policy decisions from I/O
* operations (pairing request creation, message sending).
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
/**
* Create a pairing request and send a pairing reply message to the user.
*
* This is the side-effect portion of the DM pairing gate: the pure
* policy decision (whether to pair) is made in gate.ts, and this
* function executes the resulting I/O.
*/
export declare function sendPairingReply(params: {
senderId: string;
chatId: string;
accountId: string;
accountScopedCfg?: ClawdbotConfig;
}): Promise<void>;
@@ -0,0 +1,46 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Side-effect functions for the inbound message gate.
*
* Extracted from gate.ts to separate pure policy decisions from I/O
* operations (pairing request creation, message sending).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendPairingReply = sendPairingReply;
const lark_client_1 = require("../../core/lark-client.js");
const send_1 = require("../outbound/send.js");
// ---------------------------------------------------------------------------
// Pairing reply
// ---------------------------------------------------------------------------
/**
* Create a pairing request and send a pairing reply message to the user.
*
* This is the side-effect portion of the DM pairing gate: the pure
* policy decision (whether to pair) is made in gate.ts, and this
* function executes the resulting I/O.
*/
async function sendPairingReply(params) {
const { senderId, chatId, accountId, accountScopedCfg } = params;
const core = lark_client_1.LarkClient.runtime;
const { code } = await core.channel.pairing.upsertPairingRequest({
channel: 'feishu',
id: senderId,
accountId,
});
const pairingReply = core.channel.pairing.buildPairingReply({
channel: 'feishu',
idLine: senderId,
code,
});
if (accountScopedCfg) {
await (0, send_1.sendMessageFeishu)({
cfg: accountScopedCfg,
to: chatId,
text: pairingReply,
accountId,
});
}
}
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Policy gate for inbound Feishu messages.
*
* Determines whether a parsed message should be processed or rejected
* based on group/DM access policies, sender allowlists, and mention
* requirements.
*
* Group access follows the same two-layer model as Telegram:
*
* Layer 1 Which GROUPS are allowed (SDK `resolveGroupPolicy`):
* - No `groups` configured + `groupPolicy: "open"` → any group passes
* - `groupPolicy: "allowlist"` or `groups` configured → acts as allowlist
* (explicit group IDs or `"*"` wildcard)
* - `groupPolicy: "disabled"` → all groups blocked
*
* Layer 2 Which SENDERS are allowed within a group:
* - Per-group `groupPolicy` overrides global for sender filtering
* - `groupAllowFrom` (global) + per-group `allowFrom` are merged
* - `"open"` → any sender; `"allowlist"` → check merged list;
* `"disabled"` → block all senders
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { MessageContext } from '../types';
import type { FeishuConfig, FeishuGroupConfig, LarkAccount } from '../../core/types';
/**
* Resolve the effective `respondToMentionAll` setting.
*
* Precedence: per-group > default ("*") group > global account config > false.
*/
export declare function resolveRespondToMentionAll(params: {
groupConfig?: {
respondToMentionAll?: boolean;
};
defaultConfig?: {
respondToMentionAll?: boolean;
};
accountFeishuCfg?: {
respondToMentionAll?: boolean;
};
}): boolean;
/**
* Resolve the effective allowBots setting.
*
* Precedence: per-group > default ("*") > account > 'mentions'.
*
* The `'mentions'` default lets bot-to-bot interaction work out of the box
* while still requiring an explicit @-mention in groups; DMs treat it as
* pass-through. Operators can opt into fully-open (`true`) or fully-closed
* (`false`) explicitly.
*/
export declare function resolveAllowBots(params: {
groupConfig?: FeishuGroupConfig;
defaultConfig?: FeishuGroupConfig;
accountFeishuCfg?: FeishuConfig;
}): boolean | 'mentions';
/**
* Read the pairing allowFrom store for the Feishu channel via the SDK runtime.
*/
declare function readAllowFromStore(accountId: string): Promise<string[]>;
export interface GateResult {
allowed: boolean;
reason?: string;
/** When a group message is rejected due to missing bot mention, the
* caller should record this entry into the chat history map. */
historyEntry?: HistoryEntry;
}
/**
* Read the pairing allowFrom store for the Feishu channel.
*
* Exported so that handler.ts can provide it as a closure to the SDK's
* `resolveSenderCommandAuthorization` helper.
*/
export { readAllowFromStore as readFeishuAllowFromStore };
/**
* Check whether an inbound message passes all access-control gates.
*
* The DM gate is async because it may read from the pairing store
* and send pairing request messages.
*/
export declare function checkMessageGate(params: {
ctx: MessageContext;
accountFeishuCfg?: FeishuConfig;
account: LarkAccount;
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg?: ClawdbotConfig;
log: (...args: unknown[]) => void;
}): Promise<GateResult>;
@@ -0,0 +1,342 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Policy gate for inbound Feishu messages.
*
* Determines whether a parsed message should be processed or rejected
* based on group/DM access policies, sender allowlists, and mention
* requirements.
*
* Group access follows the same two-layer model as Telegram:
*
* Layer 1 Which GROUPS are allowed (SDK `resolveGroupPolicy`):
* - No `groups` configured + `groupPolicy: "open"` → any group passes
* - `groupPolicy: "allowlist"` or `groups` configured → acts as allowlist
* (explicit group IDs or `"*"` wildcard)
* - `groupPolicy: "disabled"` → all groups blocked
*
* Layer 2 Which SENDERS are allowed within a group:
* - Per-group `groupPolicy` overrides global for sender filtering
* - `groupAllowFrom` (global) + per-group `allowFrom` are merged
* - `"open"` → any sender; `"allowlist"` → check merged list;
* `"disabled"` → block all senders
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveRespondToMentionAll = resolveRespondToMentionAll;
exports.resolveAllowBots = resolveAllowBots;
exports.readFeishuAllowFromStore = readAllowFromStore;
exports.checkMessageGate = checkMessageGate;
const lark_client_1 = require("../../core/lark-client.js");
const policy_1 = require("./policy.js");
const mention_1 = require("./mention.js");
const gate_effects_1 = require("./gate-effects.js");
/**
* Resolve the effective `respondToMentionAll` setting.
*
* Precedence: per-group > default ("*") group > global account config > false.
*/
function resolveRespondToMentionAll(params) {
return (params.groupConfig?.respondToMentionAll ??
params.defaultConfig?.respondToMentionAll ??
params.accountFeishuCfg?.respondToMentionAll ??
false);
}
/**
* Resolve the effective allowBots setting.
*
* Precedence: per-group > default ("*") > account > 'mentions'.
*
* The `'mentions'` default lets bot-to-bot interaction work out of the box
* while still requiring an explicit @-mention in groups; DMs treat it as
* pass-through. Operators can opt into fully-open (`true`) or fully-closed
* (`false`) explicitly.
*/
function resolveAllowBots(params) {
return (params.groupConfig?.allowBots ??
params.defaultConfig?.allowBots ??
params.accountFeishuCfg?.allowBots ??
'mentions');
}
/** Prevent spamming the legacy groupAllowFrom migration warning. */
let legacyGroupAllowFromWarned = false;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Read the pairing allowFrom store for the Feishu channel via the SDK runtime.
*/
async function readAllowFromStore(accountId) {
const core = lark_client_1.LarkClient.runtime;
return await core.channel.pairing.readAllowFromStore({
channel: 'feishu',
accountId,
});
}
/**
* Check whether an inbound message passes all access-control gates.
*
* The DM gate is async because it may read from the pairing store
* and send pairing request messages.
*/
async function checkMessageGate(params) {
const { ctx } = params;
if (ctx.senderIsBot) {
return checkBotSenderGate(params);
}
const isGroup = ctx.chatType === 'group';
if (isGroup) {
return checkGroupGate(params);
}
return checkDmGate(params);
}
/**
* Layer 1 group-level admission check, shared between human and bot sender paths.
*
* Computes:
* - `groupPolicy` access via SDK (`resolveGroupPolicy`)
* - Legacy chat-id-in-`groupAllowFrom` compat
* - Per-group `enabled === false` kill switch
*
* Returns `rejected` non-null when the caller should reject with that result;
* otherwise the resolved per-group config is returned for downstream use.
*
* Bot senders go through the same Layer 1 as humans — `allowBots` only governs
* sender-axis admission, not which groups the account responds in.
*/
function resolveFeishuGroupAccess(params) {
const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
const core = lark_client_1.LarkClient.runtime;
// Legacy compat: groupAllowFrom with chat_id entries.
const rawGroupAllowFrom = accountFeishuCfg?.groupAllowFrom ?? [];
const { legacyChatIds, senderAllowFrom: senderGroupAllowFrom } = (0, policy_1.splitLegacyGroupAllowFrom)(rawGroupAllowFrom);
if (legacyChatIds.length > 0 && !legacyGroupAllowFromWarned) {
legacyGroupAllowFromWarned = true;
log(`feishu[${account.accountId}]: ⚠️ groupAllowFrom contains chat_id entries ` +
`(${legacyChatIds.join(', ')}). groupAllowFrom is for SENDER filtering ` +
`(open_ids like ou_xxx). Please move chat_ids to "groups" config instead:\n` +
` channels.feishu.groups: {\n` +
legacyChatIds.map((id) => ` "${id}": {},`).join('\n') +
`\n }`);
}
const groupConfig = (0, policy_1.resolveFeishuGroupConfig)({ cfg: accountFeishuCfg, groupId: ctx.chatId });
const defaultConfig = accountFeishuCfg?.groups?.['*'];
// SDK group-level policy (groupPolicy disabled / allowlist / open).
const groupAccess = core.channel.groups.resolveGroupPolicy({
cfg: accountScopedCfg ?? {},
channel: 'feishu',
groupId: ctx.chatId,
accountId: account.accountId,
groupIdCaseInsensitive: true,
hasGroupAllowFrom: senderGroupAllowFrom.length > 0,
});
let legacyGroupAdmit = false;
if (!groupAccess.allowed) {
const chatIdLower = ctx.chatId.toLowerCase();
const legacyMatch = legacyChatIds.some((id) => String(id).toLowerCase() === chatIdLower);
if (!legacyMatch) {
log(`feishu[${account.accountId}]: group ${ctx.chatId} blocked by group-level policy`);
return {
rejected: { allowed: false, reason: 'group_not_allowed' },
legacyGroupAdmit: false,
senderGroupAllowFrom,
groupConfig,
defaultConfig,
};
}
legacyGroupAdmit = true;
}
const enabled = groupConfig?.enabled ?? defaultConfig?.enabled;
if (enabled === false) {
log(`feishu[${account.accountId}]: group ${ctx.chatId} disabled by per-group config`);
return {
rejected: { allowed: false, reason: 'group_disabled' },
legacyGroupAdmit,
senderGroupAllowFrom,
groupConfig,
defaultConfig,
};
}
return { rejected: null, legacyGroupAdmit, senderGroupAllowFrom, groupConfig, defaultConfig };
}
// ---------------------------------------------------------------------------
// Internal: bot sender gate
// ---------------------------------------------------------------------------
function checkBotSenderGate(params) {
const { ctx, accountFeishuCfg, account, log } = params;
const isGroup = ctx.chatType === 'group';
// 1. Layer 1 group access — bot senders are subject to the same group-level
// admission as humans. `allowBots` is a sender-axis filter, not a group-axis
// filter; an account configured to ignore a group must ignore bots there too.
let groupConfig;
let defaultConfig;
if (isGroup) {
const access = resolveFeishuGroupAccess(params);
if (access.rejected)
return access.rejected;
groupConfig = access.groupConfig;
defaultConfig = access.defaultConfig;
}
// 2. Resolve allowBots (per-group > default > account > 'mentions')
const allowBots = resolveAllowBots({ groupConfig, defaultConfig, accountFeishuCfg });
// 3. allowBots === false → drop
if (allowBots === false) {
log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} in ${ctx.chatId} (allowBots=false)`);
return { allowed: false, reason: 'bot_sender_disabled' };
}
// 4. allowBots === 'mentions' + bot not mentioned → drop (group only;
// DMs have no @-mention concept, so mention-mode is a pass-through there).
if (isGroup && allowBots === 'mentions' && !(0, mention_1.mentionedBot)(ctx)) {
log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} in ${ctx.chatId} (allowBots=mentions, not mentioned)`);
return { allowed: false, reason: 'bot_sender_not_mentioned' };
}
// 5. Group requireMention check — redundant with allowBots='mentions' but
// necessary for the explicit `allowBots=true + requireMention=true` combo.
//
// NOTE: this intentionally diverges from the human-sender path (checkGroupGate),
// which delegates to SDK's resolveRequireMention that defaults to true.
// For bot senders, `requireMention` must be explicitly set to true — the
// rationale being: if the operator opts into `allowBots=true`, they want
// bot traffic through by default. Holding bots to a true-default mention
// requirement would silently negate `allowBots=true` in most configs.
if (isGroup) {
const requireMention = groupConfig?.requireMention ??
defaultConfig?.requireMention ??
accountFeishuCfg?.requireMention;
if (requireMention === true && !(0, mention_1.mentionedBot)(ctx)) {
log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} (no_mention)`);
// Intentionally NO historyEntry — bot messages never enter chat history.
return { allowed: false, reason: 'no_mention' };
}
}
return { allowed: true };
}
// ---------------------------------------------------------------------------
// Internal: group gate
// ---------------------------------------------------------------------------
function checkGroupGate(params) {
const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
const core = lark_client_1.LarkClient.runtime;
// ---- Layer 1: Group-level admission (shared with bot path) ----
const access = resolveFeishuGroupAccess(params);
if (access.rejected)
return access.rejected;
const { legacyGroupAdmit, senderGroupAllowFrom, groupConfig, defaultConfig } = access;
// ---- Layer 2: Sender-level access ----
// Per-group groupPolicy overrides the global groupPolicy for sender filtering.
// senderGroupAllowFrom (global, oc_ entries excluded) + per-group allowFrom.
//
// Legacy compat: when a group was admitted via old-style chat_id in
// groupAllowFrom AND there is no explicit per-group sender config,
// skip sender filtering (old semantic = "group allowed, any sender").
const hasExplicitSenderConfig = senderGroupAllowFrom.length > 0 || (groupConfig?.allowFrom ?? []).length > 0 || groupConfig?.groupPolicy != null;
if (!(legacyGroupAdmit && !hasExplicitSenderConfig)) {
const { senderPolicy, senderAllowFrom } = (0, policy_1.resolveGroupSenderPolicyContext)({
groupConfig,
defaultConfig,
accountFeishuCfg,
senderGroupAllowFrom,
});
const senderAllowed = (0, policy_1.isFeishuGroupAllowed)({
groupPolicy: senderPolicy,
allowFrom: senderAllowFrom,
senderId: ctx.senderId,
senderName: ctx.senderName,
});
if (!senderAllowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderId} not allowed in group ${ctx.chatId}`);
return { allowed: false, reason: 'sender_not_allowed' };
}
}
// ---- Mention requirement (SDK) ----
// SDK precedence: per-group > default ("*") > requireMentionOverride > true
const requireMention = core.channel.groups.resolveRequireMention({
cfg: accountScopedCfg ?? {},
channel: 'feishu',
groupId: ctx.chatId,
accountId: account.accountId,
groupIdCaseInsensitive: true,
requireMentionOverride: accountFeishuCfg?.requireMention,
});
if (requireMention && !(0, mention_1.mentionedBot)(ctx)) {
// Check if @all mention should bypass the mention requirement
if (ctx.mentionAll) {
const respondToAll = resolveRespondToMentionAll({
groupConfig,
defaultConfig,
accountFeishuCfg,
});
if (respondToAll) {
log(`feishu[${account.accountId}]: @all mention detected in group ${ctx.chatId}, allowing due to respondToMentionAll`);
return { allowed: true };
}
}
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`);
return {
allowed: false,
reason: 'no_mention',
historyEntry: {
sender: ctx.senderId,
body: `${ctx.senderName ?? ctx.senderId}: ${ctx.content}`,
timestamp: ctx.createTime ?? Date.now(),
messageId: ctx.messageId,
},
};
}
return { allowed: true };
}
// ---------------------------------------------------------------------------
// Internal: DM gate
// ---------------------------------------------------------------------------
async function checkDmGate(params) {
const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
const dmPolicy = accountFeishuCfg?.dmPolicy ?? 'pairing';
const configAllowFrom = accountFeishuCfg?.allowFrom ?? [];
if (dmPolicy === 'disabled') {
log(`feishu[${account.accountId}]: DM disabled by policy, rejecting sender ${ctx.senderId}`);
return { allowed: false, reason: 'dm_disabled' };
}
if (dmPolicy === 'open') {
return { allowed: true };
}
if (dmPolicy === 'allowlist') {
const storeAllowFrom = await readAllowFromStore(account.accountId).catch(() => []);
const combinedAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const match = (0, policy_1.resolveFeishuAllowlistMatch)({
allowFrom: combinedAllowFrom,
senderId: ctx.senderId,
senderName: ctx.senderName,
});
if (!match.allowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderId} not in DM allowlist`);
return { allowed: false, reason: 'dm_not_allowed' };
}
return { allowed: true };
}
// dmPolicy === "pairing"
const storeAllowFrom = await readAllowFromStore(account.accountId).catch(() => []);
const combinedAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const match = (0, policy_1.resolveFeishuAllowlistMatch)({
allowFrom: combinedAllowFrom,
senderId: ctx.senderId,
senderName: ctx.senderName,
});
if (match.allowed) {
return { allowed: true };
}
// Sender not yet paired — create a pairing request and notify them
log(`feishu[${account.accountId}]: sender ${ctx.senderId} not paired, creating pairing request`);
try {
await (0, gate_effects_1.sendPairingReply)({
senderId: ctx.senderId,
chatId: ctx.chatId,
accountId: account.accountId,
accountScopedCfg,
});
}
catch (err) {
log(`feishu[${account.accountId}]: failed to create pairing request for ${ctx.senderId}: ${String(err)}`);
}
return { allowed: false, reason: 'pairing_pending' };
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared registry for the inbound message handler.
*
* Synthetic message helpers depend on this registry instead of importing
* `handler.ts` directly, which keeps the static import graph acyclic.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { FeishuMessageEvent } from '../types';
export interface InboundHandlerParams {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
replyToMessageId?: string;
forceMention?: boolean;
skipTyping?: boolean;
}
type InboundHandler = (params: InboundHandlerParams) => Promise<void>;
export declare function injectInboundHandler(handler: InboundHandler): void;
export declare function getInboundHandler(): InboundHandler;
export {};
@@ -0,0 +1,23 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Shared registry for the inbound message handler.
*
* Synthetic message helpers depend on this registry instead of importing
* `handler.ts` directly, which keeps the static import graph acyclic.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.injectInboundHandler = injectInboundHandler;
exports.getInboundHandler = getInboundHandler;
let inboundHandler = null;
function injectInboundHandler(handler) {
inboundHandler = handler;
}
function getInboundHandler() {
if (!inboundHandler) {
throw new Error('Feishu inbound handler has not been initialised.');
}
return inboundHandler;
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Inbound message handling pipeline for the Lark/Feishu channel plugin.
*
* Orchestrates a nine-stage pipeline:
* 1. Account resolution
* 2. Event parsing → parse.ts (merge_forward expanded in-place)
* 3. Empty-message guard → early return for text-less, media-less messages
* 4. Sender enrichment → enrich.ts (lightweight, before gate)
* 5. Policy gate → gate.ts
* 6. User name prefetch → enrich.ts (batch cache warm-up)
* 7. Content resolution → enrich.ts (media / quote, parallel)
* 8. Command authorization → plugin-sdk/command-auth
* 9. Agent dispatch → dispatch.ts
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { FeishuMessageEvent } from '../types';
export declare function handleFeishuMessage(params: {
cfg: ClawdbotConfig;
event: FeishuMessageEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
/** Override the message ID used for reply threading (typing indicators,
* card replies, etc.). Useful for synthetic messages whose message_id
* is not a real Feishu message ID. */
replyToMessageId?: string;
/** When true, skip the policy gate (mention requirement, allowlist).
* Used for synthetic messages that are not real user messages. */
forceMention?: boolean;
/** When true, skip the typing indicator for this dispatch (e.g. reactions). */
skipTyping?: boolean;
}): Promise<void>;
@@ -0,0 +1,286 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Inbound message handling pipeline for the Lark/Feishu channel plugin.
*
* Orchestrates a nine-stage pipeline:
* 1. Account resolution
* 2. Event parsing → parse.ts (merge_forward expanded in-place)
* 3. Empty-message guard → early return for text-less, media-less messages
* 4. Sender enrichment → enrich.ts (lightweight, before gate)
* 5. Policy gate → gate.ts
* 6. User name prefetch → enrich.ts (batch cache warm-up)
* 7. Content resolution → enrich.ts (media / quote, parallel)
* 8. Command authorization → plugin-sdk/command-auth
* 9. Agent dispatch → dispatch.ts
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleFeishuMessage = handleFeishuMessage;
const reply_history_1 = require("openclaw/plugin-sdk/reply-history");
const command_auth_1 = require("openclaw/plugin-sdk/command-auth");
const allow_from_1 = require("openclaw/plugin-sdk/allow-from");
const accounts_1 = require("../../core/accounts.js");
const lark_client_1 = require("../../core/lark-client.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const lark_ticket_1 = require("../../core/lark-ticket.js");
const chat_queue_1 = require("../../channel/chat-queue.js");
const send_1 = require("../outbound/send.js");
const parse_1 = require("./parse.js");
const enrich_1 = require("./enrich.js");
const gate_1 = require("./gate.js");
const handler_registry_1 = require("./handler-registry.js");
const dispatch_1 = require("./dispatch.js");
const policy_1 = require("./policy.js");
const mention_registry_1 = require("./mention-registry.js");
const bot_loop_guard_1 = require("./bot-loop-guard.js");
const logger = (0, lark_logger_1.larkLogger)('inbound/handler');
// ---------------------------------------------------------------------------
// Public: handle inbound message
// ---------------------------------------------------------------------------
async function handleFeishuMessage(params) {
const { cfg, event, botOpenId, runtime, chatHistories, accountId, replyToMessageId, forceMention, skipTyping } = params;
// 1. Account resolution
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const accountFeishuCfg = account.config;
// ★ 多账号配置隔离:构造 account 级别的 ClawdbotConfig
//
// 在多账号场景下,每个 account 可以独立配置 groupPolicy / requireMention
// 等策略。但 SDK 的 resolveGroupPolicy / resolveRequireMention 等函数从
// cfg.channels.feishu 读取配置,而 cfg 是顶层全局配置,不包含 per-account
// 的覆盖值。
//
// 这里将 cfg.channels.feishu 替换为经过 getLarkAccount() 合并后的
// accountFeishuCfg= base config + account override),确保下游所有 SDK 调用
// 都能正确读取当前 account 的配置。
const accountScopedCfg = {
...cfg,
channels: { ...cfg.channels, feishu: accountFeishuCfg },
};
const log = runtime?.log ?? ((...args) => logger.info(args.map(String).join(' ')));
const error = runtime?.error ?? ((...args) => logger.error(args.map(String).join(' ')));
// 2. Parse event → MessageContext (merge_forward expanded in-place)
let ctx = await (0, parse_1.parseMessageEvent)(event, botOpenId, {
cfg: accountScopedCfg,
accountId: account.accountId,
});
// Self-echo hard filter — drop messages authored by this very bot before
// enrichment, gating, or dispatch. Mirrors the channel-layer filter in
// event-handlers.ts so alternate entrypoints into handleFeishuMessage
// (synthetic messages, replays, tests) don't bypass it. Skipped when
// botOpenId is not yet populated (startup race before bot probe resolves);
// the channel-layer filter and downstream bot-sender gate act as fallback.
if (botOpenId && ctx.senderId && ctx.senderId === botOpenId) {
log(`feishu[${account.accountId}]: drop self-echo message ${ctx.messageId}`);
return;
}
// 3. Early reject: skip empty-text messages with no media resources.
// OpenClaw 2026.4.29 adds a core-side guard for this (##74634), but
// rejecting here avoids wasting cycles on enrichment, gate, and
// dispatch for messages that would be silently dropped at the deliver
// callback anyway.
// A "bare @" (only a mention, no text/media) is a valid ping in
// bot-at-bot flows — treat it as an intentional wake-up rather than an
// empty message. Only drop messages that carry no text, no media, AND
// no mention at all.
if (!ctx.content.trim() && ctx.resources.length === 0 && ctx.mentions.length === 0 && !ctx.mentionAll) {
log(`feishu[${account.accountId}]: empty message ${ctx.messageId} (no text, no media, no mention), skipping`);
return;
}
// 4. Enrich (lightweight): sender name + permission error tracking
const { ctx: enrichedCtx, permissionError } = await (0, enrich_1.resolveSenderInfo)({
ctx,
account,
log,
});
ctx = enrichedCtx;
// Feed the per-chat name→openId registry that the outbound layer uses to
// turn "@Name" in LLM output into a real <at user_id="ou_xxx"> element.
// Both the sender and any @-target observed here are valuable signal —
// recording them now (before the gate) means we keep learning names even
// for messages the gate rejects.
if (ctx.senderId && ctx.senderName) {
(0, mention_registry_1.recordSender)(ctx.chatId, ctx.senderId, ctx.senderName);
}
for (const m of ctx.mentions) {
if (m.openId && m.name)
(0, mention_registry_1.recordMention)(ctx.chatId, m.openId, m.name);
}
// Bot-loop guard: a human turn re-arms the consecutive bot-turn budget so a
// fresh human-driven exchange always starts clean (counter checked below,
// after the gate, only for bot senders).
if (!ctx.senderIsBot) {
(0, bot_loop_guard_1.resetBotLoop)(ctx.chatId, ctx.threadId ?? ctx.rootId);
}
log(`feishu[${account.accountId}]: received message from ${ctx.senderId} in ${ctx.chatId} (${ctx.chatType})`);
logger.info(`received from ${ctx.senderId} in ${ctx.chatId} (${ctx.chatType})`);
const historyLimit = Math.max(0, accountFeishuCfg?.historyLimit ?? accountScopedCfg.messages?.groupChat?.historyLimit ?? reply_history_1.DEFAULT_GROUP_HISTORY_LIMIT);
// 5. Gate: policy / access-control checks (skipped for synthetic messages)
const gate = forceMention
? { allowed: true }
: await (0, gate_1.checkMessageGate)({ ctx, accountFeishuCfg, account, accountScopedCfg, log });
if (!gate.allowed) {
if (gate.reason === 'no_mention') {
logger.info(`rejected: no bot mention in group ${ctx.chatId}`);
}
// Record history entry if the gate produced one (group no-mention case)
if (gate.historyEntry && chatHistories) {
const historyKey = (0, chat_queue_1.threadScopedKey)(ctx.chatId, ctx.threadId);
(0, reply_history_1.recordPendingHistoryEntryIfEnabled)({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
entry: gate.historyEntry,
});
}
return;
}
// 6. Batch pre-warm user name cache (sender + mentions)
await (0, enrich_1.prefetchUserNames)({ ctx, account, log });
// 7. Enrich (heavyweight, after gate — parallel where possible)
const enrichParams = { ctx, accountScopedCfg, account, log };
const [mediaResult, quotedContent] = await Promise.all([
(0, enrich_1.resolveMedia)(enrichParams),
(0, enrich_1.resolveQuotedContent)(enrichParams),
]);
// 7b. Replace Feishu file-key placeholders in content with local
// file paths so the SDK can detect images for native vision and
// the AI receives meaningful file references.
if (mediaResult.mediaList.length > 0) {
ctx = {
...ctx,
content: (0, enrich_1.substituteMediaPaths)(ctx.content, mediaResult.mediaList),
};
}
// 8. Compute commandAuthorized via SDK access group command gating
const core = lark_client_1.LarkClient.runtime;
const isGroup = ctx.chatType === 'group';
const dmPolicy = accountFeishuCfg?.dmPolicy ?? 'pairing';
// Resolve per-group config early — shared by both command authorization
// and dispatch (step 8).
const groupConfig = isGroup ? (0, policy_1.resolveFeishuGroupConfig)({ cfg: accountFeishuCfg, groupId: ctx.chatId }) : undefined;
const defaultGroupConfig = isGroup ? accountFeishuCfg?.groups?.['*'] : undefined;
// Build the sender allowlist for command authorization in group context.
// Excludes legacy oc_xxx chat-id entries (group admission, not sender identity).
//
// When the explicit group sender policy is "open", pass ["*"] to align
// command authorization with chat access (if you can chat, you can run
// commands). When no policy is configured (undefined fallback), default to
// allowlist behaviour — only users in accountFeishuCfg.allowFrom (owner list) or
// an explicit groupAllowFrom/per-group allowFrom can run commands.
const configuredGroupAllowFrom = (() => {
if (!isGroup)
return undefined;
// Exclude legacy oc_xxx chat-id entries from groupAllowFrom (sender filter only).
const { senderAllowFrom } = (0, policy_1.splitLegacyGroupAllowFrom)(accountFeishuCfg?.groupAllowFrom ?? []);
const senderGroupAllowFrom = senderAllowFrom;
const perGroupAllowFrom = (groupConfig?.allowFrom ?? []).map(String);
const defaultSenderAllowFrom = !groupConfig && defaultGroupConfig?.allowFrom ? defaultGroupConfig.allowFrom.map(String) : [];
const combined = [...senderGroupAllowFrom, ...perGroupAllowFrom, ...defaultSenderAllowFrom];
if (combined.length > 0)
return combined;
// No allowFrom list configured — check if sender policy is explicitly "open".
// Do NOT fall back to "open" as a default: unset policy → allowlist behaviour.
const explicitSenderPolicy = groupConfig?.groupPolicy ?? defaultGroupConfig?.groupPolicy ?? accountFeishuCfg?.groupPolicy;
return explicitSenderPolicy === 'open' ? ['*'] : [];
})();
const { commandAuthorized } = await (0, command_auth_1.resolveSenderCommandAuthorization)({
rawBody: ctx.content,
cfg: accountScopedCfg,
isGroup,
dmPolicy,
configuredAllowFrom: (accountFeishuCfg?.allowFrom ?? []).map(String),
configuredGroupAllowFrom,
senderId: ctx.senderId,
isSenderAllowed: (senderId, allowFrom) => (0, allow_from_1.isNormalizedSenderAllowed)({ senderId, allowFrom }),
readAllowFromStore: () => (0, gate_1.readFeishuAllowFromStore)(account.accountId),
shouldComputeCommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: core.channel.commands.resolveCommandAuthorizedFromAuthorizers,
});
// Bot-loop guard: cap consecutive bot↔bot turns so a runaway debate stops
// itself. Only bot senders count; a human turn already reset the counter
// above. Checked after the gate so only messages we would actually act on
// are counted.
if (ctx.senderIsBot) {
// Use root_id when thread_id is absent: in topic groups, reply events
// often carry only root_id (thread_id is inferred later in dispatch), and
// the queue/dispatch key uses the same fallback. Without it, all topics in
// a chat share one chat-level counter and one topic's cutoff suppresses
// the others.
const verdict = (0, bot_loop_guard_1.noteBotTurnAndCheck)(ctx.chatId, ctx.threadId ?? ctx.rootId);
if (!verdict.allowed) {
log(`feishu[${account.accountId}]: bot-loop guard tripped ` +
`(${verdict.count}/${verdict.limit} consecutive bot turns) in ${ctx.chatId}, suppressing reply`);
// Surface the cutoff to humans ONCE, on the first over-cap turn, so the
// conversation doesn't just go silent. Plain text with no @ — so it
// neither wakes the peer bot (allowBots='mentions') nor extends the
// loop. A human message resets the counter and re-arms auto-reply.
//
// Deliver it where the debate actually is: reply to the triggering
// message. Thread the notice only when the bot↔bot reply body would also
// be threaded — i.e. mirror resolveFeishuReplyRouting's effective
// replyInThread for a bot turn: a real thread_id is present AND threading
// is opted in (threadSession or replyInThread). Crucially we must NOT
// treat root_id as a thread here: a plain bot↔bot quote-reply chain in a
// normal group carries root_id but no thread_id, and reply_in_thread=true
// on such a message makes Feishu mint a brand-new topic for just the
// notice — pulling it into a thread the debate itself was never in.
if (verdict.count === verdict.limit + 1) {
try {
// Localized via i18nTexts so the viewer's Feishu client renders the
// notice in its own language (same mechanism as /help, /doctor).
// replyInThread precedence matches dispatch (group > default > account).
const replyInThreadCfg = groupConfig?.replyInThread ??
defaultGroupConfig?.replyInThread ??
account.config?.replyInThread;
const inThread = Boolean(ctx.threadId) &&
(account.config?.threadSession === true || replyInThreadCfg === true);
await (0, send_1.sendMessageFeishu)({
cfg: accountScopedCfg,
to: ctx.chatId,
text: `⏸️ 已达连续 ${verdict.limit} 轮自动对话上限,已暂停自动回复。需要继续请在群里发一条消息。`,
i18nTexts: {
zh_cn: `⏸️ 已达连续 ${verdict.limit} 轮自动对话上限,已暂停自动回复。需要继续请在群里发一条消息。`,
en_us: `⏸️ Reached the limit of ${verdict.limit} consecutive automated turns; auto-replies are paused. Send a message in the chat to continue.`,
},
accountId: account.accountId,
replyToMessageId: replyToMessageId ?? ctx.messageId,
replyInThread: inThread,
threadId: inThread ? ctx.threadId : undefined,
});
}
catch (err) {
log(`feishu[${account.accountId}]: failed to send loop-guard notice: ${String(err)}`);
}
}
return;
}
}
// 9. Dispatch to agent
// groupConfig and defaultGroupConfig are already resolved above.
try {
await (0, dispatch_1.dispatchToAgent)({
ctx,
permissionError,
mediaPayload: mediaResult.payload,
quotedContent,
account,
accountScopedCfg,
runtime,
chatHistories,
historyLimit,
replyToMessageId,
commandAuthorized,
groupConfig,
defaultGroupConfig,
skipTyping,
botOpenId,
});
}
catch (err) {
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
logger.error(`dispatch failed: ${String(err)} (elapsed=${(0, lark_ticket_1.ticketElapsed)()}ms)`);
}
}
(0, handler_registry_1.injectInboundHandler)(handleFeishuMessage);
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Media resolution and payload building for inbound Feishu messages.
*
* Downloads media files based on ResourceDescriptors extracted during
* the content converter phase, and builds the payload object spread
* into the agent envelope.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuMediaInfo, ResourceDescriptor } from '../types';
/**
* Download media files based on pre-extracted ResourceDescriptors from
* the converter phase.
*/
export declare function downloadResources(params: {
cfg: ClawdbotConfig;
messageId: string;
resources: ResourceDescriptor[];
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]>;
export declare function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
};
@@ -0,0 +1,91 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Media resolution and payload building for inbound Feishu messages.
*
* Downloads media files based on ResourceDescriptors extracted during
* the content converter phase, and builds the payload object spread
* into the agent envelope.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.downloadResources = downloadResources;
exports.buildFeishuMediaPayload = buildFeishuMediaPayload;
const lark_client_1 = require("../../core/lark-client.js");
const media_1 = require("../outbound/media.js");
// ---------------------------------------------------------------------------
// Resource-descriptor-based download
// ---------------------------------------------------------------------------
/**
* Download media files based on pre-extracted ResourceDescriptors from
* the converter phase.
*/
async function downloadResources(params) {
const { cfg, messageId, resources, maxBytes, log, accountId } = params;
if (resources.length === 0)
return [];
const out = [];
const core = lark_client_1.LarkClient.runtime;
for (const res of resources) {
try {
const resourceType = res.type === 'image' ? 'image' : 'file';
const result = await (0, media_1.downloadMessageResourceFeishu)({
cfg,
messageId,
fileKey: res.fileKey,
type: resourceType,
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const fileName = result.fileName || res.fileName;
const saved = await core.channel.media.saveMediaBuffer(result.buffer, contentType, 'inbound', maxBytes, fileName);
const placeholder = inferPlaceholderFromType(res.type);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder,
fileKey: res.fileKey,
resourceType: res.type,
});
log?.(`feishu: downloaded ${res.type} resource ${res.fileKey}, saved to ${saved.path}`);
}
catch (err) {
log?.(`feishu: failed to download ${res.type} resource ${res.fileKey}: ${String(err)}`);
}
}
return out;
}
function inferPlaceholderFromType(type) {
switch (type) {
case 'image':
return '<media:image>';
case 'file':
return '<media:document>';
case 'audio':
return '<media:audio>';
case 'video':
return '<media:video>';
case 'sticker':
return '<media:sticker>';
}
}
// ---------------------------------------------------------------------------
// Media payload builder
// ---------------------------------------------------------------------------
function buildFeishuMediaPayload(mediaList) {
const first = mediaList[0];
const mediaPaths = mediaList.map((m) => m.path);
const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean);
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Per-chat name → openId registry.
*
* Backs `normalizeOutboundMentions` / `ensureMention` on the outbound side:
* when the LLM writes "@Alice" in a reply, the outbound layer needs to look
* up which `ou_xxx` "Alice" maps to in this specific chat to produce a real
* Feishu `<at user_id="ou_xxx">` element that actually triggers delivery.
*
* Two ingestion paths feed the registry:
* 1. `recordSender(chatId, openId, name)` — every inbound message's sender
* 2. `recordMention(chatId, openId, name)` — every @-target in inbound text
*
* Entries decay with TTL so stale display names (renames, departed members)
* don't pollute future lookups indefinitely. The registry is process-local;
* it's a best-effort cache, not a source of truth, and a cache miss simply
* means the outbound mention falls back to plain "@Name" text (which Feishu
* won't deliver as a notification — that's a graceful degradation, not a
* functional failure).
*/
/**
* Record an @-mention target observed in inbound text.
*
* The name passed here should be the human-readable display name as parsed
* out of the Feishu mention element, not the raw `@user_xxx` placeholder.
*/
export declare function recordMention(chatId: string, openId: string, name: string): void;
/**
* Record the sender of an inbound message.
*
* Even when the sender is never @-mentioned, recording lets the outbound
* layer @ them back by name. In bot↔bot flows this is the only way the
* receiving bot learns the peer bot's name → openId mapping.
*/
export declare function recordSender(chatId: string, openId: string, name: string): void;
/**
* Look up the openId for a name in a given chat. Returns `undefined` when
* the name has never been seen, or when the entry has aged out past TTL.
*
* Caller may pass either the raw mention spelling ("alice", "Alice", "
* Alice ") — name comparison is case-insensitive and ignores surrounding
* whitespace.
*/
export declare function lookupByName(chatId: string, name: string, opts?: {
ttlMs?: number;
}): string | undefined;
/**
* Reset the registry. Intended for tests; production code should rely on
* TTL expiry instead.
*/
export declare function resetMentionRegistry(): void;
/**
* Drop all entries older than `ttlMs` across every chat. Optional helper
* for callers that want a deterministic cleanup tick — `lookupByName`
* already does lazy eviction on read.
*/
export declare function purgeStaleEntries(ttlMs?: number): void;
@@ -0,0 +1,115 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Per-chat name → openId registry.
*
* Backs `normalizeOutboundMentions` / `ensureMention` on the outbound side:
* when the LLM writes "@Alice" in a reply, the outbound layer needs to look
* up which `ou_xxx` "Alice" maps to in this specific chat to produce a real
* Feishu `<at user_id="ou_xxx">` element that actually triggers delivery.
*
* Two ingestion paths feed the registry:
* 1. `recordSender(chatId, openId, name)` — every inbound message's sender
* 2. `recordMention(chatId, openId, name)` — every @-target in inbound text
*
* Entries decay with TTL so stale display names (renames, departed members)
* don't pollute future lookups indefinitely. The registry is process-local;
* it's a best-effort cache, not a source of truth, and a cache miss simply
* means the outbound mention falls back to plain "@Name" text (which Feishu
* won't deliver as a notification — that's a graceful degradation, not a
* functional failure).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.recordMention = recordMention;
exports.recordSender = recordSender;
exports.lookupByName = lookupByName;
exports.resetMentionRegistry = resetMentionRegistry;
exports.purgeStaleEntries = purgeStaleEntries;
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
// chatId -> lowercased name -> Entry
const registry = new Map();
function normalizeName(name) {
return name.trim().toLowerCase();
}
function getChatMap(chatId) {
let chatMap = registry.get(chatId);
if (!chatMap) {
chatMap = new Map();
registry.set(chatId, chatMap);
}
return chatMap;
}
function recordEntry(chatId, openId, name) {
if (!chatId || !openId || !name)
return;
const key = normalizeName(name);
if (!key)
return;
getChatMap(chatId).set(key, { openId, name, recordedAt: Date.now() });
}
/**
* Record an @-mention target observed in inbound text.
*
* The name passed here should be the human-readable display name as parsed
* out of the Feishu mention element, not the raw `@user_xxx` placeholder.
*/
function recordMention(chatId, openId, name) {
recordEntry(chatId, openId, name);
}
/**
* Record the sender of an inbound message.
*
* Even when the sender is never @-mentioned, recording lets the outbound
* layer @ them back by name. In bot↔bot flows this is the only way the
* receiving bot learns the peer bot's name → openId mapping.
*/
function recordSender(chatId, openId, name) {
recordEntry(chatId, openId, name);
}
/**
* Look up the openId for a name in a given chat. Returns `undefined` when
* the name has never been seen, or when the entry has aged out past TTL.
*
* Caller may pass either the raw mention spelling ("alice", "Alice", "
* Alice ") — name comparison is case-insensitive and ignores surrounding
* whitespace.
*/
function lookupByName(chatId, name, opts = {}) {
const chatMap = registry.get(chatId);
if (!chatMap)
return undefined;
const entry = chatMap.get(normalizeName(name));
if (!entry)
return undefined;
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
if (Date.now() - entry.recordedAt > ttl) {
chatMap.delete(normalizeName(name));
return undefined;
}
return entry.openId;
}
/**
* Reset the registry. Intended for tests; production code should rely on
* TTL expiry instead.
*/
function resetMentionRegistry() {
registry.clear();
}
/**
* Drop all entries older than `ttlMs` across every chat. Optional helper
* for callers that want a deterministic cleanup tick — `lookupByName`
* already does lazy eviction on read.
*/
function purgeStaleEntries(ttlMs = DEFAULT_TTL_MS) {
const cutoff = Date.now() - ttlMs;
for (const [chatId, chatMap] of registry) {
for (const [key, entry] of chatMap) {
if (entry.recordedAt < cutoff)
chatMap.delete(key);
}
if (chatMap.size === 0)
registry.delete(chatId);
}
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* @mention utilities for the Lark/Feishu channel plugin.
*
* All logic is based on `MentionInfo[]` from `MessageContext.mentions`.
* Provides:
* - Derive helpers: `mentionedBot()`, `nonBotMentions()`
* - Format helpers for outbound text and card messages.
*/
import type { MentionInfo, MessageContext } from '../types';
export type { MentionInfo } from '../types';
/**
* Detect whether a raw mention entry represents @all / @所有人.
*
* Feishu @all mentions have `key: "@_all"` and empty ID fields.
* We match on `key` as the primary signal (most stable across locales).
*/
export declare function isMentionAll(mention: {
key: string;
}): boolean;
/** Whether the receiving bot itself was @-mentioned. */
export declare function mentionedBot(ctx: MessageContext): boolean;
/** All mentions excluding the receiving bot itself. */
export declare function nonBotMentions(ctx: MessageContext): MentionInfo[];
/**
* Remove all @mention placeholder keys from the message text.
*/
export declare function extractMessageBody(text: string, allMentionKeys: string[]): string;
/**
* Format a mention for a Feishu text / post message.
* @returns e.g. `<at user_id="ou_xxx">Alice</at>`
*/
export declare function formatMentionForText(target: MentionInfo): string;
/** Format an @everyone mention for text / post. */
export declare function formatMentionAllForText(): string;
/**
* Format a mention for a Feishu Interactive Card.
* @returns e.g. `<at id=ou_xxx></at>`
*/
export declare function formatMentionForCard(target: MentionInfo): string;
/** Format an @everyone mention for card. */
export declare function formatMentionAllForCard(): string;
/** Prepend @mention tags (text format) to a message body. */
export declare function buildMentionedMessage(targets: MentionInfo[], message: string): string;
/** Prepend @mention tags (card format) to card markdown content. */
export declare function buildMentionedCardContent(targets: MentionInfo[], message: string): string;
@@ -0,0 +1,102 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* @mention utilities for the Lark/Feishu channel plugin.
*
* All logic is based on `MentionInfo[]` from `MessageContext.mentions`.
* Provides:
* - Derive helpers: `mentionedBot()`, `nonBotMentions()`
* - Format helpers for outbound text and card messages.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isMentionAll = isMentionAll;
exports.mentionedBot = mentionedBot;
exports.nonBotMentions = nonBotMentions;
exports.extractMessageBody = extractMessageBody;
exports.formatMentionForText = formatMentionForText;
exports.formatMentionAllForText = formatMentionAllForText;
exports.formatMentionForCard = formatMentionForCard;
exports.formatMentionAllForCard = formatMentionAllForCard;
exports.buildMentionedMessage = buildMentionedMessage;
exports.buildMentionedCardContent = buildMentionedCardContent;
const utils_1 = require("../converters/utils.js");
// ---------------------------------------------------------------------------
// Derive helpers (work on MentionInfo[])
// ---------------------------------------------------------------------------
/**
* Detect whether a raw mention entry represents @all / @所有人.
*
* Feishu @all mentions have `key: "@_all"` and empty ID fields.
* We match on `key` as the primary signal (most stable across locales).
*/
function isMentionAll(mention) {
return mention.key === '@_all';
}
/** Whether the receiving bot itself was @-mentioned. */
function mentionedBot(ctx) {
return ctx.mentions.some((m) => m.isBot);
}
/** All mentions excluding the receiving bot itself. */
function nonBotMentions(ctx) {
return ctx.mentions.filter((m) => !m.isBot);
}
// ---------------------------------------------------------------------------
// extractMessageBody
// ---------------------------------------------------------------------------
/**
* Remove all @mention placeholder keys from the message text.
*/
function extractMessageBody(text, allMentionKeys) {
let result = text;
for (const key of allMentionKeys) {
result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key) + '\\s*', 'g'), '');
}
return result.trim();
}
// ---------------------------------------------------------------------------
// Format helpers -- text messages
// ---------------------------------------------------------------------------
/**
* Format a mention for a Feishu text / post message.
* @returns e.g. `<at user_id="ou_xxx">Alice</at>`
*/
function formatMentionForText(target) {
return `<at user_id="${target.openId}">${target.name}</at>`;
}
/** Format an @everyone mention for text / post. */
function formatMentionAllForText() {
return `<at user_id="all">Everyone</at>`;
}
// ---------------------------------------------------------------------------
// Format helpers -- interactive card messages
// ---------------------------------------------------------------------------
/**
* Format a mention for a Feishu Interactive Card.
* @returns e.g. `<at id=ou_xxx></at>`
*/
function formatMentionForCard(target) {
return `<at id=${target.openId}></at>`;
}
/** Format an @everyone mention for card. */
function formatMentionAllForCard() {
return `<at id=all></at>`;
}
// ---------------------------------------------------------------------------
// Build helpers (prepend mentions to message body)
// ---------------------------------------------------------------------------
/** Prepend @mention tags (text format) to a message body. */
function buildMentionedMessage(targets, message) {
if (targets.length === 0)
return message;
const mentionTags = targets.map(formatMentionForText).join(' ');
return `${mentionTags}\n${message}`;
}
/** Prepend @mention tags (card format) to card markdown content. */
function buildMentionedCardContent(targets, message) {
if (targets.length === 0)
return message;
const mentionTags = targets.map(formatMentionForCard).join(' ');
return `${mentionTags}\n${message}`;
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* I/O adapters for inbound message parsing.
*
* Contains API-calling functions that are used during the parse phase
* but separated from the pure parsing logic in parse.ts.
*/
import type { LarkClient as LarkClientType } from '../../core/lark-client';
import type { LarkAccount } from '../../core/types';
/** Shape of a single message item returned by the im/v1/messages API. */
export interface ApiMessageItem {
message_id?: string;
msg_type?: string;
body?: {
content?: string;
};
sender?: {
id?: string;
sender_type?: string;
};
[key: string]: unknown;
}
/**
* 对 interactive 消息,通过 TAT 调用 API 获取完整 v2 卡片内容。
* 事件推送的 content 可能不包含 json_cardAPI 调用可返回完整的 raw_card_content。
* 失败时返回 undefined,调用方 fallback 到原始 content。
*
* Note: `larkClient.sdk` 的类型定义不暴露 raw `request` 方法,
* 因此这里使用 `as any` 断言调用。
*/
export declare function fetchCardContent(messageId: string, larkClient: LarkClientType): Promise<string | undefined>;
/**
* Create a `fetchSubMessages` callback for use in `ConvertContext`.
*
* The returned function calls the im/v1/messages API to fetch
* sub-messages of a merge_forward message.
*
* Note: `larkClient.sdk` 的类型定义不暴露 raw `request` 方法,
* 因此这里使用 `as any` 断言调用。
*/
export declare function createFetchSubMessages(larkClient: LarkClientType): (msgId: string) => Promise<ApiMessageItem[]>;
/**
* Create a `batchResolveNames` callback for use in `ConvertContext`.
*
* Wraps `createBatchResolveNames` from user-name-cache.ts, providing
* the account and log function.
*/
export declare function createParseResolveNames(account: LarkAccount): (openIds: string[]) => Promise<void>;
@@ -0,0 +1,86 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* I/O adapters for inbound message parsing.
*
* Contains API-calling functions that are used during the parse phase
* but separated from the pure parsing logic in parse.ts.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchCardContent = fetchCardContent;
exports.createFetchSubMessages = createFetchSubMessages;
exports.createParseResolveNames = createParseResolveNames;
const lark_logger_1 = require("../../core/lark-logger.js");
const user_name_cache_1 = require("./user-name-cache.js");
const log = (0, lark_logger_1.larkLogger)('inbound/parse-io');
// ---------------------------------------------------------------------------
// Card content fetcher
// ---------------------------------------------------------------------------
/**
* 对 interactive 消息,通过 TAT 调用 API 获取完整 v2 卡片内容。
* 事件推送的 content 可能不包含 json_cardAPI 调用可返回完整的 raw_card_content。
* 失败时返回 undefined,调用方 fallback 到原始 content。
*
* Note: `larkClient.sdk` 的类型定义不暴露 raw `request` 方法,
* 因此这里使用 `as any` 断言调用。
*/
async function fetchCardContent(messageId, larkClient) {
try {
// SDK 类型不暴露 raw request 方法,需要 as any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await larkClient.sdk.request({
method: 'GET',
url: `/open-apis/im/v1/messages/${messageId}`,
params: {
user_id_type: 'open_id',
card_msg_content_type: 'raw_card_content',
},
});
return response?.data?.items?.[0]?.body?.content ?? undefined;
}
catch (err) {
log.warn(`fetchCardContent failed for ${messageId}: ${err instanceof Error ? err.message : String(err)}`);
return undefined;
}
}
// ---------------------------------------------------------------------------
// Sub-message fetcher (for merge_forward)
// ---------------------------------------------------------------------------
/**
* Create a `fetchSubMessages` callback for use in `ConvertContext`.
*
* The returned function calls the im/v1/messages API to fetch
* sub-messages of a merge_forward message.
*
* Note: `larkClient.sdk` 的类型定义不暴露 raw `request` 方法,
* 因此这里使用 `as any` 断言调用。
*/
function createFetchSubMessages(larkClient) {
return async (msgId) => {
// SDK 类型不暴露 raw request 方法,需要 as any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await larkClient.sdk.request({
method: 'GET',
url: `/open-apis/im/v1/messages/${msgId}`,
params: { user_id_type: 'open_id', card_msg_content_type: 'raw_card_content' },
});
if (response?.code !== 0) {
throw new Error(`API error: code=${response?.code} msg=${response?.msg}`);
}
return response?.data?.items ?? [];
};
}
// ---------------------------------------------------------------------------
// Batch resolve names callback factory
// ---------------------------------------------------------------------------
/**
* Create a `batchResolveNames` callback for use in `ConvertContext`.
*
* Wraps `createBatchResolveNames` from user-name-cache.ts, providing
* the account and log function.
*/
function createParseResolveNames(account) {
return (0, user_name_cache_1.createBatchResolveNames)(account, (...args) => log.info(args.map(String).join(' ')));
}
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event parsing for inbound Feishu messages.
*
* Converts a raw FeishuMessageEvent into a normalised MessageContext.
* All mention information is captured in `mentions: MentionInfo[]`;
* downstream logic derives `mentionedBot` and non-bot targets from it.
*
* When `expandCtx` is provided, `cfg` and `accountId` are passed into
* the converter context so that async converters (e.g. merge_forward)
* can make API calls during parsing.
*/
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
import type { FeishuMessageEvent, MessageContext } from '../types';
/**
* Parse a raw Feishu message event into a normalised MessageContext.
*
* @param expandCtx When provided, cfg/accountId are used to create
* callbacks for async converters (e.g. merge_forward)
* to fetch sub-messages and resolve sender names.
*/
export declare function parseMessageEvent(event: FeishuMessageEvent, botOpenId?: string, expandCtx?: {
/** account 级别的 ClawdbotConfigchannels.feishu 已替换为 per-account 合并后的配置) */
cfg: ClawdbotConfig;
accountId?: string;
}): Promise<MessageContext>;
@@ -0,0 +1,128 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Event parsing for inbound Feishu messages.
*
* Converts a raw FeishuMessageEvent into a normalised MessageContext.
* All mention information is captured in `mentions: MentionInfo[]`;
* downstream logic derives `mentionedBot` and non-bot targets from it.
*
* When `expandCtx` is provided, `cfg` and `accountId` are passed into
* the converter context so that async converters (e.g. merge_forward)
* can make API calls during parsing.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseMessageEvent = parseMessageEvent;
const content_converter_1 = require("../converters/content-converter.js");
const accounts_1 = require("../../core/accounts.js");
const lark_client_1 = require("../../core/lark-client.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const mention_1 = require("./mention.js");
const user_name_cache_1 = require("./user-name-cache.js");
const parse_io_1 = require("./parse-io.js");
const log = (0, lark_logger_1.larkLogger)('inbound/parse');
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse a raw Feishu message event into a normalised MessageContext.
*
* @param expandCtx When provided, cfg/accountId are used to create
* callbacks for async converters (e.g. merge_forward)
* to fetch sub-messages and resolve sender names.
*/
async function parseMessageEvent(event, botOpenId, expandCtx) {
// 1. Build MentionInfo list from event mentions
const mentionMap = new Map();
const mentionList = [];
let mentionAll = false;
for (const m of event.message.mentions ?? []) {
// Detect @all / @所有人: add to mentionMap (for text replacement by
// resolveMentions) but not mentionList (not a user mention).
if ((0, mention_1.isMentionAll)(m)) {
mentionAll = true;
mentionMap.set(m.key, {
key: m.key,
openId: '',
name: m.name,
isBot: false,
});
continue;
}
const openId = m.id?.open_id ?? '';
if (!openId)
continue;
const info = {
key: m.key,
openId,
name: m.name,
isBot: Boolean(botOpenId && openId === botOpenId),
};
mentionMap.set(m.key, info);
mentionList.push(info);
}
// Build reverse map for O(1) openId lookup
const mentionsByOpenId = new Map();
for (const info of mentionList) {
mentionsByOpenId.set(info.openId, info);
}
// 2. Convert content via registered converter
const acctId = expandCtx?.accountId;
// Create larkClient once when expandCtx is available (used for merge_forward & card fetch)
const larkClient = expandCtx ? lark_client_1.LarkClient.fromCfg(expandCtx.cfg, acctId) : undefined;
// Build merge_forward callbacks when expandCtx is provided
let fetchSubMessages;
let batchResolveNames;
if (expandCtx) {
const account = (0, accounts_1.getLarkAccount)(expandCtx.cfg, acctId);
fetchSubMessages = (0, parse_io_1.createFetchSubMessages)(larkClient);
batchResolveNames = (0, parse_io_1.createParseResolveNames)(account);
}
// For interactive messages, fetch full v2 card content via API
let effectiveContent = event.message.content;
if (event.message.message_type === 'interactive' && expandCtx) {
const fullContent = await (0, parse_io_1.fetchCardContent)(event.message.message_id, larkClient);
if (fullContent) {
effectiveContent = fullContent;
log.info('replaced interactive content with full v2 card data');
}
}
const convertCtx = {
mentions: mentionMap,
mentionsByOpenId,
messageId: event.message.message_id,
botOpenId,
cfg: expandCtx?.cfg,
accountId: acctId,
resolveUserName: acctId ? (openId) => (0, user_name_cache_1.getUserNameCache)(acctId).get(openId) : undefined,
fetchSubMessages,
batchResolveNames,
stripBotMentions: true,
};
const { content, resources } = await (0, content_converter_1.convertMessageContent)(effectiveContent, event.message.message_type, convertCtx);
const createTimeStr = event.message.create_time;
const createTime = createTimeStr ? parseInt(createTimeStr, 10) : undefined;
return {
chatId: event.message.chat_id,
messageId: event.message.message_id,
senderId: event.sender.sender_id.open_id || '',
chatType: event.message.chat_type,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
threadId: event.message.thread_id || undefined,
content,
contentType: event.message.message_type,
resources,
mentions: mentionList,
mentionAll,
// Per Feishu docs, im.message.receive_v1 sets sender_type to 'user' or
// 'bot'. We also accept 'app' defensively for any SDK/legacy variant that
// surfaces the older value.
senderIsBot: event.sender.sender_type === 'bot' || event.sender.sender_type === 'app',
createTime: Number.isNaN(createTime) ? undefined : createTime,
rawMessage: effectiveContent !== event.message.content ? { ...event.message, content: effectiveContent } : event.message,
rawSender: event.sender,
};
}
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Permission error extraction and cooldown tracking for Feishu API calls.
*
* Extracted from bot.ts: PermissionError type, extractPermissionError,
* PERMISSION_ERROR_COOLDOWN_MS, permissionErrorNotifiedAt.
*/
export interface PermissionError {
code: number;
message: string;
grantUrl?: string;
}
export declare function extractPermissionError(err: unknown): PermissionError | null;
export declare const PERMISSION_ERROR_COOLDOWN_MS: number;
export declare const permissionErrorNotifiedAt: Map<string, number>;
@@ -0,0 +1,44 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Permission error extraction and cooldown tracking for Feishu API calls.
*
* Extracted from bot.ts: PermissionError type, extractPermissionError,
* PERMISSION_ERROR_COOLDOWN_MS, permissionErrorNotifiedAt.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.permissionErrorNotifiedAt = exports.PERMISSION_ERROR_COOLDOWN_MS = void 0;
exports.extractPermissionError = extractPermissionError;
const permission_url_1 = require("../../core/permission-url.js");
const auth_errors_1 = require("../../core/auth-errors.js");
// ---------------------------------------------------------------------------
// Permission error extraction
// ---------------------------------------------------------------------------
function extractPermissionError(err) {
if (!err || typeof err !== 'object') {
return null;
}
const axiosErr = err;
const data = axiosErr.response?.data;
if (!data || typeof data !== 'object') {
return null;
}
const feishuErr = data;
// Feishu permission error code
if (feishuErr.code !== auth_errors_1.LARK_ERROR.APP_SCOPE_MISSING) {
return null;
}
const msg = feishuErr.msg ?? '';
const grantUrl = (0, permission_url_1.extractPermissionGrantUrl)(msg);
if (!grantUrl) {
return null;
}
return { code: feishuErr.code, message: msg, grantUrl };
}
// ---------------------------------------------------------------------------
// Cooldown tracking
// ---------------------------------------------------------------------------
exports.PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
exports.permissionErrorNotifiedAt = new Map();
@@ -0,0 +1,95 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Access control policies for the Lark/Feishu channel plugin.
*
* Provides allowlist matching, group configuration lookup, tool policy
* extraction, and group access checks.
*/
import type { ChannelGroupContext } from 'openclaw/plugin-sdk/channel-contract';
import type { GroupToolPolicyConfig } from 'openclaw/plugin-sdk/channel-policy';
import type { FeishuConfig, FeishuGroupConfig } from '../../core/types';
export interface FeishuAllowlistMatch {
allowed: boolean;
matchKey?: string;
matchSource?: 'wildcard' | 'id' | 'name';
}
/**
* Check whether a sender is permitted by a given allowlist.
*
* Entries are normalised to lowercase strings before comparison.
* A single "*" entry acts as a wildcard that matches everyone.
* When the allowlist is empty the result is `{ allowed: false }`.
*/
export declare function resolveFeishuAllowlistMatch(params: {
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): FeishuAllowlistMatch;
/**
* Look up the per-group configuration by group ID.
*
* Performs a case-insensitive lookup against the keys in `cfg.groups`.
* Returns `undefined` when no matching group entry is found.
*/
export declare function resolveFeishuGroupConfig(params: {
cfg?: FeishuConfig;
groupId?: string | null;
}): FeishuGroupConfig | undefined;
/**
* Extract the tool policy configuration from the group config that
* corresponds to the given group context.
*
* ★ 多账号配置隔离:SDK 回调传入的 params.cfg 是顶层全局配置,
* cfg.channels.feishu 不包含 per-account 的覆盖值。
* 这里通过 getLarkAccount() 获取当前 account 合并后的配置,
* 确保每个账号的 groups / tool policy 配置独立生效。
*/
export declare function resolveFeishuGroupToolPolicy(params: ChannelGroupContext): GroupToolPolicyConfig | undefined;
/**
* Determine whether an inbound group message should be processed.
*
* - `disabled` --> always rejected
* - `open` --> always allowed
* - `allowlist` --> allowed only when the sender matches the allowlist
*/
export declare function isFeishuGroupAllowed(params: {
groupPolicy: 'open' | 'allowlist' | 'disabled';
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): boolean;
/**
* Split a raw `groupAllowFrom` array into legacy chat-ID entries
* (`oc_xxx`) and sender-level entries.
*
* Older Feishu configs used `groupAllowFrom` with `oc_xxx` chat IDs to
* control which groups are allowed. The correct semantic (aligned with
* Telegram) is sender IDs. This function separates the two concerns so
* both layers can work independently.
*/
export declare function splitLegacyGroupAllowFrom(rawGroupAllowFrom: Array<string | number>): {
legacyChatIds: string[];
senderAllowFrom: string[];
};
/**
* Resolve the effective sender-level group policy and the merged
* `allowFrom` list for sender filtering within a group.
*
* The precedence chain for `senderPolicy` is:
* per-group `groupPolicy` > default ("*") group `groupPolicy` >
* global `groupPolicy` > "open" (default).
*
* The `senderAllowFrom` is the union of global (non-oc_) entries,
* per-group entries, and default ("*") entries (when no per-group config).
*/
export declare function resolveGroupSenderPolicyContext(params: {
groupConfig?: FeishuGroupConfig;
defaultConfig?: FeishuGroupConfig;
accountFeishuCfg?: FeishuConfig;
senderGroupAllowFrom: Array<string | number>;
}): {
senderPolicy: 'open' | 'allowlist' | 'disabled';
senderAllowFrom: Array<string | number>;
};
@@ -0,0 +1,168 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Access control policies for the Lark/Feishu channel plugin.
*
* Provides allowlist matching, group configuration lookup, tool policy
* extraction, and group access checks.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveFeishuAllowlistMatch = resolveFeishuAllowlistMatch;
exports.resolveFeishuGroupConfig = resolveFeishuGroupConfig;
exports.resolveFeishuGroupToolPolicy = resolveFeishuGroupToolPolicy;
exports.isFeishuGroupAllowed = isFeishuGroupAllowed;
exports.splitLegacyGroupAllowFrom = splitLegacyGroupAllowFrom;
exports.resolveGroupSenderPolicyContext = resolveGroupSenderPolicyContext;
const accounts_1 = require("../../core/accounts.js");
/**
* Check whether a sender is permitted by a given allowlist.
*
* Entries are normalised to lowercase strings before comparison.
* A single "*" entry acts as a wildcard that matches everyone.
* When the allowlist is empty the result is `{ allowed: false }`.
*/
function resolveFeishuAllowlistMatch(params) {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean);
if (allowFrom.length === 0) {
return { allowed: false };
}
// Wildcard: allow everyone
if (allowFrom.includes('*')) {
return { allowed: true, matchKey: '*', matchSource: 'wildcard' };
}
// Match by sender ID
const senderId = params.senderId.toLowerCase();
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: 'id' };
}
/* // Match by sender display name
const senderName = params.senderName?.toLowerCase();
if (senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: 'name' };
}*/
return { allowed: false };
}
// ---------------------------------------------------------------------------
// Group configuration lookup
// ---------------------------------------------------------------------------
/**
* Look up the per-group configuration by group ID.
*
* Performs a case-insensitive lookup against the keys in `cfg.groups`.
* Returns `undefined` when no matching group entry is found.
*/
function resolveFeishuGroupConfig(params) {
const groups = params.cfg?.groups ?? {};
const groupId = params.groupId?.trim();
if (!groupId) {
return undefined;
}
// Direct (exact-key) lookup first
const direct = groups[groupId];
if (direct) {
return direct;
}
// Case-insensitive fallback
const lowered = groupId.toLowerCase();
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
return matchKey ? groups[matchKey] : undefined;
}
// ---------------------------------------------------------------------------
// Group tool policy
// ---------------------------------------------------------------------------
/**
* Extract the tool policy configuration from the group config that
* corresponds to the given group context.
*
* ★ 多账号配置隔离:SDK 回调传入的 params.cfg 是顶层全局配置,
* cfg.channels.feishu 不包含 per-account 的覆盖值。
* 这里通过 getLarkAccount() 获取当前 account 合并后的配置,
* 确保每个账号的 groups / tool policy 配置独立生效。
*/
function resolveFeishuGroupToolPolicy(params) {
// 使用 getLarkAccount 获取 per-account 合并后的飞书渠道配置,
// 而非直接读取 cfg.channels.feishu(顶层全局配置)。
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId ?? undefined);
const accountFeishuCfg = account.config;
if (!accountFeishuCfg) {
return undefined;
}
const groupConfig = resolveFeishuGroupConfig({
cfg: accountFeishuCfg,
groupId: params.groupId,
});
return groupConfig?.tools;
}
// ---------------------------------------------------------------------------
// Group access gate
// ---------------------------------------------------------------------------
/**
* Determine whether an inbound group message should be processed.
*
* - `disabled` --> always rejected
* - `open` --> always allowed
* - `allowlist` --> allowed only when the sender matches the allowlist
*/
function isFeishuGroupAllowed(params) {
const { groupPolicy } = params;
if (groupPolicy === 'disabled') {
return false;
}
if (groupPolicy === 'open') {
return true;
}
// allowlist
return resolveFeishuAllowlistMatch(params).allowed;
}
// ---------------------------------------------------------------------------
// Legacy compat: groupAllowFrom splitting
// ---------------------------------------------------------------------------
/**
* Split a raw `groupAllowFrom` array into legacy chat-ID entries
* (`oc_xxx`) and sender-level entries.
*
* Older Feishu configs used `groupAllowFrom` with `oc_xxx` chat IDs to
* control which groups are allowed. The correct semantic (aligned with
* Telegram) is sender IDs. This function separates the two concerns so
* both layers can work independently.
*/
function splitLegacyGroupAllowFrom(rawGroupAllowFrom) {
const legacyChatIds = [];
const senderAllowFrom = [];
for (const entry of rawGroupAllowFrom) {
const str = String(entry);
if (str.startsWith('oc_')) {
legacyChatIds.push(str);
}
else {
senderAllowFrom.push(str);
}
}
return { legacyChatIds, senderAllowFrom };
}
// ---------------------------------------------------------------------------
// Sender policy context resolution
// ---------------------------------------------------------------------------
/**
* Resolve the effective sender-level group policy and the merged
* `allowFrom` list for sender filtering within a group.
*
* The precedence chain for `senderPolicy` is:
* per-group `groupPolicy` > default ("*") group `groupPolicy` >
* global `groupPolicy` > "open" (default).
*
* The `senderAllowFrom` is the union of global (non-oc_) entries,
* per-group entries, and default ("*") entries (when no per-group config).
*/
function resolveGroupSenderPolicyContext(params) {
const { groupConfig, defaultConfig, accountFeishuCfg, senderGroupAllowFrom } = params;
const senderPolicy = groupConfig?.groupPolicy ?? defaultConfig?.groupPolicy ?? accountFeishuCfg?.groupPolicy ?? 'open';
const senderAllowFrom = [
...senderGroupAllowFrom,
...(groupConfig?.allowFrom ?? []),
...(!groupConfig && defaultConfig?.allowFrom ? defaultConfig.allowFrom : []),
];
return { senderPolicy, senderAllowFrom };
}
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reaction event handler for the Lark/Feishu channel plugin.
*
* Handles `im.message.reaction.created_v1` events by building a
* {@link MessageContext} directly and dispatching to the agent via
* {@link dispatchToAgent}, bypassing the full 7-stage message pipeline.
*
* Controlled by `reactionNotifications` (default: "own"):
* - `"off"` — reaction events are silently ignored.
* - `"own"` — only reactions on the bot's own messages are dispatched.
* - `"all"` — reactions on any message in the chat are dispatched.
*/
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
import type { FeishuReactionCreatedEvent } from '../types';
import { type FeishuMessageInfo } from '../shared/message-lookup';
export interface ReactionContext {
/** Real chatId (from message API, or `p2p:${operatorOpenId}` fallback). */
chatId: string;
/** Resolved chat type. */
chatType: 'p2p' | 'group';
/** Thread ID from the fetched message, if any. */
threadId?: string;
/** Whether the chat is thread-capable (topic or thread-mode group). */
threadCapable?: boolean;
/** Fetched message info used to build the synthetic event. */
msg: FeishuMessageInfo;
}
/**
* Pre-resolve reaction context before enqueuing.
*
* Performs account config checks, safety filters, API fetch of the
* original message, ownership verification, chat type resolution, and
* thread-capable detection. Returns `null` when the reaction should
* be skipped (mode off, safety filter, timeout, ownership mismatch,
* thread-capable group with threadSession enabled).
*
* This function is intentionally separated so that the caller
* (event-handlers.ts) can resolve the real chatId *before* enqueuing,
* ensuring the reaction shares the same queue key as normal messages
* for the same chat.
*/
export declare function resolveReactionContext(params: {
cfg: ClawdbotConfig;
event: FeishuReactionCreatedEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
}): Promise<ReactionContext | null>;
export declare function handleFeishuReaction(params: {
cfg: ClawdbotConfig;
event: FeishuReactionCreatedEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
/** Pre-resolved context from resolveReactionContext(). */
preResolved: ReactionContext;
}): Promise<void>;
@@ -0,0 +1,259 @@
"use strict";
/**
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*
* Reaction event handler for the Lark/Feishu channel plugin.
*
* Handles `im.message.reaction.created_v1` events by building a
* {@link MessageContext} directly and dispatching to the agent via
* {@link dispatchToAgent}, bypassing the full 7-stage message pipeline.
*
* Controlled by `reactionNotifications` (default: "own"):
* - `"off"` — reaction events are silently ignored.
* - `"own"` — only reactions on the bot's own messages are dispatched.
* - `"all"` — reactions on any message in the chat are dispatched.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveReactionContext = resolveReactionContext;
exports.handleFeishuReaction = handleFeishuReaction;
const crypto = __importStar(require("node:crypto"));
const reply_history_1 = require("openclaw/plugin-sdk/reply-history");
const accounts_1 = require("../../core/accounts.js");
const message_lookup_1 = require("../shared/message-lookup.js");
const chat_info_cache_1 = require("../../core/chat-info-cache.js");
const lark_logger_1 = require("../../core/lark-logger.js");
const user_name_cache_1 = require("./user-name-cache.js");
const dispatch_1 = require("./dispatch.js");
const policy_1 = require("./policy.js");
const logger = (0, lark_logger_1.larkLogger)('inbound/reaction-handler');
const REACTION_VERIFY_TIMEOUT_MS = 3_000;
/**
* Pre-resolve reaction context before enqueuing.
*
* Performs account config checks, safety filters, API fetch of the
* original message, ownership verification, chat type resolution, and
* thread-capable detection. Returns `null` when the reaction should
* be skipped (mode off, safety filter, timeout, ownership mismatch,
* thread-capable group with threadSession enabled).
*
* This function is intentionally separated so that the caller
* (event-handlers.ts) can resolve the real chatId *before* enqueuing,
* ensuring the reaction shares the same queue key as normal messages
* for the same chat.
*/
async function resolveReactionContext(params) {
const { cfg, event, botOpenId, runtime, accountId } = params;
const log = runtime?.log ?? ((...args) => logger.info(args.map(String).join(' ')));
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const reactionMode = account.config?.reactionNotifications ?? 'own';
if (reactionMode === 'off') {
return null;
}
const emojiType = event.reaction_type?.emoji_type;
const messageId = event.message_id;
const operatorOpenId = event.user_id?.open_id ?? '';
if (!emojiType || !messageId || !operatorOpenId) {
return null;
}
// ---- Safety filters (aligned with official) ----
if (event.operator_type === 'app' || operatorOpenId === botOpenId) {
log(`feishu[${accountId}]: ignoring app/self reaction on ${messageId}`);
return null;
}
if (emojiType === 'Typing') {
return null;
}
// "own" mode requires botOpenId to verify message ownership
if (reactionMode === 'own' && !botOpenId) {
log(`feishu[${accountId}]: bot open_id unavailable, skipping reaction on ${messageId}`);
return null;
}
// ---- Fetch original message with timeout (fail-closed) ----
const msg = await Promise.race([
(0, message_lookup_1.getMessageFeishu)({ cfg, messageId, accountId }),
new Promise((resolve) => setTimeout(() => resolve(null), REACTION_VERIFY_TIMEOUT_MS)),
]).catch(() => null);
if (!msg) {
log(`feishu[${accountId}]: reacted message ${messageId} not found or timed out, skipping`);
return null;
}
// mget API returns app_id (cli_xxx) as sender.id for bot messages.
const isBotMessage = msg.senderType === 'app' && msg.senderId === account.appId;
const isOtherBotMessage = msg.senderType === 'app' && account.appId && msg.senderId !== account.appId;
// 'own': only react to this bot's messages; 'all': also skip other bots' messages.
if ((reactionMode === 'own' && !isBotMessage) || (reactionMode === 'all' && isOtherBotMessage)) {
log(`feishu[${accountId}]: reaction on ${isOtherBotMessage ? 'other bot' : 'non-bot'} message ${messageId}, skipping`);
return null;
}
// ---- Resolve effective chatId ----
const rawChatId = event.chat_id?.trim() || msg.chatId?.trim() || '';
const effectiveChatId = rawChatId || `p2p:${operatorOpenId}`;
// ---- Resolve chat type ----
// im.message.reaction.created_v1 does NOT include chat_id or chat_type
// (confirmed from Feishu docs). The message GET API returns chat_id but
// NOT chat_type. So we must determine chat_type via im.chat.get.
//
// Determine chat type: event payload → fetched message → im.chat.get API.
// The first two sources are almost always empty for reaction events, so
// getChatTypeFeishu is the primary path.
let chatType = event.chat_type === 'group'
? 'group'
: event.chat_type === 'p2p' || event.chat_type === 'private'
? 'p2p'
: msg.chatType === 'group' || msg.chatType === 'p2p'
? msg.chatType
: 'p2p'; // tentative default, overridden below when chatId is available
// When we have a real chat_id (from event or message API), query the
// authoritative chat type via im.chat.get. This is the only reliable
// source for reaction events.
if (rawChatId && chatType === 'p2p' && !event.chat_type && !msg.chatType) {
try {
chatType = await (0, chat_info_cache_1.getChatTypeFeishu)({ cfg, chatId: rawChatId, accountId });
}
catch {
// getChatTypeFeishu already logs errors and defaults to "p2p"
}
}
// ---- Thread session: skip for thread-capable groups ----
// The mget API does not return thread_id, so we cannot route the
// synthetic event to the correct thread session. Skip reaction handling
// only for thread-capable groups (topic / thread-mode); p2p and regular
// groups are unaffected since they have no threads.
let threadCapable = false;
const threadSessionEnabled = account.config?.threadSession === true;
if (rawChatId && chatType === 'group') {
threadCapable = await (0, chat_info_cache_1.isThreadCapableGroup)({ cfg, chatId: rawChatId, accountId });
if (threadSessionEnabled && threadCapable) {
log(`feishu[${accountId}]: reaction on thread-capable group ${rawChatId}, skipping (threadSession enabled)`);
return null;
}
}
return {
chatId: effectiveChatId,
chatType,
threadId: msg.threadId,
threadCapable,
msg,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
async function handleFeishuReaction(params) {
const { cfg, event, runtime, chatHistories, accountId, preResolved } = params;
const log = runtime?.log ?? ((...args) => logger.info(args.map(String).join(' ')));
const error = runtime?.error ?? ((...args) => logger.error(args.map(String).join(' ')));
const emojiType = event.reaction_type?.emoji_type ?? '';
const messageId = event.message_id;
const operatorOpenId = event.user_id?.open_id ?? '';
// ---- Step A: Account resolution + accountScopedCfg ----
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
const accountFeishuCfg = account.config;
const accountScopedCfg = {
...cfg,
channels: { ...cfg.channels, feishu: accountFeishuCfg },
};
// ---- Step B: Build MessageContext directly ----
const excerpt = preResolved.msg.content.length > 200 ? `${preResolved.msg.content.slice(0, 200)}` : preResolved.msg.content;
const syntheticText = excerpt
? `[reacted with ${emojiType} to message ${messageId}: "${excerpt}"]`
: `[reacted with ${emojiType} to message ${messageId}]`;
const syntheticMessageId = `${messageId}:reaction:${emojiType}:${crypto.randomUUID()}`;
let ctx = {
chatId: preResolved.chatId,
messageId: syntheticMessageId,
senderId: operatorOpenId,
chatType: preResolved.chatType,
content: syntheticText,
contentType: 'text',
resources: [],
mentions: [],
mentionAll: false,
threadId: preResolved.threadId,
rawMessage: {
message_id: syntheticMessageId,
chat_id: preResolved.chatId,
chat_type: preResolved.chatType,
message_type: 'text',
content: JSON.stringify({ text: syntheticText }),
create_time: event.action_time ?? String(Date.now()),
thread_id: preResolved.threadId,
},
rawSender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.user_id?.user_id,
union_id: event.user_id?.union_id,
},
sender_type: 'user',
},
};
// ---- Step C: Sender name resolution ----
const senderResult = await (0, user_name_cache_1.resolveUserName)({ account, openId: operatorOpenId, log });
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
log(`feishu[${accountId}]: reaction "${emojiType}" by ${operatorOpenId} on ${messageId} (chatId=${preResolved.chatId}, chatType=${preResolved.chatType}${preResolved.threadId ? `, thread=${preResolved.threadId}` : ''}), dispatching to AI`);
logger.info(`reaction "${emojiType}" by ${operatorOpenId} on ${messageId} (chatType=${preResolved.chatType})`);
// ---- Step D: Group config resolution ----
const isGroup = ctx.chatType === 'group';
const groupConfig = isGroup ? (0, policy_1.resolveFeishuGroupConfig)({ cfg: accountFeishuCfg, groupId: ctx.chatId }) : undefined;
const defaultGroupConfig = isGroup ? accountFeishuCfg?.groups?.['*'] : undefined;
const historyLimit = Math.max(0, accountFeishuCfg?.historyLimit ?? accountScopedCfg.messages?.groupChat?.historyLimit ?? reply_history_1.DEFAULT_GROUP_HISTORY_LIMIT);
// ---- Step E: Dispatch directly to agent ----
try {
await (0, dispatch_1.dispatchToAgent)({
ctx,
permissionError: undefined,
mediaPayload: {},
quotedContent: undefined,
account,
accountScopedCfg,
runtime,
chatHistories,
historyLimit,
replyToMessageId: messageId,
commandAuthorized: false,
groupConfig,
defaultGroupConfig,
skipTyping: true,
});
}
catch (err) {
error(`feishu[${accountId}]: error dispatching reaction event: ${String(err)}`);
}
}

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