diff --git a/src/messaging/converters/content-converter-helpers.ts b/src/messaging/converters/content-converter-helpers.ts index 8c938b3e..b0704a1d 100644 --- a/src/messaging/converters/content-converter-helpers.ts +++ b/src/messaging/converters/content-converter-helpers.ts @@ -31,6 +31,7 @@ export function buildConvertContextFromItem( item: ApiMessageItem, fallbackMessageId: string, accountId?: string, + botOpenId?: string, ): ConvertContext { const mentions = new Map(); const mentionsByOpenId = new Map(); @@ -43,7 +44,7 @@ export function buildConvertContextFromItem( key: m.key, openId, name: m.name ?? '', - isBot: false, + isBot: Boolean(botOpenId && openId === botOpenId), }; mentions.set(m.key, info); mentionsByOpenId.set(openId, info); diff --git a/src/messaging/inbound/dispatch-builders.ts b/src/messaging/inbound/dispatch-builders.ts index 1bed983a..cc659194 100644 --- a/src/messaging/inbound/dispatch-builders.ts +++ b/src/messaging/inbound/dispatch-builders.ts @@ -108,12 +108,12 @@ export function buildMessageBody( } /** - * Build the BodyForAgent value: the clean message content plus an - * optional mention annotation. + * Build the BodyForAgent value: the clean message content plus optional + * mention annotations. * * 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 + * 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 diff --git a/src/messaging/inbound/dispatch-context.ts b/src/messaging/inbound/dispatch-context.ts index e3101ece..2dfc58ad 100644 --- a/src/messaging/inbound/dispatch-context.ts +++ b/src/messaging/inbound/dispatch-context.ts @@ -42,6 +42,7 @@ export interface DispatchContext { route: ReturnType; threadSessionKey?: string; commandAuthorized?: boolean; + botOpenId?: string; } // --------------------------------------------------------------------------- @@ -75,6 +76,7 @@ export function buildDispatchContext(params: { accountScopedCfg: ClawdbotConfig; runtime?: RuntimeEnv; commandAuthorized?: boolean; + botOpenId?: string; }): DispatchContext { const { ctx, account, accountScopedCfg } = params; @@ -85,6 +87,7 @@ export function buildDispatchContext(params: { const isGroup = !isComment && ctx.chatType === 'group'; const isThread = isGroup && Boolean(ctx.threadId); const core = LarkClient.runtime; + const botOpenId = params.botOpenId ?? LarkClient.fromAccount(account).botOpenId; const feishuFrom = `feishu:${ctx.senderId}`; // Comment targets use the comment target string directly as the "To" @@ -151,6 +154,7 @@ export function buildDispatchContext(params: { route, threadSessionKey: undefined, commandAuthorized: params.commandAuthorized, + botOpenId, }; } diff --git a/src/messaging/inbound/dispatch.ts b/src/messaging/inbound/dispatch.ts index b9ce3440..45ec6101 100644 --- a/src/messaging/inbound/dispatch.ts +++ b/src/messaging/inbound/dispatch.ts @@ -56,6 +56,13 @@ import { resolveRespondToMentionAll } from './gate'; const log = larkLogger('inbound/dispatch'); +export function quotedMentionOpenIdsMentionCurrentBot( + quotedMentionOpenIds: string[] | undefined, + dc: DispatchContext, +): boolean { + return Boolean(dc.botOpenId && quotedMentionOpenIds?.includes(dc.botOpenId)); +} + // --------------------------------------------------------------------------- // Internal: normal message dispatch // --------------------------------------------------------------------------- @@ -328,6 +335,8 @@ export async function dispatchToAgent(params: { /** Additional structured metadata for synthetic or event-driven inbound flows. */ extraInboundFields?: Record; quotedContent?: string; + quotedMentionOpenIds?: string[]; + botOpenId?: string; account: LarkAccount; /** account 级别的 ClawdbotConfig(channels.feishu 已替换为 per-account 合并后的配置) */ accountScopedCfg: ClawdbotConfig; @@ -453,6 +462,7 @@ export async function dispatchToAgent(params: { messageSid: params.ctx.messageId, wasMentioned: mentionedBot(params.ctx) || + quotedMentionOpenIdsMentionCurrentBot(params.quotedMentionOpenIds, dc) || (params.ctx.mentionAll && resolveRespondToMentionAll({ groupConfig: params.groupConfig, diff --git a/src/messaging/inbound/enrich.ts b/src/messaging/inbound/enrich.ts index 9e3bdad8..edf0df2d 100644 --- a/src/messaging/inbound/enrich.ts +++ b/src/messaging/inbound/enrich.ts @@ -25,6 +25,7 @@ import type { ClawdbotConfig } from 'openclaw/plugin-sdk'; import type { FeishuMediaInfo, MessageContext } from '../types'; import type { LarkAccount } from '../../core/types'; +import { LarkClient } from '../../core/lark-client'; import { getMessageFeishu } from '../outbound/fetch'; import type { PermissionError } from './permission'; import { PERMISSION_ERROR_COOLDOWN_MS, permissionErrorNotifiedAt } from './permission'; @@ -252,18 +253,37 @@ export async function resolveQuotedContent(params: { /** account 级别的 ClawdbotConfig(channels.feishu 已替换为 per-account 合并后的配置) */ accountScopedCfg: ClawdbotConfig; account: LarkAccount; + botOpenId?: string; log: (...args: unknown[]) => void; }): Promise { + return (await resolveQuotedMessageContext(params))?.content; +} + +export interface QuotedMessageContext { + content: string; + mentionOpenIds: string[]; +} + +export async function resolveQuotedMessageContext(params: { + ctx: MessageContext; + /** account 级别的 ClawdbotConfig(channels.feishu 已替换为 per-account 合并后的配置) */ + accountScopedCfg: ClawdbotConfig; + account: LarkAccount; + botOpenId?: string; + log: (...args: unknown[]) => void; +}): Promise { const { ctx, accountScopedCfg, account, log } = params; if (!ctx.parentId) return undefined; try { + const botOpenId = params.botOpenId ?? LarkClient.fromAccount(account).botOpenId; const quotedMsg = await getMessageFeishu({ cfg: accountScopedCfg, messageId: ctx.parentId, accountId: account.accountId, expandForward: true, + botOpenId, }); if (!quotedMsg) return undefined; @@ -272,10 +292,11 @@ export async function resolveQuotedContent(params: { // 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}]`; + const mentionOpenIds = quotedMsg.mentions.map((mention) => mention.openId).filter(Boolean); if (quotedMsg.senderName) { - return `${prefix} ${quotedMsg.senderName}: ${quotedMsg.content}`; + return { content: `${prefix} ${quotedMsg.senderName}: ${quotedMsg.content}`, mentionOpenIds }; } - return `${prefix} ${quotedMsg.content}`; + return { content: `${prefix} ${quotedMsg.content}`, mentionOpenIds }; } catch (err) { log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`); return undefined; diff --git a/src/messaging/inbound/handler.ts b/src/messaging/inbound/handler.ts index fb58dff6..6f54f581 100644 --- a/src/messaging/inbound/handler.ts +++ b/src/messaging/inbound/handler.ts @@ -34,7 +34,7 @@ import { parseMessageEvent } from './parse'; import { prefetchUserNames, resolveMedia, - resolveQuotedContent, + resolveQuotedMessageContext, resolveSenderInfo, substituteMediaPaths, } from './enrich'; @@ -148,10 +148,10 @@ export async function handleFeishuMessage(params: { await prefetchUserNames({ ctx, account, log }); // 7. Enrich (heavyweight, after gate — parallel where possible) - const enrichParams = { ctx, accountScopedCfg, account, log }; - const [mediaResult, quotedContent] = await Promise.all([ + const enrichParams = { ctx, accountScopedCfg, account, botOpenId, log }; + const [mediaResult, quotedMessage] = await Promise.all([ resolveMedia(enrichParams), - resolveQuotedContent(enrichParams), + resolveQuotedMessageContext(enrichParams), ]); // 7b. Replace Feishu file-key placeholders in content with local @@ -221,7 +221,9 @@ export async function handleFeishuMessage(params: { ctx, permissionError, mediaPayload: mediaResult.payload, - quotedContent, + quotedContent: quotedMessage?.content, + quotedMentionOpenIds: quotedMessage?.mentionOpenIds, + botOpenId, account, accountScopedCfg, runtime, diff --git a/src/messaging/shared/message-lookup.ts b/src/messaging/shared/message-lookup.ts index 79e9ce06..7f8297a0 100644 --- a/src/messaging/shared/message-lookup.ts +++ b/src/messaging/shared/message-lookup.ts @@ -13,6 +13,7 @@ import type { ClawdbotConfig } from 'openclaw/plugin-sdk'; import { buildConvertContextFromItem, convertMessageContent } from '../converters/content-converter'; import { LarkClient } from '../../core/lark-client'; import { larkLogger } from '../../core/lark-logger'; +import type { MentionInfo } from '../types'; const log = larkLogger('shared/message-lookup'); import { createBatchResolveNames, getUserNameCache } from '../inbound/user-name-cache'; @@ -47,6 +48,8 @@ export interface FeishuMessageInfo { createTime?: number; /** Thread ID if the message belongs to a thread (omt_xxx format). */ threadId?: string; + /** Structured mentions returned by the Feishu message API. */ + mentions: MentionInfo[]; } // --------------------------------------------------------------------------- @@ -69,8 +72,10 @@ export async function getMessageFeishu(params: { accountId?: string; /** When true, merge_forward content is recursively expanded via API. */ expandForward?: boolean; + /** Current bot open_id, used to mark quoted-message bot mentions structurally. */ + botOpenId?: string; }): Promise { - const { cfg, messageId, accountId, expandForward } = params; + const { cfg, messageId, accountId, expandForward, botOpenId } = params; const larkClient = LarkClient.fromCfg(cfg, accountId); const sdk = larkClient.sdk; @@ -98,6 +103,7 @@ export async function getMessageFeishu(params: { ? { cfg, accountId, + botOpenId, fetchSubMessages: async (msgId: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await (larkClient.sdk as any).request({ @@ -115,7 +121,13 @@ export async function getMessageFeishu(params: { ), } : undefined; - return await parseMessageItem(items[0], messageId, expandCtx); + return await parseMessageItem(items[0], messageId, { + cfg, + accountId, + botOpenId, + fetchSubMessages: expandCtx?.fetchSubMessages, + batchResolveNames: expandCtx?.batchResolveNames, + }); } catch (error) { log.error(`get message failed (${messageId}): ${error instanceof Error ? error.message : String(error)}`); return null; @@ -140,6 +152,7 @@ async function parseMessageItem( expandCtx?: { cfg: ClawdbotConfig; accountId?: string; + botOpenId?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any fetchSubMessages?: (messageId: string) => Promise; batchResolveNames?: (openIds: string[]) => Promise; @@ -151,13 +164,14 @@ async function parseMessageItem( const acctId = expandCtx?.accountId; const ctx = { - ...buildConvertContextFromItem(msg, fallbackMessageId, acctId), + ...buildConvertContextFromItem(msg, fallbackMessageId, acctId, expandCtx?.botOpenId), cfg: expandCtx?.cfg, accountId: acctId, fetchSubMessages: expandCtx?.fetchSubMessages, batchResolveNames: expandCtx?.batchResolveNames, }; const { content } = await convertMessageContent(rawContent, msgType, ctx); + const mentions = Array.from(ctx.mentions.values()); const senderId: string | undefined = msg.sender?.id ?? undefined; const senderType: string | undefined = msg.sender?.sender_type ?? undefined; @@ -174,5 +188,6 @@ async function parseMessageItem( contentType: msgType, createTime: msg.create_time ? parseInt(String(msg.create_time), 10) : undefined, threadId: msg.thread_id || undefined, + mentions, }; } diff --git a/tests/dispatch-tool-use-init.test.ts b/tests/dispatch-tool-use-init.test.ts index ee1b47ba..a274e291 100644 --- a/tests/dispatch-tool-use-init.test.ts +++ b/tests/dispatch-tool-use-init.test.ts @@ -165,6 +165,7 @@ function createDispatchContext() { }, threadSessionKey: undefined, commandAuthorized: true, + botOpenId: 'ou_bot', }; } @@ -257,4 +258,42 @@ describe('dispatchToAgent tool_use trace initialization', () => { }); }); + it('marks the inbound payload mentioned when the quoted message mentions the bot open_id', async () => { + const dc = createDispatchContext(); + + await dispatchToAgent({ + ctx: dc.ctx as never, + mediaPayload: {}, + quotedContent: '[message_id=om_parent] @Bot please handle this', + quotedMentionOpenIds: ['ou_bot'], + account: dc.account as never, + accountScopedCfg: {} as never, + historyLimit: 0, + }); + + expect(buildInboundPayloadMock).toHaveBeenCalledTimes(1); + const buildInboundPayloadArgs = + buildInboundPayloadMock.mock.calls[0] as unknown as [unknown, { wasMentioned?: boolean }]; + expect(buildInboundPayloadArgs[1].wasMentioned).toBe(true); + }); + + it('does not mark the inbound payload mentioned from quoted display text without the bot open_id', async () => { + const dc = createDispatchContext(); + + await dispatchToAgent({ + ctx: dc.ctx as never, + mediaPayload: {}, + quotedContent: '[message_id=om_parent] @Bot please handle this', + quotedMentionOpenIds: ['ou_someone_else'], + account: dc.account as never, + accountScopedCfg: {} as never, + historyLimit: 0, + }); + + expect(buildInboundPayloadMock).toHaveBeenCalledTimes(1); + const buildInboundPayloadArgs = + buildInboundPayloadMock.mock.calls[0] as unknown as [unknown, { wasMentioned?: boolean }]; + expect(buildInboundPayloadArgs[1].wasMentioned).toBe(false); + }); + }); diff --git a/tests/empty-message-guard.test.ts b/tests/empty-message-guard.test.ts index ccee2bd2..0e278e65 100644 --- a/tests/empty-message-guard.test.ts +++ b/tests/empty-message-guard.test.ts @@ -15,7 +15,7 @@ const mockParseMessageEvent = vi.fn(); const mockResolveSenderInfo = vi.fn(); const mockPrefetchUserNames = vi.fn(); const mockResolveMedia = vi.fn(); -const mockResolveQuotedContent = vi.fn(); +const mockResolveQuotedMessageContext = vi.fn(); const mockSubstituteMediaPaths = vi.fn(); vi.mock('../src/messaging/inbound/parse', () => ({ @@ -26,7 +26,7 @@ vi.mock('../src/messaging/inbound/enrich', () => ({ resolveSenderInfo: (...args: unknown[]) => mockResolveSenderInfo(...args), prefetchUserNames: (...args: unknown[]) => mockPrefetchUserNames(...args), resolveMedia: (...args: unknown[]) => mockResolveMedia(...args), - resolveQuotedContent: (...args: unknown[]) => mockResolveQuotedContent(...args), + resolveQuotedMessageContext: (...args: unknown[]) => mockResolveQuotedMessageContext(...args), substituteMediaPaths: (...args: unknown[]) => mockSubstituteMediaPaths(...args), })); @@ -189,7 +189,7 @@ describe('handleFeishuMessage — empty message guard', () => { it('allows messages with text content even without resources', async () => { mockParseMessageEvent.mockResolvedValue(makeCtx('hello world', [])); mockResolveMedia.mockResolvedValue({ mediaList: [], payload: undefined }); - mockResolveQuotedContent.mockResolvedValue(undefined); + mockResolveQuotedMessageContext.mockResolvedValue(undefined); await handleFeishuMessage({ cfg: { channels: { feishu: {} } } as never, @@ -203,7 +203,7 @@ describe('handleFeishuMessage — empty message guard', () => { const resources = [{ type: 'image' as const, fileKey: 'img_key' }]; mockParseMessageEvent.mockResolvedValue(makeCtx('', resources)); mockResolveMedia.mockResolvedValue({ mediaList: [], payload: undefined }); - mockResolveQuotedContent.mockResolvedValue(undefined); + mockResolveQuotedMessageContext.mockResolvedValue(undefined); await handleFeishuMessage({ cfg: { channels: { feishu: {} } } as never,