mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +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.
|
||||
- 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.
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
@@ -692,8 +692,15 @@ export async function handleFeishuMessage(params: {
|
||||
const log = runtime?.log ?? console.log;
|
||||
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 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))) {
|
||||
log(`feishu: skipping duplicate message ${messageId}`);
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
sendMediaFeishu: sendMediaFeishuMock,
|
||||
@@ -12,6 +13,7 @@ vi.mock("./media.js", () => ({
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
@@ -31,6 +33,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_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 });
|
||||
}
|
||||
});
|
||||
|
||||
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 path from "path";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { sendMediaFeishu } from "./media.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
|
||||
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
||||
const raw = text?.trim();
|
||||
@@ -38,6 +39,27 @@ function normalizePossibleLocalImagePath(text: string | undefined): string | nul
|
||||
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 = {
|
||||
deliveryMode: "direct",
|
||||
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 };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
||||
// Send text first if provided
|
||||
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
|
||||
@@ -88,7 +120,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
||||
// Fallback to URL link if upload fails
|
||||
const fallbackText = `📎 ${mediaUrl}`;
|
||||
const result = await sendMessageFeishu({
|
||||
const result = await sendOutboundText({
|
||||
cfg,
|
||||
to,
|
||||
text: fallbackText,
|
||||
@@ -99,7 +131,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
}
|
||||
|
||||
// No media URL, just return text result
|
||||
const result = await sendMessageFeishu({
|
||||
const result = await sendOutboundText({
|
||||
cfg,
|
||||
to,
|
||||
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 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 = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
@@ -238,6 +248,18 @@ export async function sendMessageFeishu(
|
||||
...(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");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
@@ -278,6 +300,18 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
||||
...(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");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReceiveIdType } from "./targets.js";
|
||||
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
||||
|
||||
describe("resolveReceiveIdType", () => {
|
||||
it("resolves chat IDs by oc_ prefix", () => {
|
||||
@@ -14,3 +14,28 @@ describe("resolveReceiveIdType", () => {
|
||||
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 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 {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
@@ -24,18 +28,19 @@ export function normalizeFeishuTarget(raw: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const withoutProvider = stripProviderPrefix(trimmed);
|
||||
const lowered = withoutProvider.toLowerCase();
|
||||
if (lowered.startsWith("chat:")) {
|
||||
return trimmed.slice("chat:".length).trim() || null;
|
||||
return withoutProvider.slice("chat:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return trimmed.slice("user:".length).trim() || null;
|
||||
return withoutProvider.slice("user:".length).trim() || null;
|
||||
}
|
||||
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 {
|
||||
@@ -61,7 +66,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
||||
}
|
||||
|
||||
export function looksLikeFeishuId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
const trimmed = stripProviderPrefix(raw.trim());
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,13 @@ function createConfig(params: {
|
||||
drive?: boolean;
|
||||
perm?: boolean;
|
||||
};
|
||||
defaultAccount?: string;
|
||||
}): OpenClawPluginApi["config"] {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
defaultAccount: params.defaultAccount,
|
||||
accounts: {
|
||||
a: {
|
||||
appId: "app-a",
|
||||
@@ -67,6 +69,22 @@ describe("feishu tool account routing", () => {
|
||||
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 () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
|
||||
@@ -12,6 +12,15 @@ function normalizeOptionalAccountId(value: string | undefined): string | undefin
|
||||
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: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
@@ -24,6 +33,7 @@ export function resolveFeishuToolAccount(params: {
|
||||
cfg: params.api.config,
|
||||
accountId:
|
||||
normalizeOptionalAccountId(params.executeParams?.accountId) ??
|
||||
readConfiguredDefaultAccountId(params.api.config) ??
|
||||
normalizeOptionalAccountId(params.defaultAccountId),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user