mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(feishu): add support for merge_forward message parsing (openclaw#28707) thanks @tsu-builds
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: tsu-builds <264409075+tsu-builds@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user