diff --git a/CHANGELOG.md b/CHANGELOG.md index a400c03d690..54647ae6a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai - Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688) - Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11. - Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker. +- Feishu/Merged forward parsing: expand inbound `merge_forward` messages by fetching and formatting API sub-messages in order, so merged forwards provide usable content context instead of only a placeholder line. (#28707) Thanks @tsu-builds. - Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03. - TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674) - Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 9e87ee3b251..2d2491d11af 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -486,6 +486,131 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("expands merge_forward content from API sub-messages", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + const mockGetMerged = vi.fn().mockResolvedValue({ + code: 0, + data: { + items: [ + { + message_id: "container", + msg_type: "merge_forward", + body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) }, + }, + { + message_id: "sub-2", + upper_message_id: "container", + msg_type: "file", + body: { content: JSON.stringify({ file_name: "report.pdf" }) }, + create_time: "2000", + }, + { + message_id: "sub-1", + upper_message_id: "container", + msg_type: "text", + body: { content: JSON.stringify({ text: "alpha" }) }, + create_time: "1000", + }, + ], + }, + }); + mockCreateFeishuClient.mockReturnValue({ + contact: { + user: { + get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }), + }, + }, + im: { + message: { + get: mockGetMerged, + }, + }, + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-merge", + }, + }, + message: { + message_id: "msg-merge-forward", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "merge_forward", + content: JSON.stringify({ text: "Merged and Forwarded Message" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockGetMerged).toHaveBeenCalledWith({ + path: { message_id: "msg-merge-forward" }, + }); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: expect.stringContaining( + "[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]", + ), + }), + ); + }); + + it("falls back when merge_forward API returns no sub-messages", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + mockCreateFeishuClient.mockReturnValue({ + contact: { + user: { + get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }), + }, + }, + im: { + message: { + get: vi.fn().mockResolvedValue({ code: 0, data: { items: [] } }), + }, + }, + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-merge-empty", + }, + }, + message: { + message_id: "msg-merge-empty", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "merge_forward", + content: JSON.stringify({ text: "Merged and Forwarded Message" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: expect.stringContaining("[Merged and Forwarded Message - could not fetch]"), + }), + ); + }); + it("dispatches once and appends permission notice to the main agent body", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); mockCreateFeishuClient.mockReturnValue({ diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 690671e7487..95556aaafc4 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -208,12 +208,119 @@ function parseMessageContent(content: string, messageType: string): string { } return "[Forwarded message]"; } + if (messageType === "merge_forward") { + // Return placeholder; actual content fetched asynchronously in handleFeishuMessage + return "[Merged and Forwarded Message - loading...]"; + } return content; } catch { return content; } } +/** + * Parse merge_forward message content and fetch sub-messages. + * Returns formatted text content of all sub-messages. + */ +function parseMergeForwardContent(params: { + content: string; + log?: (...args: any[]) => void; +}): string { + const { content, log } = params; + const maxMessages = 50; + + // For merge_forward, the API returns all sub-messages in items array + // with upper_message_id pointing to the merge_forward message. + // The 'content' parameter here is actually the full API response items array as JSON. + log?.(`feishu: parsing merge_forward sub-messages from API response`); + + let items: Array<{ + message_id?: string; + msg_type?: string; + body?: { content?: string }; + sender?: { id?: string }; + upper_message_id?: string; + create_time?: string; + }>; + + try { + items = JSON.parse(content); + } catch { + log?.(`feishu: merge_forward items parse failed`); + return "[Merged and Forwarded Message - parse error]"; + } + + if (!Array.isArray(items) || items.length === 0) { + return "[Merged and Forwarded Message - no sub-messages]"; + } + + // Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself) + const subMessages = items.filter((item) => item.upper_message_id); + + if (subMessages.length === 0) { + return "[Merged and Forwarded Message - no sub-messages found]"; + } + + log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`); + + // Sort by create_time + subMessages.sort((a, b) => { + const timeA = parseInt(a.create_time || "0", 10); + const timeB = parseInt(b.create_time || "0", 10); + return timeA - timeB; + }); + + // Format output + const lines: string[] = ["[Merged and Forwarded Messages]"]; + const limitedMessages = subMessages.slice(0, maxMessages); + + for (const item of limitedMessages) { + const msgContent = item.body?.content || ""; + const msgType = item.msg_type || "text"; + const formatted = formatSubMessageContent(msgContent, msgType); + lines.push(`- ${formatted}`); + } + + if (subMessages.length > maxMessages) { + lines.push(`... and ${subMessages.length - maxMessages} more messages`); + } + + return lines.join("\n"); +} + +/** + * Format sub-message content based on message type. + */ +function formatSubMessageContent(content: string, contentType: string): string { + try { + const parsed = JSON.parse(content); + switch (contentType) { + case "text": + return parsed.text || content; + case "post": { + const { textContent } = parsePostContent(content); + return textContent; + } + case "image": + return "[Image]"; + case "file": + return `[File: ${parsed.file_name || "unknown"}]`; + case "audio": + return "[Audio]"; + case "video": + return "[Video]"; + case "sticker": + return "[Sticker]"; + case "merge_forward": + return "[Nested Merged Forward]"; + default: + return `[${contentType}]`; + } + } catch { + return content; + } +} + function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean { if (!botOpenId) return false; const mentions = event.message.mentions ?? []; @@ -602,6 +709,38 @@ export async function handleFeishuMessage(params: { const isGroup = ctx.chatType === "group"; const senderUserId = event.sender.sender_id.user_id?.trim() || undefined; + // Handle merge_forward messages: fetch full message via API then expand sub-messages + if (event.message.message_type === "merge_forward") { + log( + `feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`, + ); + try { + // Websocket event doesn't include sub-messages, need to fetch via API + // The API returns all sub-messages in the items array + const client = createFeishuClient(account); + const response = (await client.im.message.get({ + path: { message_id: event.message.message_id }, + })) as { code?: number; data?: { items?: unknown[] } }; + + if (response.code === 0 && response.data?.items && response.data.items.length > 0) { + log( + `feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`, + ); + const expandedContent = parseMergeForwardContent({ + content: JSON.stringify(response.data.items), + log, + }); + ctx = { ...ctx, content: expandedContent }; + } else { + log(`feishu[${account.accountId}]: merge_forward API returned no items`); + ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" }; + } + } catch (err) { + log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`); + ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" }; + } + } + // Resolve sender display name (best-effort) so the agent can attribute messages correctly. const senderResult = await resolveFeishuSenderName({ account,