feat(feishu): add parent/root inbound context for quote support (openclaw#18529)
* feat(feishu): add parentId and rootId to inbound context Add ParentMessageId and RootMessageId fields to Feishu inbound message context, enabling agents to: - Identify quoted/replied messages - Fetch original message content via Feishu API - Build proper message thread context The parent_id and root_id fields already exist in FeishuMessageContext but were not being passed to the agent's inbound context. Fixes: Allows proper handling of quoted card messages and message thread reconstruction. * feat(feishu): parse interactive card content in quoted messages Add support for extracting readable text from interactive card messages when fetching quoted/replied message content. Previously, only text messages were parsed. Now interactive cards (with div and markdown elements) are also converted to readable text. * 更新 bot.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(types): add RootMessageId to MsgContext type definition * style: fix formatting in bot.ts * ci: trigger rebuild * ci: retry flaky tests * Feishu: add reply-context and interactive-quote regressions --------- Co-authored-by: qiangu <qiangu@qq.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: 牛牛 <niuniu@openclaw.ai> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
|
||||
- Feishu/Post markdown parsing: parse rich-text `post` payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755)
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529)
|
||||
- Feishu/Post embedded media: extract `media` tags from inbound rich-text (`post`) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.
|
||||
- Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
|
||||
- Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups.<id>.allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.
|
||||
|
||||
@@ -287,6 +287,51 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
expect(mockCreateFeishuClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates parent/root message ids into inbound context for reply reconstruction", async () => {
|
||||
mockGetMessageFeishu.mockResolvedValueOnce({
|
||||
messageId: "om_parent_001",
|
||||
chatId: "oc-group",
|
||||
content: "quoted content",
|
||||
contentType: "text",
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-replier",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "om_reply_001",
|
||||
root_id: "om_root_001",
|
||||
parent_id: "om_parent_001",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "reply text" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ReplyToId: "om_parent_001",
|
||||
RootMessageId: "om_root_001",
|
||||
ReplyToBody: "quoted content",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
|
||||
@@ -1135,6 +1135,10 @@ export async function handleFeishuMessage(params: {
|
||||
Body: combinedBody,
|
||||
BodyForAgent: messageBody,
|
||||
InboundHistory: inboundHistory,
|
||||
// Quote/reply message support: use standard ReplyToId for parent,
|
||||
// and pass root_id for thread reconstruction.
|
||||
ReplyToId: ctx.parentId,
|
||||
RootMessageId: ctx.rootId,
|
||||
RawBody: ctx.content,
|
||||
CommandBody: ctx.content,
|
||||
From: feishuFrom,
|
||||
|
||||
71
extensions/feishu/src/send.test.ts
Normal file
71
extensions/feishu/src/send.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
|
||||
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
|
||||
mockClientGet: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: mockResolveFeishuAccount,
|
||||
}));
|
||||
|
||||
describe("getMessageFeishu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveFeishuAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
});
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
im: {
|
||||
message: {
|
||||
get: mockClientGet,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts text content from interactive card elements", async () => {
|
||||
mockClientGet.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
message_id: "om_1",
|
||||
chat_id: "oc_1",
|
||||
msg_type: "interactive",
|
||||
body: {
|
||||
content: JSON.stringify({
|
||||
elements: [
|
||||
{ tag: "markdown", content: "hello markdown" },
|
||||
{ tag: "div", text: { content: "hello div" } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getMessageFeishu({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
messageId: "om_1",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
messageId: "om_1",
|
||||
chatId: "oc_1",
|
||||
contentType: "interactive",
|
||||
content: "hello markdown\nhello div",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,17 @@ export async function getMessageFeishu(params: {
|
||||
const parsed = JSON.parse(content);
|
||||
if (item.msg_type === "text" && parsed.text) {
|
||||
content = parsed.text;
|
||||
} else if (item.msg_type === "interactive" && parsed.elements) {
|
||||
// Extract text from interactive card
|
||||
const texts: string[] = [];
|
||||
for (const element of parsed.elements) {
|
||||
if (element.tag === "div" && element.text?.content) {
|
||||
texts.push(element.text.content);
|
||||
} else if (element.tag === "markdown" && element.content) {
|
||||
texts.push(element.content);
|
||||
}
|
||||
}
|
||||
content = texts.join("\n") || "[Interactive Card]";
|
||||
}
|
||||
} catch {
|
||||
// Keep raw content if parsing fails
|
||||
|
||||
@@ -54,6 +54,11 @@ export type MsgContext = {
|
||||
MessageSidFirst?: string;
|
||||
MessageSidLast?: string;
|
||||
ReplyToId?: string;
|
||||
/**
|
||||
* Root message id for thread reconstruction (used by Feishu for root_id).
|
||||
* When a message is part of a thread, this is the id of the first message.
|
||||
*/
|
||||
RootMessageId?: string;
|
||||
/** Provider-specific full reply-to id when ReplyToId is a shortened alias. */
|
||||
ReplyToIdFull?: string;
|
||||
ReplyToBody?: string;
|
||||
|
||||
Reference in New Issue
Block a user