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:
Chuan Liu
2026-02-28 23:55:50 +08:00
committed by GitHub
parent 9b39490d6a
commit 4ad49de89d
6 changed files with 137 additions and 0 deletions

View File

@@ -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.

View File

@@ -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([]);

View File

@@ -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,

View 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",
}),
);
});
});

View File

@@ -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

View File

@@ -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;