Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/messaging/converters/content-converter-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function buildConvertContextFromItem(
item: ApiMessageItem,
fallbackMessageId: string,
accountId?: string,
botOpenId?: string,
): ConvertContext {
const mentions = new Map<string, MentionInfo>();
const mentionsByOpenId = new Map<string, MentionInfo>();
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/messaging/inbound/dispatch-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/messaging/inbound/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface DispatchContext {
route: ReturnType<typeof LarkClient.runtime.channel.routing.resolveAgentRoute>;
threadSessionKey?: string;
commandAuthorized?: boolean;
botOpenId?: string;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -75,6 +76,7 @@ export function buildDispatchContext(params: {
accountScopedCfg: ClawdbotConfig;
runtime?: RuntimeEnv;
commandAuthorized?: boolean;
botOpenId?: string;
}): DispatchContext {
const { ctx, account, accountScopedCfg } = params;

Expand All @@ -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"
Expand Down Expand Up @@ -151,6 +154,7 @@ export function buildDispatchContext(params: {
route,
threadSessionKey: undefined,
commandAuthorized: params.commandAuthorized,
botOpenId,
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/messaging/inbound/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -328,6 +335,8 @@ export async function dispatchToAgent(params: {
/** Additional structured metadata for synthetic or event-driven inbound flows. */
extraInboundFields?: Record<string, unknown>;
quotedContent?: string;
quotedMentionOpenIds?: string[];
botOpenId?: string;
account: LarkAccount;
/** account 级别的 ClawdbotConfig(channels.feishu 已替换为 per-account 合并后的配置) */
accountScopedCfg: ClawdbotConfig;
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions src/messaging/inbound/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | undefined> {
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<QuotedMessageContext | undefined> {
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;

Expand All @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions src/messaging/inbound/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { parseMessageEvent } from './parse';
import {
prefetchUserNames,
resolveMedia,
resolveQuotedContent,
resolveQuotedMessageContext,
resolveSenderInfo,
substituteMediaPaths,
} from './enrich';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 18 additions & 3 deletions src/messaging/shared/message-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];
}

// ---------------------------------------------------------------------------
Expand All @@ -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<FeishuMessageInfo | null> {
const { cfg, messageId, accountId, expandForward } = params;
const { cfg, messageId, accountId, expandForward, botOpenId } = params;

const larkClient = LarkClient.fromCfg(cfg, accountId);
const sdk = larkClient.sdk;
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand All @@ -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<any[]>;
batchResolveNames?: (openIds: string[]) => Promise<void>;
Expand All @@ -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;
Expand All @@ -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,
};
}
39 changes: 39 additions & 0 deletions tests/dispatch-tool-use-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function createDispatchContext() {
},
threadSessionKey: undefined,
commandAuthorized: true,
botOpenId: 'ou_bot',
};
}

Expand Down Expand Up @@ -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);
});

});
8 changes: 4 additions & 4 deletions tests/empty-message-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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),
}));

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down