mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:38:39 +00:00
fix(feishu): harden target routing, dedupe, and reply fallback
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -115,6 +115,25 @@ Docs: https://docs.openclaw.ai
|
|||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -1188,6 +1188,38 @@ describe("handleFeishuMessage command authorization", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
||||||
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
dmPolicy: "open",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const event: FeishuMessageEvent = {
|
||||||
|
sender: {
|
||||||
|
sender_id: {
|
||||||
|
open_id: "ou-image-dedup",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
message_id: "msg-image-dedup",
|
||||||
|
chat_id: "oc-dm",
|
||||||
|
chat_type: "p2p",
|
||||||
|
message_type: "image",
|
||||||
|
content: JSON.stringify({
|
||||||
|
image_key: "img_dedup_payload",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
|
||||||
|
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toMessageResourceType", () => {
|
describe("toMessageResourceType", () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import { resolveFeishuAccount } from "./accounts.js";
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import { tryRecordMessagePersistent } from "./dedup.js";
|
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
||||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||||
import { downloadMessageResourceFeishu } from "./media.js";
|
import { downloadMessageResourceFeishu } from "./media.js";
|
||||||
@@ -692,8 +692,15 @@ export async function handleFeishuMessage(params: {
|
|||||||
const log = runtime?.log ?? console.log;
|
const log = runtime?.log ?? console.log;
|
||||||
const error = runtime?.error ?? console.error;
|
const error = runtime?.error ?? console.error;
|
||||||
|
|
||||||
// Dedup check: skip if this message was already processed (memory + disk).
|
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
|
||||||
|
// before the async persistent check completes.
|
||||||
const messageId = event.message.message_id;
|
const messageId = event.message.message_id;
|
||||||
|
const memoryDedupeKey = `${account.accountId}:${messageId}`;
|
||||||
|
if (!tryRecordMessage(memoryDedupeKey)) {
|
||||||
|
log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Persistent dedup survives restarts and reconnects.
|
||||||
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
||||||
log(`feishu: skipping duplicate message ${messageId}`);
|
log(`feishu: skipping duplicate message ${messageId}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("./media.js", () => ({
|
vi.mock("./media.js", () => ({
|
||||||
sendMediaFeishu: sendMediaFeishuMock,
|
sendMediaFeishu: sendMediaFeishuMock,
|
||||||
@@ -12,6 +13,7 @@ vi.mock("./media.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageFeishu: sendMessageFeishuMock,
|
sendMessageFeishu: sendMessageFeishuMock,
|
||||||
|
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./runtime.js", () => ({
|
vi.mock("./runtime.js", () => ({
|
||||||
@@ -31,6 +33,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||||
|
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,4 +111,71 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses markdown cards when renderMode=card", async () => {
|
||||||
|
const result = await sendText({
|
||||||
|
cfg: {
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
renderMode: "card",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
to: "chat_1",
|
||||||
|
text: "| a | b |\n| - | - |",
|
||||||
|
accountId: "main",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "chat_1",
|
||||||
|
text: "| a | b |\n| - | - |",
|
||||||
|
accountId: "main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feishuOutbound.sendMedia renderMode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||||
|
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||||
|
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses markdown cards for captions when renderMode=card", async () => {
|
||||||
|
const result = await feishuOutbound.sendMedia?.({
|
||||||
|
cfg: {
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
renderMode: "card",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
to: "chat_1",
|
||||||
|
text: "| a | b |\n| - | - |",
|
||||||
|
mediaUrl: "https://example.com/image.png",
|
||||||
|
accountId: "main",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "chat_1",
|
||||||
|
text: "| a | b |\n| - | - |",
|
||||||
|
accountId: "main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "chat_1",
|
||||||
|
mediaUrl: "https://example.com/image.png",
|
||||||
|
accountId: "main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||||
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
import { sendMediaFeishu } from "./media.js";
|
import { sendMediaFeishu } from "./media.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import { sendMessageFeishu } from "./send.js";
|
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
||||||
|
|
||||||
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
||||||
const raw = text?.trim();
|
const raw = text?.trim();
|
||||||
@@ -38,6 +39,27 @@ function normalizePossibleLocalImagePath(text: string | undefined): string | nul
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseCard(text: string): boolean {
|
||||||
|
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOutboundText(params: {
|
||||||
|
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
accountId?: string;
|
||||||
|
}) {
|
||||||
|
const { cfg, to, text, accountId } = params;
|
||||||
|
const account = resolveFeishuAccount({ cfg, accountId });
|
||||||
|
const renderMode = account.config?.renderMode ?? "auto";
|
||||||
|
|
||||||
|
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
|
||||||
|
return sendMarkdownCardFeishu({ cfg, to, text, accountId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMessageFeishu({ cfg, to, text, accountId });
|
||||||
|
}
|
||||||
|
|
||||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
@@ -63,13 +85,23 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
|
const result = await sendOutboundText({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
return { channel: "feishu", ...result };
|
return { channel: "feishu", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
||||||
// Send text first if provided
|
// Send text first if provided
|
||||||
if (text?.trim()) {
|
if (text?.trim()) {
|
||||||
await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
|
await sendOutboundText({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload and send media if URL or local path provided
|
// Upload and send media if URL or local path provided
|
||||||
@@ -88,7 +120,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||||||
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
||||||
// Fallback to URL link if upload fails
|
// Fallback to URL link if upload fails
|
||||||
const fallbackText = `📎 ${mediaUrl}`;
|
const fallbackText = `📎 ${mediaUrl}`;
|
||||||
const result = await sendMessageFeishu({
|
const result = await sendOutboundText({
|
||||||
cfg,
|
cfg,
|
||||||
to,
|
to,
|
||||||
text: fallbackText,
|
text: fallbackText,
|
||||||
@@ -99,7 +131,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No media URL, just return text result
|
// No media URL, just return text result
|
||||||
const result = await sendMessageFeishu({
|
const result = await sendOutboundText({
|
||||||
cfg,
|
cfg,
|
||||||
to,
|
to,
|
||||||
text: text ?? "",
|
text: text ?? "",
|
||||||
|
|||||||
105
extensions/feishu/src/send.reply-fallback.test.ts
Normal file
105
extensions/feishu/src/send.reply-fallback.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const resolveFeishuSendTargetMock = vi.hoisted(() => vi.fn());
|
||||||
|
const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "preserve"));
|
||||||
|
const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
|
||||||
|
|
||||||
|
vi.mock("./send-target.js", () => ({
|
||||||
|
resolveFeishuSendTarget: resolveFeishuSendTargetMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./runtime.js", () => ({
|
||||||
|
getFeishuRuntime: () => ({
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
resolveMarkdownTableMode: resolveMarkdownTableModeMock,
|
||||||
|
convertMarkdownTables: convertMarkdownTablesMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||||
|
|
||||||
|
describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||||
|
const replyMock = vi.fn();
|
||||||
|
const createMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resolveFeishuSendTargetMock.mockReturnValue({
|
||||||
|
client: {
|
||||||
|
im: {
|
||||||
|
message: {
|
||||||
|
reply: replyMock,
|
||||||
|
create: createMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
receiveId: "ou_target",
|
||||||
|
receiveIdType: "open_id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to create for withdrawn post replies", async () => {
|
||||||
|
replyMock.mockResolvedValue({
|
||||||
|
code: 230011,
|
||||||
|
msg: "The message was withdrawn.",
|
||||||
|
});
|
||||||
|
createMock.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
data: { message_id: "om_new" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMessageFeishu({
|
||||||
|
cfg: {} as never,
|
||||||
|
to: "user:ou_target",
|
||||||
|
text: "hello",
|
||||||
|
replyToMessageId: "om_parent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.messageId).toBe("om_new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to create for withdrawn card replies", async () => {
|
||||||
|
replyMock.mockResolvedValue({
|
||||||
|
code: 231003,
|
||||||
|
msg: "The message is not found",
|
||||||
|
});
|
||||||
|
createMock.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
data: { message_id: "om_card_new" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendCardFeishu({
|
||||||
|
cfg: {} as never,
|
||||||
|
to: "user:ou_target",
|
||||||
|
card: { schema: "2.0" },
|
||||||
|
replyToMessageId: "om_parent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.messageId).toBe("om_card_new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still throws for non-withdrawn reply failures", async () => {
|
||||||
|
replyMock.mockResolvedValue({
|
||||||
|
code: 999999,
|
||||||
|
msg: "unknown failure",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageFeishu({
|
||||||
|
cfg: {} as never,
|
||||||
|
to: "user:ou_target",
|
||||||
|
text: "hello",
|
||||||
|
replyToMessageId: "om_parent",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Feishu reply failed");
|
||||||
|
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,16 @@ import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result
|
|||||||
import { resolveFeishuSendTarget } from "./send-target.js";
|
import { resolveFeishuSendTarget } from "./send-target.js";
|
||||||
import type { FeishuSendResult } from "./types.js";
|
import type { FeishuSendResult } from "./types.js";
|
||||||
|
|
||||||
|
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
|
||||||
|
|
||||||
|
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
|
||||||
|
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const msg = response.msg?.toLowerCase() ?? "";
|
||||||
|
return msg.includes("withdrawn") || msg.includes("not found");
|
||||||
|
}
|
||||||
|
|
||||||
export type FeishuMessageInfo = {
|
export type FeishuMessageInfo = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@@ -238,6 +248,18 @@ export async function sendMessageFeishu(
|
|||||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (shouldFallbackFromReplyTarget(response)) {
|
||||||
|
const fallback = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
content,
|
||||||
|
msg_type: msgType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
|
||||||
|
return toFeishuSendResult(fallback, receiveId);
|
||||||
|
}
|
||||||
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
||||||
return toFeishuSendResult(response, receiveId);
|
return toFeishuSendResult(response, receiveId);
|
||||||
}
|
}
|
||||||
@@ -278,6 +300,18 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
|||||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (shouldFallbackFromReplyTarget(response)) {
|
||||||
|
const fallback = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
content,
|
||||||
|
msg_type: "interactive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
|
||||||
|
return toFeishuSendResult(fallback, receiveId);
|
||||||
|
}
|
||||||
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
||||||
return toFeishuSendResult(response, receiveId);
|
return toFeishuSendResult(response, receiveId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveReceiveIdType } from "./targets.js";
|
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
||||||
|
|
||||||
describe("resolveReceiveIdType", () => {
|
describe("resolveReceiveIdType", () => {
|
||||||
it("resolves chat IDs by oc_ prefix", () => {
|
it("resolves chat IDs by oc_ prefix", () => {
|
||||||
@@ -14,3 +14,28 @@ describe("resolveReceiveIdType", () => {
|
|||||||
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeFeishuTarget", () => {
|
||||||
|
it("strips provider and user prefixes", () => {
|
||||||
|
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
|
||||||
|
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips provider and chat prefixes", () => {
|
||||||
|
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts provider-prefixed raw ids", () => {
|
||||||
|
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("looksLikeFeishuId", () => {
|
||||||
|
it("accepts provider-prefixed user targets", () => {
|
||||||
|
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts provider-prefixed chat targets", () => {
|
||||||
|
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const CHAT_ID_PREFIX = "oc_";
|
|||||||
const OPEN_ID_PREFIX = "ou_";
|
const OPEN_ID_PREFIX = "ou_";
|
||||||
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
function stripProviderPrefix(raw: string): string {
|
||||||
|
return raw.replace(/^(feishu|lark):/i, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function detectIdType(id: string): FeishuIdType | null {
|
export function detectIdType(id: string): FeishuIdType | null {
|
||||||
const trimmed = id.trim();
|
const trimmed = id.trim();
|
||||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||||
@@ -24,18 +28,19 @@ export function normalizeFeishuTarget(raw: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowered = trimmed.toLowerCase();
|
const withoutProvider = stripProviderPrefix(trimmed);
|
||||||
|
const lowered = withoutProvider.toLowerCase();
|
||||||
if (lowered.startsWith("chat:")) {
|
if (lowered.startsWith("chat:")) {
|
||||||
return trimmed.slice("chat:".length).trim() || null;
|
return withoutProvider.slice("chat:".length).trim() || null;
|
||||||
}
|
}
|
||||||
if (lowered.startsWith("user:")) {
|
if (lowered.startsWith("user:")) {
|
||||||
return trimmed.slice("user:".length).trim() || null;
|
return withoutProvider.slice("user:".length).trim() || null;
|
||||||
}
|
}
|
||||||
if (lowered.startsWith("open_id:")) {
|
if (lowered.startsWith("open_id:")) {
|
||||||
return trimmed.slice("open_id:".length).trim() || null;
|
return withoutProvider.slice("open_id:".length).trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed;
|
return withoutProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
||||||
@@ -61,7 +66,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeFeishuId(raw: string): boolean {
|
export function looksLikeFeishuId(raw: string): boolean {
|
||||||
const trimmed = raw.trim();
|
const trimmed = stripProviderPrefix(raw.trim());
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ function createConfig(params: {
|
|||||||
drive?: boolean;
|
drive?: boolean;
|
||||||
perm?: boolean;
|
perm?: boolean;
|
||||||
};
|
};
|
||||||
|
defaultAccount?: string;
|
||||||
}): OpenClawPluginApi["config"] {
|
}): OpenClawPluginApi["config"] {
|
||||||
return {
|
return {
|
||||||
channels: {
|
channels: {
|
||||||
feishu: {
|
feishu: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
defaultAccount: params.defaultAccount,
|
||||||
accounts: {
|
accounts: {
|
||||||
a: {
|
a: {
|
||||||
appId: "app-a",
|
appId: "app-a",
|
||||||
@@ -67,6 +69,22 @@ describe("feishu tool account routing", () => {
|
|||||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
|
||||||
|
const { api, resolveTool } = createToolFactoryHarness(
|
||||||
|
createConfig({
|
||||||
|
defaultAccount: "b",
|
||||||
|
toolsA: { wiki: true },
|
||||||
|
toolsB: { wiki: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
registerFeishuWikiTools(api);
|
||||||
|
|
||||||
|
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
|
||||||
|
await tool.execute("call", { action: "search" });
|
||||||
|
|
||||||
|
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||||
|
});
|
||||||
|
|
||||||
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||||
const { api, resolveTool } = createToolFactoryHarness(
|
const { api, resolveTool } = createToolFactoryHarness(
|
||||||
createConfig({
|
createConfig({
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ function normalizeOptionalAccountId(value: string | undefined): string | undefin
|
|||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConfiguredDefaultAccountId(config: OpenClawPluginApi["config"]): string | undefined {
|
||||||
|
const value = (config?.channels?.feishu as { defaultAccount?: unknown } | undefined)
|
||||||
|
?.defaultAccount;
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return normalizeOptionalAccountId(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveFeishuToolAccount(params: {
|
export function resolveFeishuToolAccount(params: {
|
||||||
api: Pick<OpenClawPluginApi, "config">;
|
api: Pick<OpenClawPluginApi, "config">;
|
||||||
executeParams?: AccountAwareParams;
|
executeParams?: AccountAwareParams;
|
||||||
@@ -24,6 +33,7 @@ export function resolveFeishuToolAccount(params: {
|
|||||||
cfg: params.api.config,
|
cfg: params.api.config,
|
||||||
accountId:
|
accountId:
|
||||||
normalizeOptionalAccountId(params.executeParams?.accountId) ??
|
normalizeOptionalAccountId(params.executeParams?.accountId) ??
|
||||||
|
readConfiguredDefaultAccountId(params.api.config) ??
|
||||||
normalizeOptionalAccountId(params.defaultAccountId),
|
normalizeOptionalAccountId(params.defaultAccountId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user