Skip to content

Commit f53ef73

Browse files
feat(feishu): add support for merge_forward message parsing (#28707) thanks @tsu-builds
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: tsu-builds <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 8241145 commit f53ef73

File tree

3 files changed

+265
-0
lines changed

3 files changed

+265
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
8585
- 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)
8686
- 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.
8787
- 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.
88+
- 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.
8889
- 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.
8990
- 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)
9091
- 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.

extensions/feishu/src/bot.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,131 @@ describe("handleFeishuMessage command authorization", () => {
486486
);
487487
});
488488

489+
it("expands merge_forward content from API sub-messages", async () => {
490+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
491+
const mockGetMerged = vi.fn().mockResolvedValue({
492+
code: 0,
493+
data: {
494+
items: [
495+
{
496+
message_id: "container",
497+
msg_type: "merge_forward",
498+
body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) },
499+
},
500+
{
501+
message_id: "sub-2",
502+
upper_message_id: "container",
503+
msg_type: "file",
504+
body: { content: JSON.stringify({ file_name: "report.pdf" }) },
505+
create_time: "2000",
506+
},
507+
{
508+
message_id: "sub-1",
509+
upper_message_id: "container",
510+
msg_type: "text",
511+
body: { content: JSON.stringify({ text: "alpha" }) },
512+
create_time: "1000",
513+
},
514+
],
515+
},
516+
});
517+
mockCreateFeishuClient.mockReturnValue({
518+
contact: {
519+
user: {
520+
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
521+
},
522+
},
523+
im: {
524+
message: {
525+
get: mockGetMerged,
526+
},
527+
},
528+
});
529+
530+
const cfg: ClawdbotConfig = {
531+
channels: {
532+
feishu: {
533+
dmPolicy: "open",
534+
},
535+
},
536+
} as ClawdbotConfig;
537+
538+
const event: FeishuMessageEvent = {
539+
sender: {
540+
sender_id: {
541+
open_id: "ou-merge",
542+
},
543+
},
544+
message: {
545+
message_id: "msg-merge-forward",
546+
chat_id: "oc-dm",
547+
chat_type: "p2p",
548+
message_type: "merge_forward",
549+
content: JSON.stringify({ text: "Merged and Forwarded Message" }),
550+
},
551+
};
552+
553+
await dispatchMessage({ cfg, event });
554+
555+
expect(mockGetMerged).toHaveBeenCalledWith({
556+
path: { message_id: "msg-merge-forward" },
557+
});
558+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
559+
expect.objectContaining({
560+
BodyForAgent: expect.stringContaining(
561+
"[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]",
562+
),
563+
}),
564+
);
565+
});
566+
567+
it("falls back when merge_forward API returns no sub-messages", async () => {
568+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
569+
mockCreateFeishuClient.mockReturnValue({
570+
contact: {
571+
user: {
572+
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
573+
},
574+
},
575+
im: {
576+
message: {
577+
get: vi.fn().mockResolvedValue({ code: 0, data: { items: [] } }),
578+
},
579+
},
580+
});
581+
582+
const cfg: ClawdbotConfig = {
583+
channels: {
584+
feishu: {
585+
dmPolicy: "open",
586+
},
587+
},
588+
} as ClawdbotConfig;
589+
590+
const event: FeishuMessageEvent = {
591+
sender: {
592+
sender_id: {
593+
open_id: "ou-merge-empty",
594+
},
595+
},
596+
message: {
597+
message_id: "msg-merge-empty",
598+
chat_id: "oc-dm",
599+
chat_type: "p2p",
600+
message_type: "merge_forward",
601+
content: JSON.stringify({ text: "Merged and Forwarded Message" }),
602+
},
603+
};
604+
605+
await dispatchMessage({ cfg, event });
606+
607+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
608+
expect.objectContaining({
609+
BodyForAgent: expect.stringContaining("[Merged and Forwarded Message - could not fetch]"),
610+
}),
611+
);
612+
});
613+
489614
it("dispatches once and appends permission notice to the main agent body", async () => {
490615
mockShouldComputeCommandAuthorized.mockReturnValue(false);
491616
mockCreateFeishuClient.mockReturnValue({

extensions/feishu/src/bot.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,114 @@ function parseMessageContent(content: string, messageType: string): string {
208208
}
209209
return "[Forwarded message]";
210210
}
211+
if (messageType === "merge_forward") {
212+
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
213+
return "[Merged and Forwarded Message - loading...]";
214+
}
215+
return content;
216+
} catch {
211217
return content;
218+
}
219+
}
220+
221+
/**
222+
* Parse merge_forward message content and fetch sub-messages.
223+
* Returns formatted text content of all sub-messages.
224+
*/
225+
function parseMergeForwardContent(params: {
226+
content: string;
227+
log?: (...args: any[]) => void;
228+
}): string {
229+
const { content, log } = params;
230+
const maxMessages = 50;
231+
232+
// For merge_forward, the API returns all sub-messages in items array
233+
// with upper_message_id pointing to the merge_forward message.
234+
// The 'content' parameter here is actually the full API response items array as JSON.
235+
log?.(`feishu: parsing merge_forward sub-messages from API response`);
236+
237+
let items: Array<{
238+
message_id?: string;
239+
msg_type?: string;
240+
body?: { content?: string };
241+
sender?: { id?: string };
242+
upper_message_id?: string;
243+
create_time?: string;
244+
}>;
245+
246+
try {
247+
items = JSON.parse(content);
248+
} catch {
249+
log?.(`feishu: merge_forward items parse failed`);
250+
return "[Merged and Forwarded Message - parse error]";
251+
}
252+
253+
if (!Array.isArray(items) || items.length === 0) {
254+
return "[Merged and Forwarded Message - no sub-messages]";
255+
}
256+
257+
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
258+
const subMessages = items.filter((item) => item.upper_message_id);
259+
260+
if (subMessages.length === 0) {
261+
return "[Merged and Forwarded Message - no sub-messages found]";
262+
}
263+
264+
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
265+
266+
// Sort by create_time
267+
subMessages.sort((a, b) => {
268+
const timeA = parseInt(a.create_time || "0", 10);
269+
const timeB = parseInt(b.create_time || "0", 10);
270+
return timeA - timeB;
271+
});
272+
273+
// Format output
274+
const lines: string[] = ["[Merged and Forwarded Messages]"];
275+
const limitedMessages = subMessages.slice(0, maxMessages);
276+
277+
for (const item of limitedMessages) {
278+
const msgContent = item.body?.content || "";
279+
const msgType = item.msg_type || "text";
280+
const formatted = formatSubMessageContent(msgContent, msgType);
281+
lines.push(`- ${formatted}`);
282+
}
283+
284+
if (subMessages.length > maxMessages) {
285+
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
286+
}
287+
288+
return lines.join("\n");
289+
}
290+
291+
/**
292+
* Format sub-message content based on message type.
293+
*/
294+
function formatSubMessageContent(content: string, contentType: string): string {
295+
try {
296+
const parsed = JSON.parse(content);
297+
switch (contentType) {
298+
case "text":
299+
return parsed.text || content;
300+
case "post": {
301+
const { textContent } = parsePostContent(content);
302+
return textContent;
303+
}
304+
case "image":
305+
return "[Image]";
306+
case "file":
307+
return `[File: ${parsed.file_name || "unknown"}]`;
308+
case "audio":
309+
return "[Audio]";
310+
case "video":
311+
return "[Video]";
312+
case "sticker":
313+
return "[Sticker]";
314+
case "merge_forward":
315+
return "[Nested Merged Forward]";
316+
default:
317+
return `[${contentType}]`;
318+
}
212319
} catch {
213320
return content;
214321
}
@@ -602,6 +709,38 @@ export async function handleFeishuMessage(params: {
602709
const isGroup = ctx.chatType === "group";
603710
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
604711

712+
// Handle merge_forward messages: fetch full message via API then expand sub-messages
713+
if (event.message.message_type === "merge_forward") {
714+
log(
715+
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
716+
);
717+
try {
718+
// Websocket event doesn't include sub-messages, need to fetch via API
719+
// The API returns all sub-messages in the items array
720+
const client = createFeishuClient(account);
721+
const response = (await client.im.message.get({
722+
path: { message_id: event.message.message_id },
723+
})) as { code?: number; data?: { items?: unknown[] } };
724+
725+
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
726+
log(
727+
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
728+
);
729+
const expandedContent = parseMergeForwardContent({
730+
content: JSON.stringify(response.data.items),
731+
log,
732+
});
733+
ctx = { ...ctx, content: expandedContent };
734+
} else {
735+
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
736+
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
737+
}
738+
} catch (err) {
739+
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
740+
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
741+
}
742+
}
743+
605744
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
606745
const senderResult = await resolveFeishuSenderName({
607746
account,

0 commit comments

Comments
 (0)