Skip to content

Commit 2a252a1

Browse files
committed
fix(feishu): harden target routing, dedupe, and reply fallback
1 parent 77ccd35 commit 2a252a1

11 files changed

Lines changed: 371 additions & 14 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@ Docs: https://docs.openclaw.ai
115115
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
116116
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
117117
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
118+
## Unreleased
119+
120+
### Changes
121+
122+
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
123+
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
124+
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
125+
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
126+
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
127+
128+
### Breaking
129+
130+
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
131+
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
132+
133+
### Fixes
134+
135+
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
136+
- Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42.
118137
- Google Chat/Thread replies: set `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` on threaded sends so replies attach to existing threads instead of silently failing thread placement. Landed from contributor PR #30965 by @novan. Thanks @novan.
119138
- Mattermost/Private channel policy routing: map Mattermost private channel type `P` to group chat type so `groupPolicy`/`groupAllowFrom` gates apply correctly instead of being treated as open public channels. Landed from contributor PR #30891 by @BlueBirdBack. Thanks @BlueBirdBack.
120139
- Models/Custom provider keys: trim custom provider map keys during normalization so image-capable models remain discoverable when provider keys are configured with leading/trailing whitespace. Landed from contributor PR #31202 by @stakeswky. Thanks @stakeswky.

extensions/feishu/src/bot.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,38 @@ describe("handleFeishuMessage command authorization", () => {
11881188
}),
11891189
);
11901190
});
1191+
1192+
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
1193+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1194+
1195+
const cfg: ClawdbotConfig = {
1196+
channels: {
1197+
feishu: {
1198+
dmPolicy: "open",
1199+
},
1200+
},
1201+
} as ClawdbotConfig;
1202+
1203+
const event: FeishuMessageEvent = {
1204+
sender: {
1205+
sender_id: {
1206+
open_id: "ou-image-dedup",
1207+
},
1208+
},
1209+
message: {
1210+
message_id: "msg-image-dedup",
1211+
chat_id: "oc-dm",
1212+
chat_type: "p2p",
1213+
message_type: "image",
1214+
content: JSON.stringify({
1215+
image_key: "img_dedup_payload",
1216+
}),
1217+
},
1218+
};
1219+
1220+
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
1221+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1222+
});
11911223
});
11921224

11931225
describe("toMessageResourceType", () => {

extensions/feishu/src/bot.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "openclaw/plugin-sdk";
1414
import { resolveFeishuAccount } from "./accounts.js";
1515
import { createFeishuClient } from "./client.js";
16-
import { tryRecordMessagePersistent } from "./dedup.js";
16+
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
1717
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
1818
import { normalizeFeishuExternalKey } from "./external-keys.js";
1919
import { downloadMessageResourceFeishu } from "./media.js";
@@ -692,8 +692,15 @@ export async function handleFeishuMessage(params: {
692692
const log = runtime?.log ?? console.log;
693693
const error = runtime?.error ?? console.error;
694694

695-
// Dedup check: skip if this message was already processed (memory + disk).
695+
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
696+
// before the async persistent check completes.
696697
const messageId = event.message.message_id;
698+
const memoryDedupeKey = `${account.accountId}:${messageId}`;
699+
if (!tryRecordMessage(memoryDedupeKey)) {
700+
log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
701+
return;
702+
}
703+
// Persistent dedup survives restarts and reconnects.
697704
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
698705
log(`feishu: skipping duplicate message ${messageId}`);
699706
return;

extensions/feishu/src/outbound.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
55

66
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
77
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
8+
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
89

910
vi.mock("./media.js", () => ({
1011
sendMediaFeishu: sendMediaFeishuMock,
1112
}));
1213

1314
vi.mock("./send.js", () => ({
1415
sendMessageFeishu: sendMessageFeishuMock,
16+
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
1517
}));
1618

1719
vi.mock("./runtime.js", () => ({
@@ -31,6 +33,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
3133
beforeEach(() => {
3234
vi.clearAllMocks();
3335
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
36+
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
3437
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
3538
});
3639

@@ -108,4 +111,71 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
108111
await fs.rm(dir, { recursive: true, force: true });
109112
}
110113
});
114+
115+
it("uses markdown cards when renderMode=card", async () => {
116+
const result = await sendText({
117+
cfg: {
118+
channels: {
119+
feishu: {
120+
renderMode: "card",
121+
},
122+
},
123+
} as any,
124+
to: "chat_1",
125+
text: "| a | b |\n| - | - |",
126+
accountId: "main",
127+
});
128+
129+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
130+
expect.objectContaining({
131+
to: "chat_1",
132+
text: "| a | b |\n| - | - |",
133+
accountId: "main",
134+
}),
135+
);
136+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
137+
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
138+
});
139+
});
140+
141+
describe("feishuOutbound.sendMedia renderMode", () => {
142+
beforeEach(() => {
143+
vi.clearAllMocks();
144+
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
145+
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
146+
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
147+
});
148+
149+
it("uses markdown cards for captions when renderMode=card", async () => {
150+
const result = await feishuOutbound.sendMedia?.({
151+
cfg: {
152+
channels: {
153+
feishu: {
154+
renderMode: "card",
155+
},
156+
},
157+
} as any,
158+
to: "chat_1",
159+
text: "| a | b |\n| - | - |",
160+
mediaUrl: "https://example.com/image.png",
161+
accountId: "main",
162+
});
163+
164+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
165+
expect.objectContaining({
166+
to: "chat_1",
167+
text: "| a | b |\n| - | - |",
168+
accountId: "main",
169+
}),
170+
);
171+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
172+
expect.objectContaining({
173+
to: "chat_1",
174+
mediaUrl: "https://example.com/image.png",
175+
accountId: "main",
176+
}),
177+
);
178+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
179+
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
180+
});
111181
});

extensions/feishu/src/outbound.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import fs from "fs";
22
import path from "path";
33
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
4+
import { resolveFeishuAccount } from "./accounts.js";
45
import { sendMediaFeishu } from "./media.js";
56
import { getFeishuRuntime } from "./runtime.js";
6-
import { sendMessageFeishu } from "./send.js";
7+
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
78

89
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
910
const raw = text?.trim();
@@ -38,6 +39,27 @@ function normalizePossibleLocalImagePath(text: string | undefined): string | nul
3839
return raw;
3940
}
4041

42+
function shouldUseCard(text: string): boolean {
43+
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
44+
}
45+
46+
async function sendOutboundText(params: {
47+
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
48+
to: string;
49+
text: string;
50+
accountId?: string;
51+
}) {
52+
const { cfg, to, text, accountId } = params;
53+
const account = resolveFeishuAccount({ cfg, accountId });
54+
const renderMode = account.config?.renderMode ?? "auto";
55+
56+
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
57+
return sendMarkdownCardFeishu({ cfg, to, text, accountId });
58+
}
59+
60+
return sendMessageFeishu({ cfg, to, text, accountId });
61+
}
62+
4163
export const feishuOutbound: ChannelOutboundAdapter = {
4264
deliveryMode: "direct",
4365
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
@@ -63,13 +85,23 @@ export const feishuOutbound: ChannelOutboundAdapter = {
6385
}
6486
}
6587

66-
const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
88+
const result = await sendOutboundText({
89+
cfg,
90+
to,
91+
text,
92+
accountId: accountId ?? undefined,
93+
});
6794
return { channel: "feishu", ...result };
6895
},
6996
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
7097
// Send text first if provided
7198
if (text?.trim()) {
72-
await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
99+
await sendOutboundText({
100+
cfg,
101+
to,
102+
text,
103+
accountId: accountId ?? undefined,
104+
});
73105
}
74106

75107
// Upload and send media if URL or local path provided
@@ -88,7 +120,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
88120
console.error(`[feishu] sendMediaFeishu failed:`, err);
89121
// Fallback to URL link if upload fails
90122
const fallbackText = `📎 ${mediaUrl}`;
91-
const result = await sendMessageFeishu({
123+
const result = await sendOutboundText({
92124
cfg,
93125
to,
94126
text: fallbackText,
@@ -99,7 +131,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
99131
}
100132

101133
// No media URL, just return text result
102-
const result = await sendMessageFeishu({
134+
const result = await sendOutboundText({
103135
cfg,
104136
to,
105137
text: text ?? "",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const resolveFeishuSendTargetMock = vi.hoisted(() => vi.fn());
4+
const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "preserve"));
5+
const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
6+
7+
vi.mock("./send-target.js", () => ({
8+
resolveFeishuSendTarget: resolveFeishuSendTargetMock,
9+
}));
10+
11+
vi.mock("./runtime.js", () => ({
12+
getFeishuRuntime: () => ({
13+
channel: {
14+
text: {
15+
resolveMarkdownTableMode: resolveMarkdownTableModeMock,
16+
convertMarkdownTables: convertMarkdownTablesMock,
17+
},
18+
},
19+
}),
20+
}));
21+
22+
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
23+
24+
describe("Feishu reply fallback for withdrawn/deleted targets", () => {
25+
const replyMock = vi.fn();
26+
const createMock = vi.fn();
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
resolveFeishuSendTargetMock.mockReturnValue({
31+
client: {
32+
im: {
33+
message: {
34+
reply: replyMock,
35+
create: createMock,
36+
},
37+
},
38+
},
39+
receiveId: "ou_target",
40+
receiveIdType: "open_id",
41+
});
42+
});
43+
44+
it("falls back to create for withdrawn post replies", async () => {
45+
replyMock.mockResolvedValue({
46+
code: 230011,
47+
msg: "The message was withdrawn.",
48+
});
49+
createMock.mockResolvedValue({
50+
code: 0,
51+
data: { message_id: "om_new" },
52+
});
53+
54+
const result = await sendMessageFeishu({
55+
cfg: {} as never,
56+
to: "user:ou_target",
57+
text: "hello",
58+
replyToMessageId: "om_parent",
59+
});
60+
61+
expect(replyMock).toHaveBeenCalledTimes(1);
62+
expect(createMock).toHaveBeenCalledTimes(1);
63+
expect(result.messageId).toBe("om_new");
64+
});
65+
66+
it("falls back to create for withdrawn card replies", async () => {
67+
replyMock.mockResolvedValue({
68+
code: 231003,
69+
msg: "The message is not found",
70+
});
71+
createMock.mockResolvedValue({
72+
code: 0,
73+
data: { message_id: "om_card_new" },
74+
});
75+
76+
const result = await sendCardFeishu({
77+
cfg: {} as never,
78+
to: "user:ou_target",
79+
card: { schema: "2.0" },
80+
replyToMessageId: "om_parent",
81+
});
82+
83+
expect(replyMock).toHaveBeenCalledTimes(1);
84+
expect(createMock).toHaveBeenCalledTimes(1);
85+
expect(result.messageId).toBe("om_card_new");
86+
});
87+
88+
it("still throws for non-withdrawn reply failures", async () => {
89+
replyMock.mockResolvedValue({
90+
code: 999999,
91+
msg: "unknown failure",
92+
});
93+
94+
await expect(
95+
sendMessageFeishu({
96+
cfg: {} as never,
97+
to: "user:ou_target",
98+
text: "hello",
99+
replyToMessageId: "om_parent",
100+
}),
101+
).rejects.toThrow("Feishu reply failed");
102+
103+
expect(createMock).not.toHaveBeenCalled();
104+
});
105+
});

0 commit comments

Comments
 (0)