diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5f51d2f8b..70695132b18 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 529a49b0f8e..2e54dfe9898 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -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", () => { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 9cdda4acd21..181294dd13d 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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; diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 3a56d1136d2..69377215603 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -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" })); + }); }); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 6a190242cec..b9867c496f4 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -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[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 ?? "", diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts new file mode 100644 index 00000000000..182cb3c4be9 --- /dev/null +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -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(); + }); +}); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 59810a34a15..7cb53e79f4c 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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 { 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); + }); +}); diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts index a0bd20fb1a9..209d0b3a9f3 100644 --- a/extensions/feishu/src/targets.ts +++ b/extensions/feishu/src/targets.ts @@ -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; } diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts index 4baa667112c..bceb069def9 100644 --- a/extensions/feishu/src/tool-account-routing.test.ts +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -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({ diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts index 502d7e0e60c..33cb82503aa 100644 --- a/extensions/feishu/src/tool-account.ts +++ b/extensions/feishu/src/tool-account.ts @@ -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; 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), }); }