diff --git a/index.test.ts b/index.test.ts index 05c5836..6b1584f 100644 --- a/index.test.ts +++ b/index.test.ts @@ -4,6 +4,7 @@ import { COMMANDS } from "./src/commands.js"; const controllerState = vi.hoisted(() => ({ createService: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), handleConversationBindingResolved: vi.fn(), + handleBeforeDispatch: vi.fn(), handleInboundClaim: vi.fn(), handleTelegramInteractive: vi.fn(), handleDiscordInteractive: vi.fn(), @@ -14,6 +15,7 @@ vi.mock("./src/controller.js", () => ({ CodexPluginController: class { createService = controllerState.createService; handleConversationBindingResolved = controllerState.handleConversationBindingResolved; + handleBeforeDispatch = controllerState.handleBeforeDispatch; handleInboundClaim = controllerState.handleInboundClaim; handleTelegramInteractive = controllerState.handleTelegramInteractive; handleDiscordInteractive = controllerState.handleDiscordInteractive; @@ -35,10 +37,11 @@ describe("plugin registration", () => { expect(() => plugin.register(api as never)).not.toThrow(); expect(api.registerService).toHaveBeenCalledTimes(1); expect(api.on).toHaveBeenCalledWith("inbound_claim", expect.any(Function)); + expect(api.on).toHaveBeenCalledWith("before_dispatch", expect.any(Function)); expect(api.registerInteractiveHandler).toHaveBeenCalledTimes(2); expect(api.registerCommand).toHaveBeenCalled(); expect(api.registerCommand.mock.calls.map(([params]) => params.name)).toEqual( - COMMANDS.map(([name]) => name), + [...COMMANDS.map(([name]) => name), "cas_click"], ); }); diff --git a/index.ts b/index.ts index 07f2e28..c995115 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,12 @@ const plugin = { description: "Independent OpenClaw plugin for the Codex App Server protocol.", register(api: OpenClawPluginApi) { const controller = new CodexPluginController(api); + const hookApi = api as OpenClawPluginApi & { + on?: ( + hookName: string, + handler: (event: Record, ctx?: Record) => Promise | unknown, + ) => void; + }; api.registerService(controller.createService()); @@ -25,6 +31,9 @@ const plugin = { api.on("inbound_claim", async (event) => { return await controller.handleInboundClaim(event); }); + hookApi.on?.("before_dispatch", async (event, ctx) => { + return await controller.handleBeforeDispatch(event, ctx); + }); api.registerInteractiveHandler({ channel: "telegram", @@ -54,6 +63,15 @@ const plugin = { }, }); } + + api.registerCommand({ + name: "cas_click", + description: "Internal command for Feishu card callbacks.", + acceptsArgs: true, + handler: async (ctx) => { + return await controller.handleCommand("cas_click", ctx); + }, + }); }, }; diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..59bac76 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -47,6 +47,9 @@ function createApiMock() { const sendComponentMessage = vi.fn(async (..._args: unknown[]) => ({ messageId: "discord-component-1", channelId: "channel:chan-1" })); const sendMessageDiscord = vi.fn(async (..._args: unknown[]) => ({ messageId: "discord-msg-1", channelId: "channel:chan-1" })); const sendMessageTelegram = vi.fn(async (..._args: unknown[]) => ({ messageId: "1", chatId: "123" })); + const sendMessageFeishu = vi.fn(async (..._args: unknown[]) => ({ messageId: "feishu-msg-1", chatId: "oc_group_chat" })); + const sendCardFeishu = vi.fn(async (..._args: unknown[]) => ({ messageId: "feishu-card-1", chatId: "oc_group_chat" })); + const sendOutboundText = vi.fn(async () => ({ channel: "feishu", ok: true, messageId: "feishu-outbound-1" })); const discordTypingStart = vi.fn(async () => ({ refresh: vi.fn(async () => {}), stop: vi.fn() })); const renameTopic = vi.fn(async () => ({})); const resolveTelegramToken = vi.fn(() => ({ token: "telegram-token", source: "config" })); @@ -125,6 +128,17 @@ function createApiMock() { }), ), }; + const loadOutboundAdapter = vi.fn(async (channel: string) => { + if (channel === "telegram") { + return telegramOutbound; + } + if (channel === "feishu" || channel === "lark") { + return { + sendText: sendOutboundText, + }; + } + return undefined; + }); const api = { id: "test-plugin", config: {}, @@ -154,13 +168,7 @@ function createApiMock() { opts?.fallbackLimit ?? 2000, }, outbound: { - loadAdapter: vi.fn(async (channel: string) => - channel === "telegram" - ? telegramOutbound - : channel === "discord" - ? undefined - : undefined, - ), + loadAdapter: loadOutboundAdapter, }, telegram: { sendMessageTelegram, @@ -182,6 +190,10 @@ function createApiMock() { editChannel, }, }, + feishu: { + sendMessageFeishu, + sendCardFeishu, + }, }, }, registerService: vi.fn(), @@ -195,6 +207,10 @@ function createApiMock() { sendComponentMessage, sendMessageDiscord, sendMessageTelegram, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, telegramOutbound, discordTypingStart, renameTopic, @@ -211,6 +227,10 @@ async function createControllerHarness() { sendComponentMessage, sendMessageDiscord, sendMessageTelegram, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, discordTypingStart, renameTopic, resolveTelegramToken, @@ -278,6 +298,16 @@ async function createControllerHarness() { threadState.sandbox = params.sandbox; return { ...threadState }; }), + compactThread: vi.fn(async () => ({})), + startTurn: vi.fn(() => ({ + result: Promise.resolve({ threadId: "thread-1", text: "handled" }), + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => true), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + })), startReview: vi.fn(() => ({ result: new Promise(() => {}), getThreadId: () => "thread-1", @@ -303,6 +333,10 @@ async function createControllerHarness() { sendComponentMessage, sendMessageDiscord, sendMessageTelegram, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, discordTypingStart, renameTopic, resolveTelegramToken, @@ -542,6 +576,69 @@ function buildTelegramCommandContext( } as unknown as PluginCommandContext; } +function buildFeishuCommandContext( + overrides: Partial & Record = {}, +): PluginCommandContext { + return { + senderId: "ou_user_1", + channel: "feishu", + channelId: "feishu", + isAuthorizedSender: true, + args: "", + commandBody: "/cas_status", + config: {}, + from: "feishu:group:oc_group_chat", + to: "feishu:group:oc_group_chat", + originatingTo: "chat:oc_group_chat", + accountId: "default", + messageThreadId: "om_topic_root", + requestConversationBinding: vi.fn(async () => ({ status: "bound" as const })), + detachConversationBinding: vi.fn(async () => ({ removed: true })), + getCurrentConversationBinding: vi.fn(async () => null), + ...overrides, + } as unknown as PluginCommandContext; +} + +function collectFeishuActionValues(node: unknown, out: Array> = []): Array> { + if (Array.isArray(node)) { + for (const entry of node) { + collectFeishuActionValues(entry, out); + } + return out; + } + if (!node || typeof node !== "object") { + return out; + } + const record = node as Record; + if (record.oc === "ocf1") { + out.push(record); + } + for (const value of Object.values(record)) { + collectFeishuActionValues(value, out); + } + return out; +} + +function collectFeishuMarkdownContents(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const entry of node) { + collectFeishuMarkdownContents(entry, out); + } + return out; + } + if (!node || typeof node !== "object") { + return out; + } + const record = node as Record; + if (record.tag === "markdown" && typeof record.content === "string") { + out.push(record.content); + } + for (const value of Object.values(record)) { + collectFeishuMarkdownContents(value, out); + } + return out; +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -627,6 +724,287 @@ describe("Discord controller flows", () => { ); }); + it("routes bound Feishu messages through before_dispatch using stored DM chat recovery", async () => { + const { controller, clientMock } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_dm_chat", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + await (controller as any).store.upsertFeishuDmConversation({ + accountId: "default", + userId: "ou_user_1", + conversationId: "oc_dm_chat", + updatedAt: Date.now(), + }); + clientMock.startTurn = vi.fn(() => ({ + result: Promise.resolve({ threadId: "thread-1", text: "handled" }), + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => true), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + })); + + const result = await controller.handleBeforeDispatch( + { + channel: "feishu", + content: "hello from feishu", + isGroup: false, + }, + { + accountId: "default", + channelId: "feishu", + senderId: "ou_user_1", + to: "user:ou_user_1", + } as any, + ); + + expect(result).toEqual({ handled: true }); + expect(clientMock.startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "hello from feishu", + existingThreadId: "thread-1", + sessionKey: "session-1", + workspaceDir: "/repo/openclaw", + }), + ); + }); + + it("renders Feishu cas_status as an interactive card when controls are available", async () => { + const { controller, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + threadTitle: "Feishu Thread", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_status", + buildFeishuCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + const actions = collectFeishuActionValues(payload?.card); + expect(actions.some((action) => typeof action.q === "string" && action.q.startsWith("/cas_click "))).toBe(true); + const markdownContents = collectFeishuMarkdownContents(payload?.card).join("\n"); + expect(markdownContents).toContain("Binding:"); + expect(markdownContents).toContain("Permissions:"); + }); + + it("sends Feishu cas_skills as a card with callback-backed buttons", async () => { + const { controller, sendCardFeishu } = await createControllerHarness(); + + const reply = await controller.handleCommand( + "cas_skills", + buildFeishuCommandContext({ + commandBody: "/cas_skills", + }), + ); + + expect(reply).toEqual({}); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("skill-a"); + expect(serializedCard).toContain("/cas_click "); + }); + + it("sends Feishu cas_resume as a card with callback-backed thread choices", async () => { + const { controller, sendCardFeishu } = await createControllerHarness(); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + commandBody: "/cas_resume", + messageThreadId: undefined, + }), + ); + + expect(reply).toEqual({}); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("Discord Thread"); + expect(serializedCard).toContain("/cas_click "); + }); + + it("dispatches Feishu cas_click callbacks and re-renders status after toggling permissions", async () => { + const { controller, sendCardFeishu } = await createControllerHarness(); + const conversation = { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }; + await (controller as any).store.upsertBinding({ + conversation, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "toggle-permissions", + conversation, + }); + + const reply = await controller.handleCommand( + "cas_click", + buildFeishuCommandContext({ + args: callback.token, + commandBody: `/cas_click ${callback.token}`, + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + expect((controller as any).store.getBinding(conversation)?.permissionsMode).toBe("full-access"); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + const markdownContents = collectFeishuMarkdownContents(payload?.card).join("\n"); + expect(markdownContents).toContain("Permissions: Full Access"); + }); + + it("accepts a full Feishu cas_click command body when extracting the callback token", async () => { + const { controller, sendCardFeishu } = await createControllerHarness(); + const conversation = { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }; + await (controller as any).store.upsertBinding({ + conversation, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "toggle-fast", + conversation, + }); + + const reply = await controller.handleCommand( + "cas_click", + buildFeishuCommandContext({ + args: "", + commandBody: `/cas_click ${callback.token}`, + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + expect((controller as any).store.getBinding(conversation)?.preferences?.preferredServiceTier).toBe("fast"); + }); + + it("falls back to the direct Feishu card sender when runtime card capability is unavailable", async () => { + const { controller, api, sendCardFeishu } = await createControllerHarness(); + const directCardSender = vi.fn(async () => ({ messageId: "direct-card-1" })); + (api as any).runtime.channel.feishu.sendCardFeishu = undefined; + (controller as any).resolveFeishuDirectCardSender = vi.fn(async () => directCardSender); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + threadTitle: "Feishu Thread", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_status", + buildFeishuCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + expect(sendCardFeishu).not.toHaveBeenCalled(); + expect(directCardSender).toHaveBeenCalledTimes(1); + const firstCall = directCardSender.mock.calls[0] as unknown[] | undefined; + expect(firstCall).toBeDefined(); + const params = (firstCall?.[0] ?? {}) as { card?: unknown; to?: string; accountId?: string }; + expect(params.to).toBe("oc_group_chat"); + expect(params.accountId).toBe("default"); + const actions = collectFeishuActionValues(params.card); + expect(actions.some((action) => typeof action.q === "string" && action.q.startsWith("/cas_click "))).toBe(true); + }); + + it("sends Feishu text-only callback replies only once", async () => { + const { controller, sendMessageFeishu, sendCardFeishu } = await createControllerHarness(); + const conversation = { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }; + await (controller as any).store.upsertBinding({ + conversation, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "show-mcp", + conversation, + }); + + const reply = await controller.handleCommand( + "cas_click", + buildFeishuCommandContext({ + args: callback.token, + commandBody: `/cas_click ${callback.token}`, + to: "chat:oc_group_chat", + originatingTo: "chat:oc_group_chat", + }), + ); + + expect(reply).toEqual({}); + expect(sendMessageFeishu).toHaveBeenCalledTimes(1); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + expect.stringContaining("No MCP servers reported."), + expect.objectContaining({ + accountId: "default", + }), + ); + expect(sendCardFeishu).not.toHaveBeenCalled(); + }); + it("sends resume pickers through the Discord outbound adapter when the legacy runtime is absent", async () => { const { controller, discordOutbound } = await createControllerHarnessWithoutLegacyDiscordRuntime(); diff --git a/src/controller.ts b/src/controller.ts index 4ba6fb1..1673fd6 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -16,6 +16,7 @@ import type { ReplyPayload, ConversationRef, } from "openclaw/plugin-sdk"; +import { COMMANDS } from "./commands.js"; import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js"; import { CodexAppServerModeClient, type ActiveCodexRun, isMissingThreadError } from "./client.js"; import { getThreadDisplayTitle } from "./thread-display.js"; @@ -246,6 +247,8 @@ type DiscordOutboundAdapter = { }) => Promise<{ messageId: string; channelId?: string }>; }; const MAX_TEXT_ATTACHMENT_CHARS = 16_000; +const COMMAND_NAME_SET = new Set(COMMANDS.map(([name]) => name)); +const FEISHU_CARD_CALLBACK_ACTION_ID = "openclaw.codex.callback"; const PLUGIN_VERSION = (() => { try { const packageJson = require("../package.json") as { version?: unknown }; @@ -369,6 +372,106 @@ function isDiscordChannel(channel: string): boolean { return channel.trim().toLowerCase() === "discord"; } +function isFeishuChannel(channel: string): boolean { + const normalized = channel.trim().toLowerCase(); + return normalized === "feishu" || normalized === "lark"; +} + +function isFeishuCurrentConversationBindRejection(message: string | undefined): boolean { + return message?.trim().toLowerCase() === "this command cannot bind the current conversation."; +} + +function normalizeFeishuTargetId(raw: string | undefined): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const withoutProvider = trimmed.replace(/^(feishu|lark):/i, ""); + return withoutProvider.replace(/^(chat|channel|group|dm|user):/i, ""); +} + +function normalizeFeishuConversationId(raw: string | undefined): string | undefined { + return normalizeFeishuTargetId(raw); +} + +function isLikelyFeishuChatId(raw: string | undefined): boolean { + const normalized = normalizeFeishuTargetId(raw); + return Boolean(normalized && normalized.startsWith("oc_")); +} + +function isLikelyFeishuUserId(raw: string | undefined): boolean { + const normalized = normalizeFeishuTargetId(raw); + return Boolean(normalized && (normalized.startsWith("ou_") || normalized.startsWith("on_"))); +} + +function pickFeishuConversationIdFromInbound(event: { + conversationId?: string; + parentConversationId?: string; + metadata?: Record; +}): string | undefined { + const normalizedConversation = normalizeFeishuConversationId(event.conversationId); + const normalizedParent = normalizeFeishuTargetId(event.parentConversationId); + const metadata = event.metadata; + const metadataCandidates = [ + typeof metadata?.originatingTo === "string" ? metadata.originatingTo : undefined, + typeof metadata?.chatId === "string" ? metadata.chatId : undefined, + typeof metadata?.conversationId === "string" ? metadata.conversationId : undefined, + typeof metadata?.groupId === "string" ? metadata.groupId : undefined, + typeof metadata?.to === "string" ? metadata.to : undefined, + typeof metadata?.from === "string" ? metadata.from : undefined, + typeof metadata?.channelId === "string" ? metadata.channelId : undefined, + ] + .map((candidate) => normalizeFeishuTargetId(candidate)) + .filter((candidate): candidate is string => Boolean(candidate)); + + const preferredChatId = [normalizedParent, ...metadataCandidates, normalizedConversation].find((candidate) => + isLikelyFeishuChatId(candidate), + ); + if (preferredChatId) { + return preferredChatId; + } + if (normalizedConversation && !isLikelyFeishuUserId(normalizedConversation)) { + return normalizedConversation; + } + if (normalizedParent) { + return normalizedParent; + } + return metadataCandidates.find((candidate) => !isLikelyFeishuUserId(candidate)) ?? normalizedConversation; +} + +function getCommandThreadId(ctx: PluginCommandContext): string | number | undefined { + if (ctx.messageThreadId != null && `${ctx.messageThreadId}`.trim()) { + return ctx.messageThreadId; + } + const raw = (ctx as Record).threadId; + if (typeof raw === "string" || typeof raw === "number") { + return `${raw}`.trim() ? raw : undefined; + } + return undefined; +} + +function normalizeConversationThreadId(value: unknown): string | number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const numeric = Number(trimmed); + return Number.isFinite(numeric) && `${numeric}` === trimmed ? numeric : trimmed; +} + +function getTelegramThreadId(threadId: string | number | undefined): number | undefined { + return typeof threadId === "number" ? threadId : undefined; +} + const IMAGE_FILE_EXTENSIONS = new Set([ ".png", ".jpg", @@ -387,6 +490,10 @@ function buildPlainReply(text: string): ReplyPayload { return { text }; } +function buildSupportedConversationRequiredReply(): ReplyPayload { + return { text: "This command needs a Telegram, Discord, or Feishu conversation." }; +} + function normalizeTelegramChatId(raw: string | undefined): string | undefined { if (!raw) { return undefined; @@ -556,6 +663,51 @@ function toConversationTargetFromCommand(ctx: PluginCommandContext): Conversatio if (isDiscordChannel(ctx.channel)) { return resolveDiscordCommandConversation(ctx); } + if (isFeishuChannel(ctx.channel)) { + const commandCtx = ctx as PluginCommandContext & { + originatingTo?: string; + OriginatingTo?: string; + }; + const originatingTo = + typeof commandCtx.originatingTo === "string" && commandCtx.originatingTo.trim() + ? commandCtx.originatingTo.trim() + : typeof commandCtx.OriginatingTo === "string" && commandCtx.OriginatingTo.trim() + ? commandCtx.OriginatingTo.trim() + : undefined; + const threadId = getCommandThreadId(ctx); + const normalizedChannelId = + typeof ctx.channelId === "string" && ctx.channelId.trim() + ? normalizeFeishuTargetId(ctx.channelId) + : undefined; + const channelIdAsConversation = + normalizedChannelId && !isFeishuChannel(normalizedChannelId) + ? normalizedChannelId + : undefined; + const parentConversationId = normalizeFeishuConversationId( + originatingTo ?? ctx.to ?? ctx.from ?? channelIdAsConversation, + ); + if (threadId != null && parentConversationId) { + return { + channel: "feishu", + accountId: ctx.accountId ?? "default", + conversationId: `${parentConversationId}:topic:${String(threadId).trim()}`, + parentConversationId, + threadId, + }; + } + const conversationId = normalizeFeishuConversationId( + originatingTo ?? ctx.to ?? ctx.from ?? channelIdAsConversation ?? ctx.senderId, + ); + if (!conversationId) { + return null; + } + return { + channel: "feishu", + accountId: ctx.accountId ?? "default", + conversationId, + threadId, + }; + } return null; } @@ -568,10 +720,14 @@ function toConversationTargetFromInbound(event: { isGroup?: boolean; metadata?: Record; }): ConversationTarget | null { - if (!event.accountId || !event.conversationId) { + if (!event.conversationId) { return null; } const channel = event.channel.trim().toLowerCase(); + const accountId = event.accountId?.trim() || (channel === "feishu" ? "default" : undefined); + if (!accountId) { + return null; + } const conversationIdRaw = event.conversationId?.trim(); const conversationId = channel === "discord" @@ -596,9 +752,35 @@ function toConversationTargetFromInbound(event: { if (!conversationId) { return null; } + if (channel === "feishu") { + const normalizedParent = normalizeFeishuTargetId(event.parentConversationId); + const normalizedConversation = pickFeishuConversationIdFromInbound({ + conversationId: conversationIdRaw, + parentConversationId: event.parentConversationId, + metadata: event.metadata, + }); + const normalizedThreadId = + typeof event.threadId === "string" || typeof event.threadId === "number" + ? `${event.threadId}`.trim() || undefined + : undefined; + const resolvedConversationId = + normalizedThreadId && normalizedParent + ? `${normalizedParent}:topic:${normalizedThreadId}` + : normalizedConversation; + if (!resolvedConversationId) { + return null; + } + return { + channel: "feishu", + accountId, + conversationId: resolvedConversationId, + parentConversationId: normalizedParent, + threadId: normalizedThreadId, + }; + } return { channel, - accountId: event.accountId, + accountId, conversationId, parentConversationId, threadId: @@ -843,6 +1025,72 @@ function buildReplyWithButtons(text: string, buttons?: PluginInteractiveButtons) : { text }; } +function parseInboundCodexCommand(content: string): { commandName: string; args: string } | null { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const withoutSlash = trimmed.slice(1).trim(); + if (!withoutSlash) { + return null; + } + const [commandNameRaw, ...rest] = withoutSlash.split(/\s+/); + const commandName = commandNameRaw?.trim().toLowerCase(); + if (!commandName || !COMMAND_NAME_SET.has(commandName) && commandName !== "cas_click") { + return null; + } + return { + commandName, + args: rest.join(" ").trim(), + }; +} + +function extractFeishuCardClickToken(ctx: PluginCommandContext): string | undefined { + const argsToken = ctx.args?.trim().split(/\s+/, 1)[0]?.trim(); + if (argsToken) { + return argsToken; + } + const commandBody = ctx.commandBody?.trim(); + if (!commandBody) { + return undefined; + } + const parsed = parseInboundCodexCommand(commandBody); + if (!parsed || parsed.commandName !== "cas_click") { + return undefined; + } + return parsed.args.split(/\s+/, 1)[0]?.trim() || undefined; +} + +function extractCallbackTokenFromData(callbackData: string): string | undefined { + const trimmed = callbackData.trim(); + if (!trimmed) { + return undefined; + } + const parts = trimmed.split(":"); + return parts[parts.length - 1]?.trim() || undefined; +} + +function getFeishuChatContextId(conversation: ConversationRef | ConversationTarget): string | undefined { + const parent = normalizeFeishuTargetId(conversation.parentConversationId); + if (parent) { + return parent; + } + return normalizeFeishuTargetId(conversation.conversationId.split(":topic:", 1)[0]); +} + +function isSameFeishuChatConversation( + left: ConversationRef | ConversationTarget, + right: ConversationRef | ConversationTarget, +): boolean { + const leftChatId = getFeishuChatContextId(left); + const rightChatId = getFeishuChatContextId(right); + return Boolean(leftChatId && rightChatId && leftChatId === rightChatId); +} + +function compactFeishuCardText(text: string): string { + return text.replace(/\n{3,}/g, "\n\n").trim(); +} + function extractReplyButtons(reply: ReplyPayload): PluginInteractiveButtons | undefined { const telegramButtons = asRecord(reply.channelData?.telegram)?.buttons; if (Array.isArray(telegramButtons)) { @@ -1366,6 +1614,17 @@ export class CodexPluginController { private readonly store; private serviceWorkspaceDir?: string; private lastRuntimeConfig?: unknown; + private feishuDirectCardSenderPromise?: Promise< + | ((params: { + cfg: unknown; + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + replyToMessageId?: string; + }) => Promise) + | null + >; private started = false; constructor(private readonly api: OpenClawPluginApi) { @@ -1417,16 +1676,7 @@ export class CodexPluginController { accountId: event.request.conversation.accountId, conversationId: event.request.conversation.conversationId, parentConversationId: event.request.conversation.parentConversationId, - threadId: (() => { - if (typeof event.request.conversation.threadId === "number") { - return event.request.conversation.threadId; - } - if (typeof event.request.conversation.threadId !== "string") { - return undefined; - } - const normalized = Number(event.request.conversation.threadId.trim()); - return Number.isFinite(normalized) ? normalized : undefined; - })(), + threadId: normalizeConversationThreadId(event.request.conversation.threadId), }; const pending = this.store.getPendingBind(conversation); if (!pending) { @@ -1462,6 +1712,124 @@ export class CodexPluginController { } } + private getFeishuContextCandidates( + event: Record | undefined, + ctx: Record | undefined, + ): string[] { + return [ + typeof ctx?.conversationId === "string" ? ctx.conversationId : undefined, + typeof ctx?.to === "string" ? ctx.to : undefined, + typeof ctx?.from === "string" ? ctx.from : undefined, + typeof ctx?.senderId === "string" ? ctx.senderId : undefined, + typeof event?.conversationId === "string" ? event.conversationId : undefined, + typeof event?.parentConversationId === "string" ? event.parentConversationId : undefined, + typeof event?.to === "string" ? event.to : undefined, + typeof event?.from === "string" ? event.from : undefined, + typeof event?.senderId === "string" ? event.senderId : undefined, + ] + .map((value) => normalizeFeishuTargetId(value)) + .filter((value): value is string => Boolean(value)); + } + + private resolveFeishuConversationIdFromContext( + event: Record | undefined, + ctx: Record | undefined, + accountId: string, + ): string | undefined { + const candidates = this.getFeishuContextCandidates(event, ctx); + const directChat = candidates.find((candidate) => isLikelyFeishuChatId(candidate)); + if (directChat) { + return directChat; + } + const directUser = candidates.find((candidate) => isLikelyFeishuUserId(candidate)); + if (!directUser) { + return undefined; + } + return this.store.getFeishuDmConversation({ + accountId, + userId: directUser, + }); + } + + private async trackFeishuDmConversation(params: { + accountId?: string; + conversationId?: string; + senderId?: string; + to?: string; + isGroup?: boolean; + }): Promise { + const conversationId = normalizeFeishuConversationId(params.conversationId); + const senderId = normalizeFeishuTargetId(params.senderId); + const to = normalizeFeishuTargetId(params.to); + if (!conversationId || !senderId) { + return; + } + if (!isLikelyFeishuChatId(conversationId) || !isLikelyFeishuUserId(senderId)) { + return; + } + if (params.isGroup || (to && !isLikelyFeishuUserId(to))) { + return; + } + await this.store.upsertFeishuDmConversation({ + accountId: params.accountId?.trim() || "default", + userId: senderId, + conversationId, + updatedAt: Date.now(), + }); + } + + async handleBeforeDispatch( + event: Record, + ctx: Record | undefined, + ): Promise<{ handled: boolean }> { + if (!this.settings.enabled) { + return { handled: false }; + } + const channel = + (typeof event.channel === "string" && event.channel.trim()) || + (typeof ctx?.channelId === "string" && ctx.channelId.trim()) || + ""; + if (!isFeishuChannel(channel)) { + return { handled: false }; + } + const contentRaw = + (typeof event.content === "string" && event.content) || + (typeof event.body === "string" && event.body) || + ""; + const content = contentRaw.trim(); + if (!content || content.startsWith("/")) { + return { handled: false }; + } + + await this.start(); + const accountId = + (typeof ctx?.accountId === "string" && ctx.accountId.trim()) || "default"; + const conversationId = this.resolveFeishuConversationIdFromContext(event, ctx, accountId); + if (!conversationId) { + return { handled: false }; + } + await this.trackFeishuDmConversation({ + accountId, + conversationId, + senderId: typeof ctx?.senderId === "string" ? ctx.senderId : undefined, + to: typeof ctx?.to === "string" ? ctx.to : undefined, + isGroup: Boolean(event.isGroup), + }); + return await this.handleInboundClaim({ + content, + channel: "feishu", + accountId, + conversationId, + parentConversationId: conversationId, + senderId: typeof ctx?.senderId === "string" ? ctx.senderId : undefined, + isGroup: Boolean(event.isGroup), + metadata: + event.metadata && typeof event.metadata === "object" + ? (event.metadata as Record) + : undefined, + }); + } + private formatConversationForLog(conversation: ConversationTarget): string { return [ `channel=${conversation.channel}`, @@ -1479,6 +1847,9 @@ export class CodexPluginController { conversationId?: string; parentConversationId?: string; threadId?: string | number; + senderId?: string; + from?: string; + to?: string; isGroup?: boolean; media?: PluginInboundMedia[]; metadata?: Record; @@ -1492,6 +1863,63 @@ export class CodexPluginController { if (!conversation) { return { handled: false }; } + const fallbackCommand = isFeishuChannel(event.channel) + ? parseInboundCodexCommand(event.content) + : null; + if (fallbackCommand && isFeishuChannel(conversation.channel)) { + const metadata = event.metadata && typeof event.metadata === "object" + ? event.metadata + : undefined; + const originatingConversation = + conversation.parentConversationId ?? + conversation.conversationId.split(":topic:", 1)[0] ?? + conversation.conversationId; + const commandContext = { + senderId: event.senderId, + channel: "feishu", + channelId: "feishu", + isAuthorizedSender: true, + args: fallbackCommand.args, + commandBody: `/${fallbackCommand.commandName}${fallbackCommand.args ? ` ${fallbackCommand.args}` : ""}`, + config: this.lastRuntimeConfig ?? {}, + from: + event.from ?? + (typeof metadata?.from === "string" ? metadata.from : undefined) ?? + (event.senderId ? `feishu:${event.senderId}` : undefined), + to: + event.to ?? + (typeof metadata?.to === "string" ? metadata.to : undefined) ?? + `chat:${originatingConversation}`, + originatingTo: + typeof metadata?.originatingTo === "string" && metadata.originatingTo.trim() + ? metadata.originatingTo.trim() + : `chat:${originatingConversation}`, + accountId: conversation.accountId, + messageThreadId: event.threadId, + } as unknown as PluginCommandContext; + const reply = await this.handleCommand(fallbackCommand.commandName, commandContext); + const replyText = reply.text?.trim() ?? ""; + const replyButtons = extractReplyButtons(reply); + if (replyText || replyButtons?.length) { + await this.sendReply(conversation, { + text: replyText, + buttons: replyButtons, + }); + } + return { handled: true }; + } + if (isFeishuChannel(event.channel)) { + await this.trackFeishuDmConversation({ + accountId: conversation.accountId, + conversationId: conversation.parentConversationId ?? conversation.conversationId, + senderId: event.senderId, + to: + event.metadata && typeof event.metadata === "object" && typeof event.metadata.to === "string" + ? event.metadata.to + : event.to, + isGroup: event.isGroup, + }); + } const input = await buildInboundTurnInput(event); const requiresStructuredInput = !isQueueCompatibleTurnInput(event.content, input); const activeKey = buildConversationKey(conversation); @@ -1872,6 +2300,15 @@ export class CodexPluginController { this.lastRuntimeConfig = ctx.config; const bindingApi = asScopedBindingApi(ctx); const conversation = toConversationTargetFromCommand(ctx); + if (conversation && isFeishuChannel(conversation.channel)) { + await this.trackFeishuDmConversation({ + accountId: conversation.accountId, + conversationId: conversation.parentConversationId ?? conversation.conversationId, + senderId: ctx.senderId, + to: ctx.to, + isGroup: Boolean((ctx as Record).isGroup), + }); + } const currentBinding = conversation && bindingApi.getCurrentConversationBinding ? await bindingApi.getCurrentConversationBinding() @@ -1907,14 +2344,18 @@ export class CodexPluginController { ); case "cas_detach": if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const detachResult = await bindingApi.detachConversationBinding?.(); await this.unbindConversation(conversation); return { text: detachResult?.removed - ? "Detached this conversation from Codex." - : "This conversation is not currently bound to Codex.", + ? isFeishuChannel(conversation.channel) + ? "Detached this Feishu conversation from Codex. Future messages will fall back to the default codex-agent route." + : "Detached this conversation from Codex." + : isFeishuChannel(conversation.channel) + ? "This Feishu conversation is not currently bound to Codex." + : "This conversation is not currently bound to Codex.", }; case "cas_status": return await this.handleStatusCommand( @@ -1949,6 +2390,8 @@ export class CodexPluginController { binding, Boolean(currentBinding || binding), ); + case "cas_click": + return await this.handleFeishuCardClickCommand(conversation, ctx); case "cas_init": return await this.handlePromptAlias(conversation, binding, args, "/init"); case "cas_diff": @@ -2034,7 +2477,7 @@ export class CodexPluginController { ): Promise { const parsed = parseThreadSelectionArgs(filter); if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const picker = parsed.listProjects ? await this.renderProjectPicker(conversation, binding, parsed, 0) @@ -2048,6 +2491,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + await this.sendReply(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } @@ -2062,7 +2512,7 @@ export class CodexPluginController { ): Promise { const bindingApi = asScopedBindingApi(ctx); if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const parsed = parseThreadSelectionArgs(args); if (parsed.error) { @@ -2152,6 +2602,12 @@ export class CodexPluginController { return {}; } if (parsed.listProjects || !parsed.query) { + if (binding && isFeishuChannel(channel)) { + return { + text: + `This Feishu conversation is already bound to Codex thread ${binding.threadId}. Use /cas_status to inspect it or /cas_detach to unbind.`, + }; + } const passthroughArgs = formatThreadSelectionFlags(parsed); return await this.handleListCommand(conversation, binding, passthroughArgs, channel); } @@ -2177,6 +2633,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + await this.sendReply(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } const targetPermissionsMode = this.resolveRequestedPermissionsMode( @@ -2937,7 +3400,7 @@ export class CodexPluginController { private async handleStopCommand(conversation: ConversationTarget | null): Promise { if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const active = this.activeRuns.get(buildConversationKey(conversation)); if (!active) { @@ -3164,6 +3627,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (conversation && isFeishuChannel(conversation.channel)) { + await this.sendReply(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } @@ -3269,6 +3739,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(conversation.channel)) { + await this.sendReply(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } const state = await this.client.setThreadModel({ @@ -3316,6 +3793,51 @@ export class CodexPluginController { return await this.handleStatusCommand(conversation, binding, "", bindingActive); } + private async handleFeishuCardClickCommand( + conversation: ConversationTarget | null, + ctx: PluginCommandContext, + ): Promise { + if (!conversation || !isFeishuChannel(conversation.channel)) { + return buildSupportedConversationRequiredReply(); + } + const token = extractFeishuCardClickToken(ctx); + if (!token) { + return { text: "Usage: /cas_click " }; + } + const callback = this.store.getCallback(token); + if (!callback) { + return { text: "That Codex card action expired. Please retry the command." }; + } + if (callback.expiresAt <= Date.now()) { + await this.store.removeCallback(callback.token); + return { text: "That Codex card action expired. Please retry the command." }; + } + if (!isFeishuChannel(callback.conversation.channel)) { + return { text: "That Codex card action is invalid for this conversation." }; + } + if (!isSameFeishuChatConversation(callback.conversation, conversation)) { + return { text: "That Codex card action belongs to a different conversation." }; + } + const bindingApi = asScopedBindingApi(ctx); + await this.dispatchCallbackAction(callback, { + conversation, + acknowledge: async () => {}, + clear: async () => {}, + reply: async (text) => { + await this.sendText(conversation, text); + }, + editPicker: async (picker) => { + await this.sendReply(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + }, + requestConversationBinding: undefined, + detachConversationBinding: bindingApi.detachConversationBinding, + }); + return {}; + } + private async handlePromptAlias( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -6320,6 +6842,10 @@ export class CodexPluginController { | { status: "error"; message: string } > { if (!requestBinding) { + if (isFeishuChannel(conversation.channel)) { + const binding = await this.bindConversation(conversation, params); + return { status: "bound", binding }; + } return { status: "error", message: "This action can only bind from a live command or interactive context.", @@ -6335,6 +6861,14 @@ export class CodexPluginController { summary: `Bind this conversation to Codex thread ${params.threadTitle?.trim() || params.threadId}.`, }); if (approval.status !== "bound") { + if ( + approval.status === "error" && + isFeishuChannel(conversation.channel) && + isFeishuCurrentConversationBindRejection(approval.message) + ) { + const binding = await this.bindConversation(conversation, params); + return { status: "bound", binding }; + } if (approval.status === "pending") { await this.store.upsertPendingBind({ conversation: { @@ -6681,7 +7215,13 @@ export class CodexPluginController { }, ): Promise { const delivered = await this.sendReplyWithDeliveryRef(conversation, payload); - return delivered !== null; + if (delivered !== null) { + return true; + } + if (isFeishuChannel(conversation.channel)) { + return Boolean(payload.buttons?.length || payload.text?.trim()); + } + return false; } private async sendReplyWithDeliveryRef( @@ -6949,6 +7489,37 @@ export class CodexPluginController { ); return delivered; } + if (isFeishuChannel(conversation.channel)) { + if (payload.buttons?.length) { + const sentCard = await this.sendFeishuCard(conversation, text, payload.buttons); + if (sentCard) { + return null; + } + } + const limit = this.api.runtime.channel.text.resolveTextChunkLimit( + undefined, + "feishu", + conversation.accountId, + { fallbackLimit: 2000 }, + ); + const textWithFallback = + payload.buttons?.length && text + ? `${text}\n\n[Feishu cards unavailable in this runtime; using text fallback.]` + : payload.buttons?.length + ? "Feishu cards unavailable in this runtime; using text fallback." + : text; + const chunks = textWithFallback + ? this.api.runtime.channel.text.chunkText(textWithFallback, limit).filter(Boolean) + : []; + const textChunks = chunks.length > 0 ? chunks : [textWithFallback]; + for (const chunk of textChunks) { + if (!chunk) { + continue; + } + await this.sendFeishuText(conversation, chunk); + } + return null; + } return null; } @@ -6972,6 +7543,30 @@ export class CodexPluginController { return (await loadAdapter("discord")) as DiscordOutboundAdapter | undefined; } + private async loadFeishuOutboundAdapter(): Promise<{ + sendText?: (ctx: { + cfg: unknown; + to: string; + text: string; + accountId?: string; + threadId?: string | number | null; + }) => Promise; + } | undefined> { + const loadAdapter = this.api.runtime.channel.outbound?.loadAdapter; + if (typeof loadAdapter !== "function") { + return undefined; + } + return await loadAdapter("feishu") as { + sendText?: (ctx: { + cfg: unknown; + to: string; + text: string; + accountId?: string; + threadId?: string | number | null; + }) => Promise; + } | undefined; + } + private async sendTelegramTextChunk( outbound: TelegramOutboundAdapter | undefined, conversation: ConversationTarget, @@ -7146,6 +7741,200 @@ export class CodexPluginController { throw new Error("Discord outbound messaging is unavailable."); } + private async sendFeishuText( + conversation: ConversationTarget, + text: string, + ): Promise { + const to = conversation.parentConversationId ?? conversation.conversationId; + const runtimeFeishu = (this.api.runtime.channel as { + feishu?: { + sendMessageFeishu?: ( + to: string, + text: string, + opts?: { + accountId?: string; + replyInThread?: boolean; + }, + ) => Promise; + }; + }).feishu; + if (typeof runtimeFeishu?.sendMessageFeishu === "function") { + await runtimeFeishu.sendMessageFeishu(to, text, { + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + return; + } + const outbound = await this.loadFeishuOutboundAdapter(); + if (typeof outbound?.sendText === "function") { + await outbound.sendText({ + cfg: this.getOpenClawConfig(), + to, + text, + accountId: conversation.accountId, + threadId: conversation.threadId ?? null, + }); + return; + } + throw new Error("Feishu outbound send unavailable in current plugin runtime context."); + } + + private buildFeishuCard( + conversation: ConversationTarget, + text: string, + buttons: PluginInteractiveButtons, + ): Record { + const chatId = getFeishuChatContextId(conversation); + const elements: Array> = []; + const cardText = compactFeishuCardText(text); + if (cardText) { + elements.push({ + tag: "markdown", + content: cardText, + }); + } + for (const row of buttons) { + const actions = row + .map((button) => { + const token = extractCallbackTokenFromData(button.callback_data); + if (!token) { + return null; + } + const callback = this.store.getCallback(token); + if (!callback) { + return null; + } + return { + tag: "button", + text: { + tag: "plain_text", + content: button.text, + }, + type: "primary", + value: { + oc: "ocf1", + k: "quick", + a: FEISHU_CARD_CALLBACK_ACTION_ID, + q: `/cas_click ${token}`, + c: { + ...(chatId ? { h: chatId } : {}), + e: callback.expiresAt, + }, + }, + }; + }) + .filter(Boolean) as Array>; + if (actions.length > 0) { + elements.push({ + tag: "action", + actions, + }); + } + } + return { + schema: "1.0", + config: { + wide_screen_mode: true, + }, + elements, + }; + } + + private async sendFeishuCard( + conversation: ConversationTarget, + text: string, + buttons: PluginInteractiveButtons, + ): Promise { + const to = conversation.parentConversationId ?? conversation.conversationId; + const card = this.buildFeishuCard(conversation, text, buttons); + const sendCard = (this.api.runtime.channel as { + feishu?: { + sendCardFeishu?: (params: { + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + }) => Promise; + }; + }).feishu?.sendCardFeishu; + if (typeof sendCard === "function") { + await sendCard({ + to, + card, + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + return true; + } + const directCardSender = await this.resolveFeishuDirectCardSender(); + if (!directCardSender) { + return false; + } + await directCardSender({ + cfg: this.getOpenClawConfig(), + to, + card, + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + return true; + } + + private async resolveFeishuDirectCardSender(): Promise< + | ((params: { + cfg: unknown; + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + replyToMessageId?: string; + }) => Promise) + | null + > { + if (this.feishuDirectCardSenderPromise) { + return await this.feishuDirectCardSenderPromise; + } + this.feishuDirectCardSenderPromise = (async () => { + const moduleCandidates: string[] = []; + try { + const require = createRequire(import.meta.url); + const openclawEntryPath = require.resolve("openclaw"); + const openclawRootDir = path.resolve(path.dirname(openclawEntryPath), ".."); + moduleCandidates.push(path.join(openclawRootDir, "dist/extensions/feishu/index.js")); + } catch { + // Ignore local openclaw resolution failures and try common global install paths below. + } + moduleCandidates.push( + "/opt/homebrew/lib/node_modules/openclaw/dist/extensions/feishu/index.js", + "/usr/local/lib/node_modules/openclaw/dist/extensions/feishu/index.js", + "/usr/lib/node_modules/openclaw/dist/extensions/feishu/index.js", + ); + for (const modulePath of moduleCandidates) { + if (!existsSync(modulePath)) { + continue; + } + try { + const loaded = await import(pathToFileURL(modulePath).href); + const sendCardFeishu = (loaded as { sendCardFeishu?: unknown }).sendCardFeishu; + if (typeof sendCardFeishu === "function") { + return sendCardFeishu as (params: { + cfg: unknown; + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + replyToMessageId?: string; + }) => Promise; + } + } catch { + // Ignore candidate load failures and keep trying. + } + } + return null; + })(); + return await this.feishuDirectCardSenderPromise; + } + private resolveReplyMediaLocalRoots(mediaUrl?: string): readonly string[] | undefined { const rawValue = mediaUrl?.trim(); if (!rawValue) { @@ -7210,7 +7999,7 @@ export class CodexPluginController { return await legacyTyping({ to: conversation.parentConversationId ?? conversation.conversationId, accountId: conversation.accountId, - messageThreadId: conversation.threadId, + messageThreadId: getTelegramThreadId(conversation.threadId), }); } return await this.startTelegramTypingLease(conversation); @@ -7260,6 +8049,9 @@ export class CodexPluginController { accountId: conversation.accountId, }); } + if (isFeishuChannel(conversation.channel)) { + return null; + } return null; } @@ -7334,6 +8126,20 @@ export class CodexPluginController { } return firstDelivered; } + if (isFeishuChannel(conversation.channel)) { + const limit = this.api.runtime.channel.text.resolveTextChunkLimit( + undefined, + "feishu", + conversation.accountId, + { fallbackLimit: 2000 }, + ); + const chunks = this.api.runtime.channel.text.chunkText(trimmed, limit).filter(Boolean); + const textChunks = chunks.length > 0 ? chunks : [trimmed]; + for (const chunk of textChunks) { + await this.sendFeishuText(conversation, chunk); + } + return null; + } await this.sendText(conversation, trimmed); return null; } @@ -7484,7 +8290,9 @@ export class CodexPluginController { const body = { chat_id: conversation.parentConversationId ?? conversation.conversationId, action: "typing", - ...(conversation.threadId != null ? { message_thread_id: conversation.threadId } : {}), + ...(getTelegramThreadId(conversation.threadId) != null + ? { message_thread_id: getTelegramThreadId(conversation.threadId) } + : {}), }; const sendTyping = async () => { const response = await fetch(`https://api.telegram.org/bot${token}/sendChatAction`, { @@ -7601,12 +8409,13 @@ export class CodexPluginController { conversation: ConversationTarget, name: string, ): Promise { - if (isTelegramChannel(conversation.channel) && conversation.threadId != null) { + const telegramThreadId = getTelegramThreadId(conversation.threadId); + if (isTelegramChannel(conversation.channel) && telegramThreadId != null) { const legacyRename = this.api.runtime.channel.telegram?.conversationActions?.renameTopic; if (typeof legacyRename === "function") { await legacyRename( conversation.parentConversationId ?? conversation.conversationId, - conversation.threadId, + telegramThreadId, name, { accountId: conversation.accountId, @@ -7622,7 +8431,7 @@ export class CodexPluginController { } await this.callTelegramTopicEditApi(token, { chat_id: conversation.parentConversationId ?? conversation.conversationId, - message_thread_id: conversation.threadId, + message_thread_id: telegramThreadId, name, }).catch((error) => { this.api.logger.warn(`codex telegram topic rename failed: ${String(error)}`); diff --git a/src/openclaw-plugin-sdk.d.ts b/src/openclaw-plugin-sdk.d.ts index bbee7c8..1e41b1f 100644 --- a/src/openclaw-plugin-sdk.d.ts +++ b/src/openclaw-plugin-sdk.d.ts @@ -289,6 +289,22 @@ declare module "openclaw/plugin-sdk" { ) => Promise; }; }; + feishu: { + sendMessageFeishu: ( + to: string, + text: string, + opts?: { + accountId?: string; + replyInThread?: boolean; + }, + ) => Promise<{ messageId?: string; chatId?: string }>; + sendCardFeishu?: (params: { + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + }) => Promise<{ messageId?: string; chatId?: string }>; + }; }; }; registerService: (service: OpenClawPluginService) => void; @@ -307,7 +323,7 @@ declare module "openclaw/plugin-sdk" { handler: (ctx: PluginCommandContext) => Promise | ReplyPayload; }) => void; on: ( - hookName: "inbound_claim", + hookName: "inbound_claim" | "before_dispatch", handler: (event: { content: string; channel: string; @@ -316,7 +332,7 @@ declare module "openclaw/plugin-sdk" { parentConversationId?: string; threadId?: string | number; media?: PluginInboundMedia[]; - }) => Promise<{ handled: boolean }> | { handled: boolean }, + }, ctx?: Record) => Promise<{ handled: boolean }> | { handled: boolean }, ) => void; }; } diff --git a/src/state.test.ts b/src/state.test.ts index 2a2f9ca..dae0dd6 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -281,6 +281,25 @@ describe("state store", () => { expect(binding?.permissionsMode).toBe("full-access"); }); + it("persists Feishu DM user-to-chat mappings across reload", async () => { + const dir = await makeStoreDir(); + const store = await makeStore(dir); + await store.upsertFeishuDmConversation({ + accountId: "default", + userId: "feishu:user:ou_user_1", + conversationId: "chat:oc_b57524acd79413d9b6c87fc6c9f4c684", + updatedAt: Date.now(), + }); + + const reloaded = await makeStore(dir); + expect( + reloaded.getFeishuDmConversation({ + accountId: "default", + userId: "ou_user_1", + }), + ).toBe("oc_b57524acd79413d9b6c87fc6c9f4c684"); + }); + it("migrates legacy profile and permission fields into permissions mode", async () => { const dir = await makeStoreDir(); const stateDir = path.join(dir, "openclaw-codex-app-server"); diff --git a/src/state.ts b/src/state.ts index 8cdc282..95876b4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -7,6 +7,7 @@ import type { CollaborationMode, ConversationTarget, ConversationPreferences, + FeishuDmConversation, PermissionsMode, StoreSnapshot, StoredBinding, @@ -192,14 +193,28 @@ type PutCallbackInput = function toConversationKey(target: ConversationTarget): string { const channel = target.channel.trim().toLowerCase(); + const conversationId = + channel === "feishu" || channel === "lark" + ? normalizeFeishuConversationIdForKey(target.conversationId) + : target.conversationId.trim(); return [ channel, target.accountId.trim(), - target.conversationId.trim(), + conversationId, channel === "telegram" ? (target.parentConversationId?.trim() ?? "") : "", ].join("::"); } +function normalizeFeishuConversationIdForKey(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return trimmed; + } + const withoutProvider = trimmed.replace(/^(feishu|lark):/i, ""); + const withoutRoute = withoutProvider.replace(/^(chat|channel|group|dm|user):/i, ""); + return withoutRoute || trimmed; +} + function cloneSnapshot(value?: Partial): StoreSnapshot { return { version: STORE_VERSION, @@ -207,6 +222,7 @@ function cloneSnapshot(value?: Partial): StoreSnapshot { pendingBinds: value?.pendingBinds ?? [], pendingRequests: value?.pendingRequests ?? [], callbacks: value?.callbacks ?? [], + feishuDmConversations: value?.feishuDmConversations ?? [], }; } @@ -297,6 +313,20 @@ function normalizeSnapshot(value?: Partial): StoreSnapshot { preferences: normalizeConversationPreferences(legacyPreferences), }; }); + snapshot.feishuDmConversations = snapshot.feishuDmConversations + .map((entry) => { + const accountId = entry.accountId?.trim() || "default"; + const userId = normalizeFeishuConversationIdForKey(entry.userId ?? ""); + const conversationId = normalizeFeishuConversationIdForKey(entry.conversationId ?? ""); + return { + accountId, + userId, + conversationId, + updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), + }; + }) + .filter((entry) => entry.userId.startsWith("ou_") || entry.userId.startsWith("on_")) + .filter((entry) => entry.conversationId.startsWith("oc_")); return snapshot; } @@ -343,6 +373,11 @@ export class PluginStateStore { (entry) => entry.state.expiresAt > now, ); this.snapshot.callbacks = this.snapshot.callbacks.filter((entry) => entry.expiresAt > now); + this.snapshot.feishuDmConversations = this.snapshot.feishuDmConversations.filter( + (entry) => + (entry.userId.startsWith("ou_") || entry.userId.startsWith("on_")) && + entry.conversationId.startsWith("oc_"), + ); } listBindings(): StoredBinding[] { @@ -380,6 +415,56 @@ export class PluginStateStore { this.snapshot.callbacks = this.snapshot.callbacks.filter( (entry) => toConversationKey(entry.conversation) !== key, ); + if (target.channel === "feishu" || target.channel === "lark") { + const normalizedConversation = normalizeFeishuConversationIdForKey(target.conversationId); + this.snapshot.feishuDmConversations = this.snapshot.feishuDmConversations.filter( + (entry) => normalizeFeishuConversationIdForKey(entry.conversationId) !== normalizedConversation, + ); + } + await this.save(); + } + + getFeishuDmConversation(params: { + accountId?: string; + userId?: string; + }): string | undefined { + const accountId = params.accountId?.trim() || "default"; + const userId = normalizeFeishuConversationIdForKey(params.userId ?? ""); + if (!userId) { + return undefined; + } + return this.snapshot.feishuDmConversations.find( + (entry) => + entry.accountId === accountId && + normalizeFeishuConversationIdForKey(entry.userId) === userId, + )?.conversationId; + } + + async upsertFeishuDmConversation(entry: FeishuDmConversation): Promise { + const accountId = entry.accountId?.trim() || "default"; + const userId = normalizeFeishuConversationIdForKey(entry.userId ?? ""); + const conversationId = normalizeFeishuConversationIdForKey(entry.conversationId ?? ""); + if ( + !userId || + !conversationId || + (!userId.startsWith("ou_") && !userId.startsWith("on_")) || + !conversationId.startsWith("oc_") + ) { + return; + } + this.snapshot.feishuDmConversations = this.snapshot.feishuDmConversations.filter( + (current) => + !( + current.accountId === accountId && + normalizeFeishuConversationIdForKey(current.userId) === userId + ), + ); + this.snapshot.feishuDmConversations.push({ + accountId, + userId, + conversationId, + updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(), + }); await this.save(); } diff --git a/src/types.ts b/src/types.ts index f6e161f..f975931 100644 --- a/src/types.ts +++ b/src/types.ts @@ -318,6 +318,13 @@ export type StoredPendingRequest = { updatedAt: number; }; +export type FeishuDmConversation = { + accountId: string; + userId: string; + conversationId: string; + updatedAt: number; +}; + export type CallbackAction = | { token: string; @@ -566,10 +573,11 @@ export type StoreSnapshot = { pendingBinds: StoredPendingBind[]; pendingRequests: StoredPendingRequest[]; callbacks: CallbackAction[]; + feishuDmConversations: FeishuDmConversation[]; }; export type ConversationTarget = ConversationRef & { - threadId?: number; + threadId?: string | number; }; export type CommandButtons = PluginInteractiveButtons;