diff --git a/docs/specs/MEDIA.md b/docs/specs/MEDIA.md index 4300594..3a4b50b 100644 --- a/docs/specs/MEDIA.md +++ b/docs/specs/MEDIA.md @@ -14,10 +14,9 @@ This is a spec/notes document only. It does not imply that inbound media support - Codex app-server already supports multimodal turn input via `UserInput`. - The supported image-shaped input items are remote/data URL images and local filesystem images. -- This plugin now supports mixed text + image turn input and forwards inbound image media into Codex when OpenClaw provides a staged media path or URL. +- This plugin currently sends text-only turn input to Codex. - OpenClaw’s plugin SDK already supports outbound attachments from a plugin via `mediaUrl` and `mediaUrls`. -- OpenClaw’s plugin SDK still does not model inbound attachments as a first-class typed field on command or `inbound_claim` events. -- In practice, current `inbound_claim` hook metadata already carries `mediaPath` / `mediaType`, which is enough for this plugin to forward a staged inbound image. +- OpenClaw’s plugin SDK does not currently expose inbound attachments or image files to plugin commands or `inbound_claim` hooks. - The cleanest future bridge is: OpenClaw stages inbound files locally, then this plugin maps image paths to Codex `localImage` items. ## Codex App-Server Input Model @@ -158,27 +157,22 @@ Or, if only a URL/data URL is available: ## Current State In This Plugin -This plugin now builds multimodal turn input when image media is available: +Today this plugin builds text-only turn input: Source: - [`src/client.ts`](../../src/client.ts) ```ts -function buildTurnInput(prompt: string, input?: readonly CodexTurnInputItem[]) { - if (input?.length) { - return input.map((item) => ({ ...item })); - } +function buildTurnInput(prompt: string): Array> { return [{ type: "text", text: prompt }]; } ``` That means: -- text-only turns still work as before -- mixed text + image turns can be forwarded into Codex -- image-only inbound turns can be forwarded into Codex -- staged text attachments such as `.txt`, `.md`, `.json`, `.yaml`, and `.yml` can be read and forwarded as additional `text` items -- unsupported binary non-image inbound media is still ignored for now +- even though Codex app-server supports images +- and even though OpenClaw can handle attachments elsewhere +- this plugin currently does not forward inbound JPEG/PNG/etc. into Codex ## OpenClaw Plugin SDK: Outbound Media @@ -283,9 +277,8 @@ export type PluginHookInboundClaimEvent = { So, from the plugin’s point of view today: - outbound attachments are supported -- inbound attachments are still not modeled as first-class typed plugin input -- `inbound_claim` metadata does already carry `mediaPath` / `mediaType`, so the plugin can use that best-effort bridge for inbound image forwarding -- command handlers still cannot rely on a first-class structured image field from OpenClaw +- inbound attachments are not modeled as first-class plugin input +- a command handler cannot currently receive a JPEG as a structured image input from OpenClaw ## OpenClaw Gateway Already Has Attachment Logic @@ -414,12 +407,10 @@ Within this repository, future media support would require at least: - local image path -> `localImage` - remote/data URL image -> `image` - mixed text + image turn input - - text attachments read and forwarded as `text` - - unsupported binary attachments ignored or downgraded to text references + - non-image attachments ignored or downgraded to text references -The remaining practical boundary is: +Until then, the practical answer is: -- Codex app-server already supports images plus ordinary text items +- Codex app-server already supports images - OpenClaw already supports outbound attachments from plugins -- this plugin can now turn staged inbound images into Codex image input and staged inbound text files into Codex text input -- richer binary formats such as PDF, audio, and video still need preprocessing before they can be meaningfully sent to Codex +- but this plugin cannot yet accept inbound JPEG/PNG/etc. from OpenClaw as Codex turn input because the current plugin boundary does not expose those attachments diff --git a/index.test.ts b/index.test.ts index 05c5836..50a9556 100644 --- a/index.test.ts +++ b/index.test.ts @@ -38,7 +38,7 @@ describe("plugin registration", () => { 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..887ec3f 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()); @@ -26,6 +32,13 @@ const plugin = { return await controller.handleInboundClaim(event); }); + hookApi.on?.("before_dispatch", async (event, ctx) => { + return await controller.handleBeforeDispatch(event, ctx); + }); + (api as OpenClawPluginApi & { logger?: { warn?: (text: string) => void } }).logger?.warn?.( + "codex plugin registered before_dispatch hook", + ); + api.registerInteractiveHandler({ channel: "telegram", namespace: INTERACTIVE_NAMESPACE, @@ -54,6 +67,18 @@ const plugin = { }, }); } + + // Internal Feishu card callback command. + // This must be registered so `/cas_click ` is routed to command handling + // instead of falling through to a normal LLM turn. + 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/package.json b/package.json index 0cc69ce..c355af1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { "name": "openclaw-codex-app-server", - "version": "0.0.0", + "version": "0.5.0", "description": "Independent OpenClaw plugin for the Codex App Server protocol", "author": "PwrDrvr LLC", "license": "MIT", - "packageManager": "pnpm@10.29.3", "type": "module", "openclaw": { "extensions": [ @@ -19,16 +18,6 @@ "pluginSdkVersion": "2026.3.22" } }, - "scripts": { - "project:sync": "node ./.agents/skills/project-manager/scripts/sync-work-items.mjs", - "test": "vitest run", - "typecheck": "tsc --noEmit", - "smoke:app-server-permissions": "node ./scripts/app-server-permissions-smoke.mjs", - "smoke:app-server-thread-permissions": "node ./scripts/app-server-thread-permissions-smoke.mjs", - "pack:smoke": "node ./scripts/pack-smoke.mjs", - "release:metadata": "node ./scripts/release-metadata.mjs", - "release:apply-version": "node ./scripts/apply-release-version.mjs" - }, "peerDependencies": { "openclaw": ">=2026.3.22" }, @@ -40,5 +29,15 @@ "typescript": "^5.9.2", "vitest": "^3.2.4", "yaml": "^2.8.2" + }, + "scripts": { + "project:sync": "node ./.agents/skills/project-manager/scripts/sync-work-items.mjs", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "smoke:app-server-permissions": "node ./scripts/app-server-permissions-smoke.mjs", + "smoke:app-server-thread-permissions": "node ./scripts/app-server-thread-permissions-smoke.mjs", + "pack:smoke": "node ./scripts/pack-smoke.mjs", + "release:metadata": "node ./scripts/release-metadata.mjs", + "release:apply-version": "node ./scripts/apply-release-version.mjs" } -} +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index c0bf4ff..d922fa9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,7 +14,6 @@ import { type CompactProgress, type CompactResult, type ContextUsageSnapshot, - type CodexTurnInputItem, type ExperimentalFeatureSummary, type McpServerSummary, type ModelSummary, @@ -26,6 +25,7 @@ import { type ReviewResult, type ReviewTarget, type SkillSummary, + type CodexTurnInputItem, type ThreadReplay, type ThreadState, type ThreadSummary, @@ -847,7 +847,7 @@ async function initializeClient(params: { }): Promise { const initializeResult = await params.client.request("initialize", { protocolVersion: DEFAULT_PROTOCOL_VERSION, - clientInfo: { name: "openclaw-codex-app-server", version: "0.0.0" }, + clientInfo: { name: "openclaw-codex-app-server", version: "0.5.0" }, capabilities: { experimentalApi: true }, }); await params.client.notify("initialized", {}); @@ -1762,7 +1762,7 @@ function extractThreadState(value: unknown): ThreadState { return { threadId: extractIds(value).threadId ?? - findFirstNestedString(value, ["threadId", "thread_id", "id", "conversationId"]) ?? + findFirstNestedString(value, ["threadId", "thread_id", "conversationId", "conversation_id"]) ?? "", threadName: findFirstNestedString(value, ["threadName", "thread_name", "name", "title"]), model: findFirstNestedString(value, ["model", "modelId", "model_id"]), @@ -2407,16 +2407,24 @@ export function isMissingThreadError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); const normalized = message.trim().toLowerCase(); return ( - normalized.includes("no rollout found for thread id") || + isPreMaterializationThreadError(error) || normalized.includes("thread not loaded") || - normalized.includes("not materialized yet") || - normalized.includes("includeturns is unavailable before first user message") || normalized.includes("thread not found") || normalized.includes("no thread found") || normalized.includes("unknown thread id") ); } +export function isPreMaterializationThreadError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.trim().toLowerCase(); + return ( + normalized.includes("no rollout found for thread id") || + normalized.includes("not materialized yet") || + normalized.includes("includeturns is unavailable before first user message") + ); +} + function buildFullAccessPluginSettings(settings: PluginSettings): PluginSettings | null { if (settings.transport !== "stdio") { return null; @@ -2617,6 +2625,7 @@ export class CodexAppServerClient { sessionKey?: string; workspaceDir: string; model?: string; + strictNew?: boolean; }): Promise { return await this.withClient( { sessionKey: params.sessionKey }, @@ -2632,14 +2641,87 @@ export class CodexAppServerClient { timeoutMs: settings.requestTimeoutMs, }); const state = extractThreadState(result); - const threadId = extractIds(result).threadId ?? state.threadId; - if (!threadId?.trim()) { + let threadId = (extractIds(result).threadId ?? state.threadId).trim(); + if (!threadId) { throw new Error("Codex App Server did not return a thread id."); } + + const resolveUsableThread = async ( + candidateThreadId: string, + missingError: unknown, + ): Promise<{ threadId: string; state: ThreadState }> => { + if (params.strictNew) { + throw new Error( + `Codex App Server returned unusable new thread id ${candidateThreadId}: ${String(missingError)}`, + ); + } + const listResult = await requestWithFallbacks({ + client, + methods: ["thread/list", "thread/loaded/list"], + payloads: buildThreadDiscoveryFilter(undefined, params.workspaceDir), + timeoutMs: settings.requestTimeoutMs, + }).catch(() => undefined); + const discovered = listResult ? extractThreadsFromValue(listResult) : []; + const candidates = discovered + .filter((thread) => thread.threadId && thread.threadId !== candidateThreadId) + .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)) + .slice(0, 12); + for (const candidate of candidates) { + try { + const resumed = await requestWithFallbacks({ + client, + methods: ["thread/resume"], + payloads: buildThreadResumePayloads({ threadId: candidate.threadId }), + timeoutMs: settings.requestTimeoutMs, + }); + return { + threadId: candidate.threadId, + state: extractThreadState(resumed), + }; + } catch { + continue; + } + } + throw new Error( + `Codex App Server returned unusable thread id ${candidateThreadId}: ${String(missingError)}`, + ); + }; + + let resolvedState = state; + try { + const resumed = await requestWithFallbacks({ + client, + methods: ["thread/resume"], + payloads: buildThreadResumePayloads({ threadId }), + timeoutMs: settings.requestTimeoutMs, + }); + resolvedState = extractThreadState(resumed); + } catch (error) { + if (!isMissingThreadError(error)) { + throw error; + } + if (params.strictNew && isPreMaterializationThreadError(error)) { + this.logger.warn( + `codex thread/start candidate not yet materialized; accepting new thread candidate=${threadId}: ${String(error)}`, + ); + return { + ...resolvedState, + threadId, + cwd: resolvedState.cwd?.trim() || params.workspaceDir, + }; + } + this.logger.warn( + `codex thread/start candidate missing; discovering usable thread candidate=${threadId}: ${String(error)}`, + ); + const recovered = await resolveUsableThread(threadId, error); + threadId = recovered.threadId; + resolvedState = recovered.state; + } + return { - ...state, + ...resolvedState, threadId, - cwd: state.cwd?.trim() || params.workspaceDir, + cwd: resolvedState.cwd?.trim() || params.workspaceDir, }; }, ); @@ -3262,6 +3344,7 @@ export class CodexAppServerClient { sandbox?: string; collaborationMode?: CollaborationMode; onPendingInput?: (state: PendingInputState | null) => Promise | void; + onAssistantDelta?: (text: string) => Promise | void; onFileEdits?: (text: string) => Promise | void; onInterrupted?: () => Promise | void; }): ActiveCodexRun { @@ -3383,6 +3466,14 @@ export class CodexAppServerClient { assistantItemId = assistantNotification.itemId; } if (assistantNotification.mode === "delta" && assistantNotification.text) { + const priorAssistantText = assistantText; + const deltaText = + priorAssistantText && assistantNotification.text.startsWith(priorAssistantText) + ? assistantNotification.text.slice(priorAssistantText.length) + : assistantNotification.text; + if (deltaText) { + await params.onAssistantDelta?.(deltaText); + } assistantText = assistantText && assistantNotification.text.startsWith(assistantText) ? assistantNotification.text @@ -3504,7 +3595,7 @@ export class CodexAppServerClient { this.logger.debug( `codex turn using shared app-server client run=${params.runId} session=${params.sessionKey ?? ""}`, ); - if (!threadId) { + const createFreshThread = async (reason: "initial" | "missing-bound-thread") => { const created = await requestWithFallbacks({ client, methods: ["thread/start", "thread/new"], @@ -3516,33 +3607,49 @@ export class CodexAppServerClient { timeoutMs: this.settings.requestTimeoutMs, }); const createdState = extractThreadState(created); - threadId = extractIds(created).threadId ?? ""; + threadId = (extractIds(created).threadId ?? createdState.threadId).trim(); + turnId = ""; threadModel = createdState.model?.trim() || threadModel; threadReasoningEffort = createdState.reasoningEffort?.trim() || threadReasoningEffort; if (!threadId) { throw new Error("Codex App Server did not return a thread id."); } this.logger.debug( - `codex turn thread created run=${params.runId} thread=${threadId} model=${threadModel || ""} reasoningEffort=${threadReasoningEffort || ""}`, + `codex turn thread created run=${params.runId} reason=${reason} thread=${threadId} model=${threadModel || ""} reasoningEffort=${threadReasoningEffort || ""}`, ); - if (params.serviceTier || params.approvalPolicy || params.sandbox) { + const resumePayload = buildThreadResumePayloads({ + threadId, + serviceTier: params.serviceTier, + approvalPolicy: params.approvalPolicy, + sandbox: params.sandbox, + })[0] as Record; + + try { const resumed = await requestWithFallbacks({ client, methods: ["thread/resume"], - payloads: buildThreadResumePayloads({ - threadId, - serviceTier: params.serviceTier, - approvalPolicy: params.approvalPolicy, - sandbox: params.sandbox, - }), + payloads: [resumePayload], timeoutMs: this.settings.requestTimeoutMs, }); const resumedState = extractThreadState(resumed); threadModel = resumedState.model?.trim() || threadModel; threadReasoningEffort = resumedState.reasoningEffort?.trim() || threadReasoningEffort; + } catch (error) { + if (!isMissingThreadError(error)) { + throw error; + } + this.logger.warn( + `codex turn created thread candidate not yet materialized; proceeding with candidate run=${params.runId} candidate=${threadId}: ${String(error)}`, + ); } + }; + + if (!threadId) { + await createFreshThread("initial"); } else { + let missingBoundThread = false; + let boundThreadNotMaterialized = false; const resumed = await requestWithFallbacks({ client, methods: ["thread/resume"], @@ -3554,14 +3661,44 @@ export class CodexAppServerClient { sandbox: params.sandbox, }), timeoutMs: this.settings.requestTimeoutMs, - }).catch(() => undefined); - const resumedState = resumed ? extractThreadState(resumed) : undefined; - threadModel = resumedState?.model?.trim() || threadModel; - threadReasoningEffort = - resumedState?.reasoningEffort?.trim() || threadReasoningEffort; - this.logger.debug( - `codex turn thread resumed run=${params.runId} thread=${threadId} model=${threadModel || ""} reasoningEffort=${threadReasoningEffort || ""}`, - ); + }).catch((error) => { + if (isMissingThreadError(error)) { + if (isPreMaterializationThreadError(error)) { + boundThreadNotMaterialized = true; + this.logger.warn( + `codex turn bound thread not yet materialized during resume run=${params.runId} thread=${threadId}: ${String(error)}`, + ); + return undefined; + } + missingBoundThread = true; + this.logger.warn( + `codex turn bound thread missing during resume run=${params.runId} thread=${threadId}: ${String(error)}`, + ); + return undefined; + } + this.logger.warn( + `codex turn resume failed run=${params.runId} thread=${threadId}: ${String(error)}`, + ); + return undefined; + }); + if (missingBoundThread) { + threadId = ""; + await createFreshThread("missing-bound-thread"); + } else { + const resumedState = resumed ? extractThreadState(resumed) : undefined; + threadModel = resumedState?.model?.trim() || threadModel; + threadReasoningEffort = + resumedState?.reasoningEffort?.trim() || threadReasoningEffort; + if (boundThreadNotMaterialized) { + this.logger.debug( + `codex turn thread resume skipped pending materialization run=${params.runId} thread=${threadId}`, + ); + } else { + this.logger.debug( + `codex turn thread resumed run=${params.runId} thread=${threadId} model=${threadModel || ""} reasoningEffort=${threadReasoningEffort || ""}`, + ); + } + } } const synthesizedDefaultMode = buildDefaultCollaborationMode({ model: params.model?.trim() || threadModel, @@ -3588,12 +3725,51 @@ export class CodexAppServerClient { `codex turn start omitted collaboration mode payload run=${params.runId} thread=${threadId} requestedMode=${collaborationMode.mode} requestedModel=${params.model?.trim() || ""} threadModel=${threadModel || ""}`, ); } - const started = await requestWithFallbacks({ - client, - methods: ["turn/start"], - payloads: turnStartPayloads, - timeoutMs: this.settings.requestTimeoutMs, - }); + let started: unknown; + try { + started = await requestWithFallbacks({ + client, + methods: ["turn/start"], + payloads: turnStartPayloads, + timeoutMs: this.settings.requestTimeoutMs, + }); + } catch (error) { + if (threadId && isMissingThreadError(error)) { + if (isPreMaterializationThreadError(error)) { + this.logger.warn( + `codex turn start received pre-materialization thread error run=${params.runId} thread=${threadId}; retrying same thread`, + ); + started = await requestWithFallbacks({ + client, + methods: ["turn/start"], + payloads: turnStartPayloads, + timeoutMs: this.settings.requestTimeoutMs, + }); + } else { + this.logger.warn( + `codex turn start failed due to missing thread run=${params.runId} staleThread=${threadId}: ${String(error)}`, + ); + await createFreshThread("missing-bound-thread"); + const retryTurnStartPayloads = buildTurnStartPayloads({ + threadId, + prompt: params.prompt, + input: params.input, + model: params.model, + serviceTier: params.serviceTier, + collaborationMode, + collaborationFallbackModel: params.model?.trim() || threadModel, + }); + started = await requestWithFallbacks({ + client, + methods: ["turn/start"], + payloads: retryTurnStartPayloads, + timeoutMs: this.settings.requestTimeoutMs, + }); + } + } else { + throw error; + } + } const startedIds = extractIds(started); threadId ||= startedIds.threadId ?? ""; turnId ||= startedIds.runId ?? ""; diff --git a/src/commands.ts b/src/commands.ts index 3b21b06..ff0dc38 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -13,6 +13,8 @@ export const COMMANDS = [ ["cas_fast", "Toggle or inspect fast mode for the current Codex binding."], ["cas_model", "List or switch the Codex model for the current binding."], ["cas_permissions", "Show Codex permissions and account status."], + ["cas_reply", "Submit a pending Codex input choice by number or label."], + ["cas_q", "Answer a pending Codex questionnaire in text mode."], ["cas_init", "Forward /init to Codex."], ["cas_diff", "Forward /diff to Codex."], ["cas_rename", "Rename the Codex thread and optionally sync the conversation name."], diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..98d3b77 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolvePluginSettings } from "./config.js"; + +describe("resolvePluginSettings", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("prefers an explicit command from plugin config", () => { + vi.stubEnv("OPENCLAW_CODEX_COMMAND", "/env/codex"); + + const settings = resolvePluginSettings({ + command: "/custom/codex", + }); + + expect(settings.command).toBe("/custom/codex"); + }); + + it("uses OPENCLAW_CODEX_COMMAND when config.command is missing", () => { + vi.stubEnv("OPENCLAW_CODEX_COMMAND", "/env/codex"); + + const settings = resolvePluginSettings({}); + + expect(settings.command).toBe("/env/codex"); + }); + + it("falls back to the bundled Codex app binary when present", () => { + vi.stubEnv("OPENCLAW_CODEX_COMMAND", ""); + vi.stubEnv("CODEX_COMMAND", ""); + vi + .spyOn(fs, "existsSync") + .mockImplementation((candidate) => candidate === "/Applications/Codex.app/Contents/Resources/codex"); + + const settings = resolvePluginSettings({}); + + expect(settings.command).toBe("/Applications/Codex.app/Contents/Resources/codex"); + }); +}); diff --git a/src/config.ts b/src/config.ts index 5d1ab5f..b67c3ef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,14 @@ +import fs from "node:fs"; import type { PluginSettings } from "./types.js"; import { DEFAULT_REQUEST_TIMEOUT_MS, } from "./types.js"; +const FALLBACK_CODEX_COMMAND_PATHS = [ + "/Applications/Codex.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/MacOS/codex", +]; + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -56,6 +62,22 @@ function readNumber( return fallback; } +function resolveDefaultCodexCommand(): string { + const envCandidates = [ + process.env.OPENCLAW_CODEX_COMMAND, + process.env.CODEX_COMMAND, + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + if (envCandidates.length > 0) { + return envCandidates[0]; + } + for (const candidate of FALLBACK_CODEX_COMMAND_PATHS) { + if (fs.existsSync(candidate)) return candidate; + } + return "codex"; +} + export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const record = asRecord(rawConfig); const transport = record.transport === "websocket" ? "websocket" : "stdio"; @@ -69,7 +91,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { return { enabled: record.enabled !== false, transport, - command: readString(record, "command") ?? "codex", + command: readString(record, "command") ?? resolveDefaultCodexCommand(), args: readStringArray(record, "args"), url: readString(record, "url"), headers: Object.keys(headers).length > 0 ? headers : undefined, diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..f1e1f7b 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -47,6 +47,23 @@ 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 () => ({ messageId: "feishu-msg-1", chatId: "oc_group_chat" })); + const sendCardFeishu = vi.fn(async () => ({ messageId: "feishu-card-1", chatId: "oc_group_chat" })); + const sendOutboundText = vi.fn(async () => ({ channel: "feishu", ok: true, messageId: "feishu-outbound-1" })); + const loadOutboundAdapter = vi.fn(async (channel: string) => { + if (channel === "telegram") { + return telegramOutbound; + } + if (channel === "discord") { + return undefined; + } + if (channel === "feishu" || channel === "lark") { + return { + sendText: sendOutboundText, + }; + } + return undefined; + }); 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" })); @@ -153,15 +170,6 @@ function createApiMock() { resolveTextChunkLimit: (_cfg: unknown, _provider?: string, _accountId?: string | null, opts?: { fallbackLimit?: number }) => opts?.fallbackLimit ?? 2000, }, - outbound: { - loadAdapter: vi.fn(async (channel: string) => - channel === "telegram" - ? telegramOutbound - : channel === "discord" - ? undefined - : undefined, - ), - }, telegram: { sendMessageTelegram, resolveTelegramToken, @@ -182,6 +190,13 @@ function createApiMock() { editChannel, }, }, + feishu: { + sendMessageFeishu, + sendCardFeishu, + }, + outbound: { + loadAdapter: loadOutboundAdapter, + }, }, }, registerService: vi.fn(), @@ -196,6 +211,10 @@ function createApiMock() { sendMessageDiscord, sendMessageTelegram, telegramOutbound, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, discordTypingStart, renameTopic, resolveTelegramToken, @@ -211,6 +230,11 @@ async function createControllerHarness() { sendComponentMessage, sendMessageDiscord, sendMessageTelegram, + telegramOutbound, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, discordTypingStart, renameTopic, resolveTelegramToken, @@ -303,6 +327,11 @@ async function createControllerHarness() { sendComponentMessage, sendMessageDiscord, sendMessageTelegram, + telegramOutbound, + sendMessageFeishu, + sendCardFeishu, + sendOutboundText, + loadOutboundAdapter, discordTypingStart, renameTopic, resolveTelegramToken, @@ -542,6 +571,89 @@ 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 collectFeishuButtons(node: unknown, out: Array> = []): Array> { + if (Array.isArray(node)) { + for (const entry of node) { + collectFeishuButtons(entry, out); + } + return out; + } + if (!node || typeof node !== "object") { + return out; + } + const record = node as Record; + if (record.tag === "button") { + out.push(record); + } + for (const value of Object.values(record)) { + collectFeishuButtons(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(); @@ -836,6 +948,256 @@ describe("Discord controller flows", () => { expect(callback?.kind).toBe("start-new-thread"); }); + it("renders a text thread list with thread ids for Feishu /cas_resume", async () => { + const { controller, clientMock, api, sendMessageFeishu } = await createControllerHarness(); + delete (api as any).runtime.channel.feishu.sendCardFeishu; + clientMock.listThreads.mockResolvedValue([ + ...Array.from({ length: 9 }).map((_, index) => ({ + threadId: `thread-${index + 1}`, + title: `Thread ${index + 1}`, + projectKey: "/repo/openclaw", + createdAt: Date.now() - (index + 1) * 1_000, + updatedAt: Date.now() - index * 1_000, + })), + ]); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "", + commandBody: "/cas_resume", + messageThreadId: undefined, + }), + ); + + expect(reply).toEqual({}); + const sentText = ((sendMessageFeishu.mock.calls as unknown) as Array<[string, string, unknown?]>) + .map(([, value]) => value) + .join("\n"); + expect(sentText).toContain("Threads on this page:"); + expect(sentText).toContain("id: thread-1"); + expect(sentText).toContain("Next page: /cas_resume --page 2"); + expect(sentText).toContain("Bind exact thread: /cas_resume "); + expect(sentText).not.toContain("Tap a thread to resume it."); + expect(sentText).toContain("Use card buttons below when available."); + }); + + it("sends Feishu /cas_resume as a structured card when card runtime is available", async () => { + const { controller, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + + const result = await controller.handleInboundClaim({ + content: "/cas_resume", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + + expect(result).toEqual({ handled: true }); + expect(sendCardFeishu.mock.calls.length).toBeGreaterThan(0); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ + to: "oc_group_chat", + accountId: "default", + })); + const actions = collectFeishuActionValues(payload?.card); + expect(actions.length).toBeGreaterThan(0); + expect(actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + oc: "ocf1", + k: "quick", + a: "openclaw.codex.callback", + c: expect.objectContaining({ + h: "oc_group_chat", + }), + }), + ])); + 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("Use the buttons below to continue."); + expect(markdownContents).not.toContain("Threads on this page:"); + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("\"schema\":\"1.0\""); + expect(serializedCard).toContain("\"tag\":\"action\""); + }); + + it("falls back to Feishu text for /cas_resume when card runtime is unavailable and notes the downgrade", async () => { + const { controller, api, sendMessageFeishu, sendCardFeishu } = await createControllerHarness(); + delete (api as any).runtime.channel.feishu.sendCardFeishu; + + const result = await controller.handleInboundClaim({ + content: "/cas_resume", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + + expect(result).toEqual({ handled: true }); + expect(sendCardFeishu).not.toHaveBeenCalled(); + expect(sendMessageFeishu).toHaveBeenCalled(); + const sentText = ((sendMessageFeishu.mock.calls as unknown) as Array<[string, string, unknown?]>) + .map(([, value]) => value) + .join("\n"); + expect(sentText).toContain("Feishu cards unavailable"); + expect(sentText).toContain("Bind exact thread: /cas_resume "); + }); + + it("supports --page for Feishu /cas_resume --projects text fallback", async () => { + const { controller, clientMock, api, sendMessageFeishu } = await createControllerHarness(); + delete (api as any).runtime.channel.feishu.sendCardFeishu; + clientMock.listThreads.mockResolvedValue([ + ...Array.from({ length: 12 }).map((_, index) => ({ + threadId: `thread-${index + 1}`, + title: `Thread ${index + 1}`, + projectKey: `/repo/project-${index + 1}`, + createdAt: Date.now() - (index + 1) * 1_000, + updatedAt: Date.now() - index * 1_000, + })), + ]); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "--projects --page 2", + commandBody: "/cas_resume --projects --page 2", + messageThreadId: undefined, + }), + ); + + expect(reply).toEqual({}); + const sentText = ((sendMessageFeishu.mock.calls as unknown) as Array<[string, string, unknown?]>) + .map(([, value]) => value) + .join("\n"); + expect(sentText).toContain("Page 2/2."); + expect(sentText).toContain("Projects on this page:"); + expect(sentText).toContain("project-12"); + expect(sentText).toContain("Prev page: /cas_resume --projects"); + }); + + it("falls back to global thread search for Feishu /cas_resume ", async () => { + const { controller, clientMock } = await createControllerHarness(); + const targetThreadId = "019d5133-b02c-73f1-8574-5ddad7f8d0a5"; + (clientMock.listThreads as any).mockImplementation(async (params: { workspaceDir?: string; filter?: string }) => { + if (params.workspaceDir === "/repo/openclaw") { + return []; + } + if (!params.workspaceDir && params.filter === targetThreadId) { + return [ + { + threadId: targetThreadId, + title: "PV_DIR_103757 你现在在哪个目录?", + projectKey: "/Users/leonzhao/.openclaw", + createdAt: Date.now() - 30_000, + updatedAt: Date.now() - 10_000, + }, + ]; + } + return []; + }); + + const requestConversationBinding = vi.fn(async () => ({ status: "bound" as const })); + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: `<${targetThreadId}>`, + commandBody: `/cas_resume <${targetThreadId}>`, + messageThreadId: undefined, + requestConversationBinding, + }), + ); + + expect(reply).toEqual({}); + expect(clientMock.listThreads).toHaveBeenNthCalledWith(1, expect.objectContaining({ + workspaceDir: "/repo/openclaw", + filter: targetThreadId, + })); + expect(clientMock.listThreads).toHaveBeenNthCalledWith(2, expect.objectContaining({ + workspaceDir: undefined, + filter: targetThreadId, + })); + expect(requestConversationBinding).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringContaining("Bind this conversation to Codex thread"), + }), + ); + const bindings = (controller as any).store.listBindings() as Array<{ + threadId: string; + }>; + expect(bindings).toContainEqual(expect.objectContaining({ + threadId: targetThreadId, + })); + }); + + it("filters Feishu /cas_resume thread picker when global results are not pre-filtered", async () => { + const { controller, clientMock, api, sendMessageFeishu } = await createControllerHarness(); + delete (api as any).runtime.channel.feishu.sendCardFeishu; + const projectQuery = "dailywork"; + (clientMock.listThreads as any).mockImplementation(async (params: { workspaceDir?: string; filter?: string }) => { + if (params.workspaceDir === "/repo/openclaw") { + return []; + } + if (!params.workspaceDir && params.filter === projectQuery) { + return [ + { + threadId: "019d6000-1111-7222-8333-aaaaaaaaaaaa", + title: "dailywork task alpha", + projectKey: "/Users/leonzhao/dailywork", + createdAt: Date.now() - 90_000, + updatedAt: Date.now() - 50_000, + }, + { + threadId: "019d6000-1111-7222-8333-bbbbbbbbbbbb", + title: "dailywork task beta", + projectKey: "/Users/leonzhao/dailywork", + createdAt: Date.now() - 80_000, + updatedAt: Date.now() - 40_000, + }, + { + threadId: "019d6000-1111-7222-8333-cccccccccccc", + title: "openclaw unrelated", + projectKey: "/Users/leonzhao/.openclaw", + createdAt: Date.now() - 70_000, + updatedAt: Date.now() - 30_000, + }, + ]; + } + return []; + }); + + const requestConversationBinding = vi.fn(async () => ({ status: "bound" as const })); + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: `<${projectQuery}>`, + commandBody: `/cas_resume <${projectQuery}>`, + messageThreadId: undefined, + requestConversationBinding, + }), + ); + + expect(reply).toEqual({}); + const sentText = ((sendMessageFeishu.mock.calls as unknown) as Array<[string, string, unknown?]>) + .map(([, value]) => value) + .join("\n"); + expect(sentText).toContain('Showing threads matching "dailywork" from all projects.'); + expect(sentText).toContain("Threads on this page:"); + expect(sentText).toContain("project: /Users/leonzhao/dailywork"); + expect(sentText).not.toContain("project: /Users/leonzhao/.openclaw"); + expect(requestConversationBinding).not.toHaveBeenCalled(); + expect(clientMock.listThreads).toHaveBeenCalledWith(expect.objectContaining({ + workspaceDir: undefined, + filter: projectQuery, + })); + }); + it("collapses matching worktrees to one project root in the /cas_resume --new picker", async () => { const { controller } = await createControllerHarness(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-worktree-picker-")); @@ -956,12 +1318,66 @@ describe("Discord controller flows", () => { sessionKey: undefined, workspaceDir: "/repo/openclaw", model: undefined, + strictNew: true, + }); + expect(requestConversationBinding).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringContaining("Bind this conversation to Codex thread"), + }), + ); + }); + + it("starts a new Feishu thread for /cas_resume --new and emits the full post-bind sequence", async () => { + const { controller, clientMock, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + (controller as any).client.readThreadContext = vi.fn(async () => ({ + lastUserMessage: "Who are you?", + lastAssistantMessage: "I am Codex.", + })); + const requestConversationBinding = vi.fn(async () => ({ status: "bound" as const })); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "--new openclaw", + commandBody: "/cas_resume --new openclaw", + requestConversationBinding, + }), + ); + + expect(reply).toEqual({}); + expect(clientMock.startThread).toHaveBeenCalledWith({ + profile: "default", + sessionKey: undefined, + workspaceDir: "/repo/openclaw", + model: undefined, + strictNew: true, }); expect(requestConversationBinding).toHaveBeenCalledWith( expect.objectContaining({ summary: expect.stringContaining("Bind this conversation to Codex thread"), }), ); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + "Last User Request in Thread:", + expect.objectContaining({ accountId: "default" }), + ); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + "Last Agent Reply in Thread:", + expect.objectContaining({ accountId: "default" }), + ); + expect(sendCardFeishu.mock.calls.length).toBeGreaterThan(0); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.at(-1)?.[0]; + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("Binding:"); + expect(serializedCard).toContain("Thread:"); + expect(serializedCard).toContain("Permissions:"); + expect(serializedCard).toContain("Select Model"); + expect(serializedCard).toContain("Skills"); + const buttons = collectFeishuButtons(payload?.card); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((button) => button.type === "primary")).toBe(true); }); it("keeps grouped project names in the /cas_resume --new picker and disambiguates after selection", async () => { @@ -1059,6 +1475,7 @@ describe("Discord controller flows", () => { sessionKey: undefined, workspaceDir: path.join(os.homedir(), "github/openclaw"), model: undefined, + strictNew: true, }); }); @@ -2112,123 +2529,344 @@ describe("Discord controller flows", () => { ); }); - it("does not hydrate a denied pending bind into cas_status", async () => { - const { controller } = await createControllerHarness(); - await (controller as any).store.upsertPendingBind({ + it("renders Feishu cas_status as an interactive card when controls are available", async () => { + const { controller, sendCardFeishu, sendMessageFeishu, sendComponentMessage, sendMessageTelegram } = + await createControllerHarness(); + await (controller as any).store.upsertBinding({ conversation: { - channel: "telegram", + channel: "feishu", accountId: "default", - conversationId: "123", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", }, + sessionKey: "session-1", threadId: "thread-1", - workspaceDir: "/repo/discrawl", - threadTitle: "Summarize tools used", + workspaceDir: "/repo/openclaw", + threadTitle: "Feishu Thread", updatedAt: Date.now(), }); const reply = await controller.handleCommand( "cas_status", - buildTelegramCommandContext({ + buildFeishuCommandContext({ commandBody: "/cas_status", - getCurrentConversationBinding: vi.fn(async () => null), + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), }), ); - expect(reply.text).toContain("Binding: none"); - expect(reply.text).not.toContain("Project folder: /repo/discrawl"); - expect((controller as any).store.getBinding({ - channel: "telegram", + expect(reply).toEqual({}); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + expect(sendComponentMessage).not.toHaveBeenCalled(); + expect(sendMessageTelegram).not.toHaveBeenCalled(); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ accountId: "default", - conversationId: "123", - })).toBeNull(); - }); - - it("shows plan mode on in cas_status when the bound conversation has an active plan run", async () => { - const { controller, sendComponentMessage } = await createControllerHarness(); - await (controller as any).store.upsertBinding({ - conversation: { - channel: "discord", - accountId: "default", - conversationId: "channel:chan-1", - }, - sessionKey: "session-1", - threadId: "thread-1", - workspaceDir: "/repo/openclaw", - threadTitle: "Discord Thread", - updatedAt: Date.now(), - }); - (controller as any).activeRuns.set("discord::default::channel:chan-1::", { - conversation: { - channel: "discord", - accountId: "default", - conversationId: "channel:chan-1", - }, - workspaceDir: "/repo/openclaw", - mode: "plan", - handle: { - result: Promise.resolve({ threadId: "thread-1", text: "planned" }), - queueMessage: vi.fn(async () => true), - getThreadId: () => "thread-1", - interrupt: vi.fn(async () => {}), - isAwaitingInput: () => false, - submitPendingInput: vi.fn(async () => false), - submitPendingInputPayload: vi.fn(async () => false), - }, - }); - - const reply = await controller.handleCommand("cas_status", buildDiscordCommandContext({ - commandBody: "/cas_status", - getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), })); - - expect(reply).toEqual({ - text: "Sent Codex status controls to this Discord conversation.", - }); - expect(sendComponentMessage).toHaveBeenCalledWith( - "channel:chan-1", - expect.objectContaining({ - text: expect.stringContaining("Binding: Discord Thread (openclaw)"), - }), - expect.objectContaining({ accountId: "default" }), - ); - expect(sendComponentMessage).toHaveBeenCalledWith( - "channel:chan-1", - expect.objectContaining({ - text: expect.stringContaining("Plan mode: on"), - }), - expect.objectContaining({ accountId: "default" }), - ); + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("Binding:"); + expect(serializedCard).toContain("Select Model"); + expect(serializedCard).toContain("Permissions: toggle"); + expect(serializedCard).toContain("Skills"); + expect(serializedCard).toContain("MCPs"); + const actions = collectFeishuActionValues(payload?.card); + expect(actions.length).toBeGreaterThan(0); + const buttons = collectFeishuButtons(payload?.card); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((button) => button.type === "primary")).toBe(true); }); - it("sends and pins status control buttons when a binding exists", async () => { - const { controller, sendMessageTelegram } = await createControllerHarness(); - const fetchMock = vi.mocked(fetch); + it("uses local Feishu binding for cas_status when host binding is unavailable", async () => { + const { controller, sendCardFeishu } = await createControllerHarness(); await (controller as any).store.upsertBinding({ conversation: { - channel: "telegram", + channel: "feishu", accountId: "default", - conversationId: "123", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", }, sessionKey: "session-1", - threadId: "thread-1", + threadId: "thread-local-1", workspaceDir: "/repo/openclaw", + threadTitle: "Feishu Local Thread", updatedAt: Date.now(), }); const reply = await controller.handleCommand( "cas_status", - buildTelegramCommandContext({ + buildFeishuCommandContext({ commandBody: "/cas_status", - getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + getCurrentConversationBinding: vi.fn(async () => null), }), ); expect(reply).toEqual({}); - expect(sendMessageTelegram).toHaveBeenCalledTimes(1); - const firstCall = sendMessageTelegram.mock.calls[0] as unknown as - | [string, string, { buttons?: Array> }] - | undefined; - const buttons = firstCall?.[2]?.buttons ?? []; + 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("Binding: Discord Thread (openclaw)"); + expect(serializedCard).toContain("Thread:"); + expect(serializedCard).not.toContain("Binding: none"); + }); + + it("binds a Feishu conversation locally when core binding helpers are unavailable", async () => { + const { controller } = await createControllerHarness(); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "thread-1", + commandBody: "/cas_resume thread-1", + from: "feishu:ou_user_1", + to: "user:ou_user_1", + messageThreadId: undefined, + requestConversationBinding: undefined, + getCurrentConversationBinding: undefined, + }), + ); + + expect(reply).toEqual({}); + expect( + (controller as any).store.getBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }), + ).toEqual(expect.objectContaining({ + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + })); + }); + + it("binds a Feishu conversation locally when host binding rejects the current conversation", async () => { + const { controller } = await createControllerHarness(); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "thread-1", + commandBody: "/cas_resume thread-1", + requestConversationBinding: vi.fn(async () => ({ + status: "error" as const, + message: "This command cannot bind the current conversation.", + })), + }), + ); + + expect(reply).toEqual({}); + expect( + (controller as any).store.getBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }), + ).toEqual(expect.objectContaining({ + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + })); + }); + + it("starts a new Feishu thread locally when host binding rejects the current conversation", async () => { + const { controller, clientMock, sendCardFeishu } = await createControllerHarness(); + + const reply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "--new openclaw", + commandBody: "/cas_resume --new openclaw", + requestConversationBinding: vi.fn(async () => ({ + status: "error" as const, + message: "This command cannot bind the current conversation.", + })), + }), + ); + + expect(reply).toEqual({}); + expect(clientMock.startThread).toHaveBeenCalledWith(expect.objectContaining({ + workspaceDir: "/repo/openclaw", + strictNew: true, + })); + const binding = (controller as any).store + .listBindings() + .find((entry: any) => entry.conversation.channel === "feishu" && entry.workspaceDir === "/repo/openclaw"); + expect(binding).toEqual(expect.objectContaining({ + workspaceDir: "/repo/openclaw", + })); + expect(binding?.threadId).toBe("thread-new"); + expect(sendCardFeishu).toHaveBeenCalled(); + }); + + it("sends Feishu text through outbound adapter when direct Feishu sender is unavailable", async () => { + const { controller, api, loadOutboundAdapter, sendOutboundText } = await createControllerHarness(); + delete (api as any).runtime.channel.feishu; + + await (controller as any).sendTextWithDeliveryRef( + { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + parentConversationId: "oc_group_chat", + }, + "where are you", + ); + + expect(loadOutboundAdapter).toHaveBeenCalledWith("feishu"); + expect(sendOutboundText).toHaveBeenCalledWith( + expect.objectContaining({ + to: "oc_group_chat", + text: "where are you", + accountId: "default", + }), + ); + }); + + it("renders cas_model as plain text for Feishu without sending interactive controls", async () => { + const { controller, sendMessageFeishu, sendComponentMessage } = 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", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_model", + buildFeishuCommandContext({ + commandBody: "/cas_model", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply.text).toContain("openai/gpt-5.4"); + expect(reply.text).toContain("Reply with /cas_model "); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + expect(sendComponentMessage).not.toHaveBeenCalled(); + }); + + it("does not hydrate a denied pending bind into cas_status", async () => { + const { controller } = await createControllerHarness(); + await (controller as any).store.upsertPendingBind({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "123", + }, + threadId: "thread-1", + workspaceDir: "/repo/discrawl", + threadTitle: "Summarize tools used", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_status", + buildTelegramCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => null), + }), + ); + + expect(reply.text).toContain("Binding: none"); + expect(reply.text).not.toContain("Project folder: /repo/discrawl"); + expect((controller as any).store.getBinding({ + channel: "telegram", + accountId: "default", + conversationId: "123", + })).toBeNull(); + }); + + it("shows plan mode on in cas_status when the bound conversation has an active plan run", async () => { + const { controller, sendComponentMessage } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + threadTitle: "Discord Thread", + updatedAt: Date.now(), + }); + (controller as any).activeRuns.set("discord::default::channel:chan-1::", { + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + workspaceDir: "/repo/openclaw", + mode: "plan", + handle: { + result: Promise.resolve({ threadId: "thread-1", text: "planned" }), + queueMessage: vi.fn(async () => true), + getThreadId: () => "thread-1", + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + }, + }); + + const reply = await controller.handleCommand("cas_status", buildDiscordCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + })); + + expect(reply).toEqual({ + text: "Sent Codex status controls to this Discord conversation.", + }); + expect(sendComponentMessage).toHaveBeenCalledWith( + "channel:chan-1", + expect.objectContaining({ + text: expect.stringContaining("Binding: Discord Thread (openclaw)"), + }), + expect.objectContaining({ accountId: "default" }), + ); + expect(sendComponentMessage).toHaveBeenCalledWith( + "channel:chan-1", + expect.objectContaining({ + text: expect.stringContaining("Plan mode: on"), + }), + expect.objectContaining({ accountId: "default" }), + ); + }); + + it("sends and pins status control buttons when a binding exists", async () => { + const { controller, sendMessageTelegram } = await createControllerHarness(); + const fetchMock = vi.mocked(fetch); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "123", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_status", + buildTelegramCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + expect(sendMessageTelegram).toHaveBeenCalledTimes(1); + const firstCall = sendMessageTelegram.mock.calls[0] as unknown as + | [string, string, { buttons?: Array> }] + | undefined; + const buttons = firstCall?.[2]?.buttons ?? []; expect(buttons).toHaveLength(5); expect(buttons[0][0].text).toBe("Select Model"); @@ -2729,114 +3367,937 @@ describe("Discord controller flows", () => { }), ); - await controller.handleCommand( - "cas_detach", - buildTelegramCommandContext({ - commandBody: "/cas_detach", - messageThreadId: 456, - getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "binding-1" })), - }), - ); + await controller.handleCommand( + "cas_detach", + buildTelegramCommandContext({ + commandBody: "/cas_detach", + messageThreadId: 456, + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "binding-1" })), + }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.telegram.org/bottelegram-token/unpinChatMessage", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("pins the Discord status message and unpins it on detach", async () => { + const { controller, sendComponentMessage } = await createControllerHarness(); + const fetchMock = vi.mocked(fetch); + vi.spyOn(controller as any, "resolveDiscordBotToken").mockResolvedValue("discord-token"); + + await controller.handleCommand( + "cas_resume", + buildDiscordCommandContext({ + args: "thread-1", + commandBody: "/cas_resume thread-1", + }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/channels/channel%3Achan-1/pins/discord-component-1", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bot discord-token", + }), + }), + ); + expect(sendComponentMessage).toHaveBeenCalledWith( + "channel:chan-1", + expect.objectContaining({ + text: expect.stringContaining("Binding: Discord Thread (openclaw)"), + blocks: expect.arrayContaining([ + expect.objectContaining({ + buttons: expect.arrayContaining([ + expect.objectContaining({ label: "Refresh" }), + expect.objectContaining({ label: "Detach" }), + ]), + }), + ]), + }), + expect.objectContaining({ accountId: "default" }), + ); + expect( + (controller as any).store.getBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }), + ).toEqual( + expect.objectContaining({ + pinnedBindingMessage: { + provider: "discord", + messageId: "discord-component-1", + channelId: "channel:chan-1", + }, + }), + ); + + await controller.handleCommand( + "cas_detach", + buildDiscordCommandContext({ + commandBody: "/cas_detach", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "binding-1" })), + }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/channels/channel%3Achan-1/pins/discord-component-1", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: "Bot discord-token", + }), + }), + ); + }); + + it("resolves the Discord bot token through the host api when the sdk facade is unavailable", async () => { + const { controller } = await createControllerHarness(); + (controller as any).lastRuntimeConfig = { plugins: { discord: {} } }; + vi.spyOn(controller as any, "loadDiscordSdk").mockRejectedValue( + new Error( + "Cannot find module '/Users/huntharo/github/openclaw/dist/plugin-sdk/root-alias.cjs/discord'", + ), + ); + const resolveDiscordAccount = vi.fn(() => ({ token: "discord-token" })); + vi.spyOn(controller as any, "loadDiscordExtensionApi").mockResolvedValue({ + resolveDiscordAccount, + }); + + const token = await (controller as any).resolveDiscordBotToken("default"); + + expect(token).toBe("discord-token"); + expect(resolveDiscordAccount).toHaveBeenCalledWith({ + cfg: { plugins: { discord: {} } }, + accountId: "default", + }); + }); + + it("detaches a Feishu conversation without requiring Telegram or Discord", async () => { + const { controller } = 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", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_detach", + buildFeishuCommandContext({ + commandBody: "/cas_detach", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "binding-1" })), + }), + ); + + expect(reply).toEqual({ + text: "Detached this Feishu conversation from Codex. Future messages will fall back to the default codex-agent route.", + }); + expect( + (controller as any).store.getBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }), + ).toBeNull(); + }); + + it("claims only locally bound Feishu inbound conversations and preserves string thread ids", async () => { + const { controller } = await createControllerHarness(); + const startTurn = vi.fn(async () => undefined); + (controller as any).startTurn = startTurn; + + const unbound = await controller.handleInboundClaim({ + content: "hello", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }); + + expect(unbound).toEqual({ handled: false }); + + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const bound = await controller.handleInboundClaim({ + content: "hello", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }); + + expect(bound).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ + conversation: expect.objectContaining({ + channel: "feishu", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + threadId: "om_topic_root", + }), + })); + }); + + it("falls back to plugin command handling for Feishu /cas_status received via inbound claim", async () => { + const { controller, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + const startTurn = vi.fn(async () => undefined); + (controller as any).startTurn = startTurn; + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const result = await controller.handleInboundClaim({ + content: "/cas_status", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).not.toHaveBeenCalled(); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ + to: "oc_group_chat", + accountId: "default", + })); + expect(JSON.stringify(payload?.card)).toContain("Binding:"); + }); + + it("falls back to plugin command handling for Feishu /cas_detach received via inbound claim", async () => { + const { controller, sendMessageFeishu } = await createControllerHarness(); + const startTurn = vi.fn(async () => undefined); + (controller as any).startTurn = startTurn; + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + + const result = await controller.handleInboundClaim({ + content: "/cas_detach", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).not.toHaveBeenCalled(); + expect((controller as any).store.getBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + })).toBeNull(); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + expect.stringContaining("Detached this Feishu conversation from Codex."), + expect.objectContaining({ accountId: "default" }), + ); + }); + + it("skips Feishu before_dispatch when context only has a user id conversation", async () => { + const { controller } = await createControllerHarness(); + const claimSpy = vi.spyOn(controller, "handleInboundClaim"); + claimSpy.mockResolvedValue({ handled: true }); + + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + }, + sessionKey: "session-dm", + threadId: "thread-dm", + workspaceDir: "/Users/leonzhao/dailywork", + updatedAt: Date.now(), + }); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_5e87bd10b496378e9fe52b9b8c7cd709", + }, + sessionKey: "session-group", + threadId: "thread-group", + workspaceDir: "/Users/leonzhao/.openclaw", + updatedAt: Date.now(), + }); + + const result = await controller.handleBeforeDispatch( + { + channel: "feishu", + content: "你在哪个目录?", + }, + { + accountId: "default", + channelId: "feishu", + senderId: "ou_user_1", + conversationId: "ou_user_1", + }, + ); + + expect(result).toEqual({ handled: false }); + expect(claimSpy).not.toHaveBeenCalled(); + }); + + it("maps Feishu before_dispatch user conversation to a known DM chat binding", async () => { + const { controller } = await createControllerHarness(); + const claimSpy = vi.spyOn(controller, "handleInboundClaim"); + claimSpy.mockResolvedValue({ handled: true }); + + await (controller as any).store.upsertFeishuDmConversation({ + accountId: "default", + userId: "ou_user_1", + conversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + updatedAt: Date.now(), + }); + + const result = await controller.handleBeforeDispatch( + { + channel: "feishu", + content: "继续排查", + isGroup: false, + }, + { + accountId: "default", + channelId: "feishu", + senderId: "ou_user_1", + conversationId: "ou_user_1", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(claimSpy).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "feishu", + conversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + parentConversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + }), + ); + }); + + it("handles Feishu before_dispatch when context contains a chat conversation id", async () => { + const { controller } = await createControllerHarness(); + const claimSpy = vi.spyOn(controller, "handleInboundClaim"); + claimSpy.mockResolvedValue({ handled: true }); + + const result = await controller.handleBeforeDispatch( + { + channel: "feishu", + content: "继续排查", + }, + { + accountId: "default", + channelId: "feishu", + senderId: "ou_user_1", + conversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(claimSpy).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "feishu", + conversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + parentConversationId: "oc_b57524acd79413d9b6c87fc6c9f4c684", + }), + ); + }); + + it("accepts Feishu text fallback replies for pending approvals", async () => { + const { controller } = await createControllerHarness(); + const submitPendingInput = vi.fn(async () => true); + (controller as any).activeRuns.set("feishu::default::ou_user_1::", { + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_user_1", + }, + workspaceDir: "/repo/openclaw", + mode: "default", + handle: { + result: new Promise(() => {}), + queueMessage: vi.fn(async () => false), + submitPendingInput, + submitPendingInputPayload: vi.fn(async () => false), + interrupt: vi.fn(async () => undefined), + isAwaitingInput: vi.fn(() => true), + getThreadId: vi.fn(() => "thread-1"), + }, + }); + await (controller as any).store.upsertPendingRequest({ + requestId: "req-1", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_user_1", + }, + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + state: { + requestId: "req-1", + options: ["accept", "decline"], + actions: [ + { + kind: "approval", + label: "Accept", + decision: "accept", + responseDecision: "accept", + }, + { + kind: "approval", + label: "Decline", + decision: "decline", + responseDecision: "decline", + }, + ], + expiresAt: Date.now() + 60_000, + }, + updatedAt: Date.now(), + }); + + const handled = await controller.handleInboundClaim({ + content: "1", + channel: "feishu", + accountId: "default", + conversationId: "ou_user_1", + }); + + expect(handled).toEqual({ handled: true }); + expect(submitPendingInput).toHaveBeenCalledWith(0); + }); + + it("dispatches /cas_click for Feishu pending-input callbacks and rejects cross-conversation clicks", async () => { + const { controller, sendMessageFeishu } = await createControllerHarness(); + const submitPendingInput = vi.fn(async () => true); + (controller as any).activeRuns.set("feishu::default::oc_group_chat::", { + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + workspaceDir: "/repo/openclaw", + mode: "default", + handle: { + result: new Promise(() => {}), + queueMessage: vi.fn(async () => false), + submitPendingInput, + submitPendingInputPayload: vi.fn(async () => false), + interrupt: vi.fn(async () => undefined), + isAwaitingInput: vi.fn(() => true), + getThreadId: vi.fn(() => "thread-1"), + }, + }); + await (controller as any).store.upsertPendingRequest({ + requestId: "req-click-1", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + state: { + requestId: "req-click-1", + options: ["approve", "decline"], + actions: [ + { + kind: "approval", + label: "Approve", + decision: "accept", + responseDecision: "accept", + }, + { + kind: "approval", + label: "Decline", + decision: "decline", + responseDecision: "decline", + }, + ], + expiresAt: Date.now() + 60_000, + }, + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "pending-input", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + requestId: "req-click-1", + actionIndex: 0, + ttlMs: 60_000, + }); + + const handled = await controller.handleInboundClaim({ + content: `/cas_click ${callback.token}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + + expect(handled).toEqual({ handled: true }); + expect(submitPendingInput).toHaveBeenCalledWith(0); + + const crossConversationCallback = await (controller as any).store.putCallback({ + kind: "pending-input", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + requestId: "req-click-1", + actionIndex: 1, + ttlMs: 60_000, + }); + + const crossHandled = await controller.handleInboundClaim({ + content: `/cas_click ${crossConversationCallback.token}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_other_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_other_chat", + }, + }); + + expect(crossHandled).toEqual({ handled: true }); + expect(submitPendingInput).toHaveBeenCalledTimes(1); + expect(sendMessageFeishu).toHaveBeenLastCalledWith( + "oc_other_chat", + expect.stringContaining("different conversation"), + expect.objectContaining({ accountId: "default" }), + ); + }); + + it("dispatches /cas_click for Feishu permission toggles and re-renders Full Access status", async () => { + const { controller, clientMock, 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", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + permissionsMode: "default", + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "toggle-permissions", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + }); + + const reply = await controller.handleCommand( + "cas_click", + buildFeishuCommandContext({ + args: callback.token, + commandBody: `/cas_click ${callback.token}`, + }), + ); + + expect(reply).toEqual({}); + expect(clientMock.setThreadPermissions).toHaveBeenCalledWith({ + profile: "full-access", + sessionKey: "session-1", + threadId: "thread-1", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + const binding = (controller as any).store.getBinding({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }); + expect(binding?.permissionsMode).toBe("full-access"); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ + accountId: "default", + to: "oc_group_chat", + })); + expect(JSON.stringify(payload?.card)).toContain("Permissions: Full Access"); + const buttons = collectFeishuButtons(payload?.card); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((button) => button.type === "primary")).toBe(true); + }); + + it("treats Feishu quoted approval text as stale input when no active run is waiting", async () => { + const { controller, clientMock, sendMessageFeishu } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + sessionKey: "plugin-session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + await (controller as any).store.upsertPendingRequest({ + requestId: "req-stale-text", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + state: { + requestId: "req-stale-text", + options: ["approve once", "deny"], + actions: [ + { + kind: "approval", + label: "Approve once", + decision: "accept", + responseDecision: "accept", + }, + { + kind: "approval", + label: "Deny", + decision: "decline", + responseDecision: "decline", + }, + ], + expiresAt: Date.now() + 60_000, + }, + updatedAt: Date.now(), + }); + + const handled = await controller.handleInboundClaim({ + content: "1 approve once", + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + + expect(handled).toEqual({ handled: true }); + expect(clientMock.startThread).not.toHaveBeenCalled(); + expect(sendMessageFeishu).toHaveBeenLastCalledWith( + "oc_group_chat", + "No active Codex run is waiting for input.", + expect.objectContaining({ accountId: "default" }), + ); + expect((controller as any).store.getPendingRequestById("req-stale-text")).toBeNull(); + }); + + it("clears stale Feishu pending-input callbacks when /cas_click arrives after the active run is gone", async () => { + const { controller, clientMock, sendMessageFeishu } = await createControllerHarness(); + await (controller as any).store.upsertPendingRequest({ + requestId: "req-stale-click", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + state: { + requestId: "req-stale-click", + options: ["approve once", "deny"], + actions: [ + { + kind: "approval", + label: "Approve once", + decision: "accept", + responseDecision: "accept", + }, + { + kind: "approval", + label: "Deny", + decision: "decline", + responseDecision: "decline", + }, + ], + expiresAt: Date.now() + 60_000, + }, + updatedAt: Date.now(), + }); + const callback = await (controller as any).store.putCallback({ + kind: "pending-input", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + requestId: "req-stale-click", + actionIndex: 0, + ttlMs: 60_000, + }); + + const handled = await controller.handleInboundClaim({ + content: `/cas_click ${callback.token}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.telegram.org/bottelegram-token/unpinChatMessage", - expect.objectContaining({ - method: "POST", - }), + expect(handled).toEqual({ handled: true }); + expect(clientMock.startThread).not.toHaveBeenCalled(); + expect(sendMessageFeishu).toHaveBeenLastCalledWith( + "oc_group_chat", + "No active Codex run is waiting for input.", + expect.objectContaining({ accountId: "default" }), ); + expect((controller as any).store.getPendingRequestById("req-stale-click")).toBeNull(); + expect((controller as any).store.getCallback(callback.token)).toBeNull(); }); - it("pins the Discord status message and unpins it on detach", async () => { - const { controller, sendComponentMessage } = await createControllerHarness(); - const fetchMock = vi.mocked(fetch); - vi.spyOn(controller as any, "resolveDiscordBotToken").mockResolvedValue("discord-token"); + it("rejects expired /cas_click tokens for Feishu callbacks", async () => { + const { controller, sendMessageFeishu } = await createControllerHarness(); + const callback = await (controller as any).store.putCallback({ + kind: "reply-text", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + text: "stale", + ttlMs: -1, + }); - await controller.handleCommand( - "cas_resume", - buildDiscordCommandContext({ - args: "thread-1", - commandBody: "/cas_resume thread-1", - }), - ); + const handled = await controller.handleInboundClaim({ + content: `/cas_click ${callback.token}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); - expect(fetchMock).toHaveBeenCalledWith( - "https://discord.com/api/v10/channels/channel%3Achan-1/pins/discord-component-1", - expect.objectContaining({ - method: "PUT", - headers: expect.objectContaining({ - Authorization: "Bot discord-token", - }), - }), - ); - expect(sendComponentMessage).toHaveBeenCalledWith( - "channel:chan-1", - expect.objectContaining({ - text: expect.stringContaining("Binding: Discord Thread (openclaw)"), - blocks: expect.arrayContaining([ - expect.objectContaining({ - buttons: expect.arrayContaining([ - expect.objectContaining({ label: "Refresh" }), - expect.objectContaining({ label: "Detach" }), - ]), - }), - ]), - }), + expect(handled).toEqual({ handled: true }); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + expect.stringContaining("expired"), expect.objectContaining({ accountId: "default" }), ); - expect( - (controller as any).store.getBinding({ - channel: "discord", + }); + + it("progresses Feishu questionnaires through /cas_click for prev, next, freeform, and option selection", async () => { + const { controller } = await createControllerHarness(); + const submitPendingInputPayload = vi.fn(async () => true); + (controller as any).activeRuns.set("feishu::default::oc_group_chat::", { + conversation: { + channel: "feishu", accountId: "default", - conversationId: "channel:chan-1", - }), - ).toEqual( - expect.objectContaining({ - pinnedBindingMessage: { - provider: "discord", - messageId: "discord-component-1", - channelId: "channel:chan-1", + conversationId: "oc_group_chat", + }, + workspaceDir: "/repo/openclaw", + mode: "plan", + handle: { + result: new Promise(() => {}), + queueMessage: vi.fn(async () => false), + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload, + interrupt: vi.fn(async () => undefined), + isAwaitingInput: vi.fn(() => true), + getThreadId: vi.fn(() => "thread-1"), + }, + }); + await (controller as any).store.upsertPendingRequest({ + requestId: "req-questionnaire-click", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + state: { + requestId: "req-questionnaire-click", + options: [], + expiresAt: Date.now() + 60_000, + questionnaire: { + currentIndex: 1, + awaitingFreeform: false, + questions: [ + { + index: 0, + id: "q1", + prompt: "Question 1", + options: [{ key: "A", label: "Alpha" }], + guidance: [], + }, + { + index: 1, + id: "q2", + prompt: "Question 2", + options: [{ key: "A", label: "Beta" }], + guidance: [], + }, + { + index: 2, + id: "q3", + prompt: "Question 3", + options: [{ key: "A", label: "Gamma" }], + guidance: [], + allowFreeform: true, + }, + ], + answers: [ + { kind: "option", optionKey: "A", optionLabel: "Alpha" }, + { kind: "option", optionKey: "A", optionLabel: "Beta" }, + null, + ], }, - }), - ); + }, + updatedAt: Date.now(), + }); - await controller.handleCommand( - "cas_detach", - buildDiscordCommandContext({ - commandBody: "/cas_detach", - getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "binding-1" })), - }), + const questionState = ((controller as any).store.getPendingRequestById("req-questionnaire-click") as any).state; + const initialButtons = await (controller as any).buildPendingQuestionnaireButtons( + { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + questionState, ); + const prevToken = initialButtons.flat().find((button: { text: string }) => button.text === "Prev")?.callback_data.split(":").pop(); + const nextToken = initialButtons.flat().find((button: { text: string }) => button.text === "Next")?.callback_data.split(":").pop(); + expect(prevToken).toBeTruthy(); + expect(nextToken).toBeTruthy(); - expect(fetchMock).toHaveBeenCalledWith( - "https://discord.com/api/v10/channels/channel%3Achan-1/pins/discord-component-1", - expect.objectContaining({ - method: "DELETE", - headers: expect.objectContaining({ - Authorization: "Bot discord-token", - }), - }), - ); - }); + await controller.handleInboundClaim({ + content: `/cas_click ${prevToken}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + expect((controller as any).store.getPendingRequestById("req-questionnaire-click")?.state.questionnaire.currentIndex).toBe(0); - it("resolves the Discord bot token through the host api when the sdk facade is unavailable", async () => { - const { controller } = await createControllerHarness(); - (controller as any).lastRuntimeConfig = { plugins: { discord: {} } }; - vi.spyOn(controller as any, "loadDiscordSdk").mockRejectedValue( - new Error( - "Cannot find module '/Users/huntharo/github/openclaw/dist/plugin-sdk/root-alias.cjs/discord'", - ), - ); - const resolveDiscordAccount = vi.fn(() => ({ token: "discord-token" })); - vi.spyOn(controller as any, "loadDiscordExtensionApi").mockResolvedValue({ - resolveDiscordAccount, + const pending = (controller as any).store.getPendingRequestById("req-questionnaire-click"); + pending.state.questionnaire.currentIndex = 1; + pending.state.questionnaire.awaitingFreeform = false; + await (controller as any).store.upsertPendingRequest(pending); + + await controller.handleInboundClaim({ + content: `/cas_click ${nextToken}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, }); + expect((controller as any).store.getPendingRequestById("req-questionnaire-click")?.state.questionnaire.currentIndex).toBe(2); - const token = await (controller as any).resolveDiscordBotToken("default"); + const finalState = ((controller as any).store.getPendingRequestById("req-questionnaire-click") as any).state; + const finalButtons = await (controller as any).buildPendingQuestionnaireButtons( + { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + finalState, + ); + const freeformToken = finalButtons.flat().find((button: { text: string }) => button.text === "Use Free Form")?.callback_data.split(":").pop(); + const selectToken = finalButtons.flat().find((button: { text: string }) => button.text.startsWith("A."))?.callback_data.split(":").pop(); + expect(freeformToken).toBeTruthy(); + expect(selectToken).toBeTruthy(); - expect(token).toBe("discord-token"); - expect(resolveDiscordAccount).toHaveBeenCalledWith({ - cfg: { plugins: { discord: {} } }, + await controller.handleInboundClaim({ + content: `/cas_click ${freeformToken}`, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + expect((controller as any).store.getPendingRequestById("req-questionnaire-click")?.state.questionnaire.awaitingFreeform).toBe(true); + + await controller.handleInboundClaim({ + content: `/cas_click ${selectToken}`, + channel: "feishu", accountId: "default", + conversationId: "oc_group_chat", + senderId: "ou_user_1", + metadata: { + originatingTo: "chat:oc_group_chat", + }, + }); + expect(submitPendingInputPayload).toHaveBeenCalledWith({ + answers: { + q1: { answers: ["Alpha"] }, + q2: { answers: ["Beta"] }, + q3: { answers: ["Gamma"] }, + }, }); }); @@ -2917,6 +4378,83 @@ describe("Discord controller flows", () => { ); }); + it("replays pending Feishu cas_resume effects with last request, last reply, and status card", async () => { + const { controller, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + (controller as any).client.readThreadContext = vi.fn(async () => ({ + lastUserMessage: "Who are you?", + lastAssistantMessage: "I am Codex.", + })); + const requestConversationBinding = vi + .fn() + .mockResolvedValueOnce({ + status: "pending" as const, + reply: { text: "Plugin bind approval required" }, + }); + + const pendingReply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + args: "thread-1", + commandBody: "/cas_resume thread-1", + requestConversationBinding, + }), + ); + + expect(pendingReply).toEqual({ text: "Plugin bind approval required" }); + + const hydratedReply = await controller.handleCommand( + "cas_resume", + buildFeishuCommandContext({ + commandBody: "/cas_resume", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + await flushAsyncWork(); + + expect(hydratedReply).toEqual({}); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + "Last User Request in Thread:", + expect.objectContaining({ accountId: "default" }), + ); + expect(sendMessageFeishu).toHaveBeenCalledWith( + "oc_group_chat", + "Last Agent Reply in Thread:", + expect.objectContaining({ accountId: "default" }), + ); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.at(-1)?.[0]; + const serializedCard = JSON.stringify(payload?.card); + expect(serializedCard).toContain("Binding:"); + expect(serializedCard).toContain("Select Model"); + expect(serializedCard).toContain("Permissions: toggle"); + }); + + it("renders Feishu cas_skills as an interactive card with primary buttons", async () => { + const { controller, sendCardFeishu, sendMessageFeishu } = await createControllerHarness(); + const result = await controller.handleCommand( + "cas_skills", + buildFeishuCommandContext({ + commandBody: "/cas_skills", + }), + ); + + expect(result).toEqual({}); + expect(sendMessageFeishu).not.toHaveBeenCalled(); + expect(sendCardFeishu).toHaveBeenCalledTimes(1); + const payload = ((sendCardFeishu.mock.calls as unknown) as Array<[Record]>)?.[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ + accountId: "default", + })); + const actions = collectFeishuActionValues(payload?.card); + expect(actions.length).toBeGreaterThan(0); + const buttons = collectFeishuButtons(payload?.card); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((button) => button.type === "primary")).toBe(true); + expect(JSON.stringify(payload?.card)).toContain("Codex skills."); + }); + it("retries an incomplete cas_resume bind before falling back to the picker", async () => { const { controller } = await createControllerHarness(); await (controller as any).store.upsertPendingBind({ @@ -3602,6 +5140,7 @@ describe("Discord controller flows", () => { sessionKey: undefined, workspaceDir: "/repo/openclaw", model: undefined, + strictNew: true, }); expect(requestConversationBinding).toHaveBeenCalledWith( expect.objectContaining({ @@ -3665,6 +5204,7 @@ describe("Discord controller flows", () => { sessionKey: undefined, workspaceDir: "/repo/openclaw", model: "gpt-5.3-codex-spark", + strictNew: true, }); const binding = (controller as any).store.getBinding({ channel: "telegram", @@ -5132,6 +6672,67 @@ describe("Discord controller flows", () => { ); }); + it("streams Feishu assistant deltas and only sends the unsent tail on completion", async () => { + vi.useFakeTimers(); + try { + const { controller, sendMessageFeishu } = await createControllerHarness(); + const prefix = "A streamed prefix that is long enough to flush."; + const fullText = `${prefix} tail`; + let resolveResult: ((value: unknown) => void) | undefined; + let capturedParams: Record | undefined; + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + (controller as any).client.startTurn = vi.fn((params: Record) => { + capturedParams = params; + return { + result, + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => false), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + }; + }); + + await (controller as any).startTurn({ + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }, + binding: null, + workspaceDir: "/repo/openclaw", + prompt: "stream it", + reason: "inbound", + }); + + expect(typeof capturedParams?.onAssistantDelta).toBe("function"); + await (capturedParams?.onAssistantDelta as (text: string) => Promise)(prefix); + await vi.advanceTimersByTimeAsync(1_500); + vi.useRealTimers(); + + resolveResult?.({ + threadId: "thread-1", + text: fullText, + }); + await flushAsyncWork(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const sentTexts = ((sendMessageFeishu.mock.calls as unknown) as Array<[string, string, unknown?]>) + .map(([, value]) => value); + expect(sentTexts).toEqual([ + prefix, + "tail", + "Codex completed.", + ]); + expect(sentTexts).not.toContain(fullText); + } finally { + vi.useRealTimers(); + } + }); + it("passes saved conversation preferences into review runs", async () => { const { controller } = await createControllerHarness(); const startReview = vi.fn(() => ({ diff --git a/src/controller.ts b/src/controller.ts index 4ba6fb1..fcf4713 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2,7 +2,8 @@ import { execFile } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import type { PluginConversationBindingResolvedEvent, @@ -50,6 +51,7 @@ import { REASONING_EFFORT_OPTIONS, } from "./model-capabilities.js"; import { formatCommandUsage, renderCommandHelpText } from "./help.js"; +import { COMMANDS } from "./commands.js"; import type { AccountSummary, CollaborationMode, @@ -57,6 +59,7 @@ import type { ConversationPreferences, InteractiveMessageRef, PermissionsMode, + ThreadSummary, ThreadState, TurnTerminalError, } from "./types.js"; @@ -75,6 +78,7 @@ import { } from "./state.js"; import { parseThreadSelectionArgs, + type ParsedThreadSelectionArgs, expandHomeDir, selectThreadFromMatches, } from "./thread-selection.js"; @@ -82,6 +86,7 @@ import { filterThreadsByProjectName, getProjectName, listProjects, + type ProjectSummary, paginateItems, } from "./thread-picker.js"; import { @@ -246,6 +251,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 INTERNAL_FEISHU_INBOUND_COMMAND_SET = new Set(["cas_click"]); const PLUGIN_VERSION = (() => { try { const packageJson = require("../package.json") as { version?: unknown }; @@ -278,6 +285,7 @@ function dedupeSkillsByName(skills: import("./types.js").SkillSummary[]): import type PickerRender = { text: string; buttons: PluginInteractiveButtons | undefined; + feishuText?: string; }; type StatusCardRender = { @@ -312,6 +320,9 @@ type PickerResponders = { }; const DELAYED_QUESTIONNAIRE_NOTE_THRESHOLD_MS = 15 * 60_000; +const FEISHU_CARD_CALLBACK_ACTION_ID = "openclaw.codex.callback"; +const FEISHU_ASSISTANT_DELTA_THROTTLE_MS = 1_200; +const FEISHU_ASSISTANT_DELTA_MIN_CHARS = 24; function formatElapsedDuration(elapsedMs: number): string { const totalMinutes = Math.max(1, Math.round(elapsedMs / 60_000)); @@ -369,6 +380,15 @@ 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."; +} + const IMAGE_FILE_EXTENSIONS = new Set([ ".png", ".jpg", @@ -387,6 +407,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; @@ -538,6 +562,109 @@ function resolveDiscordCommandConversation( }; } +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 { + const normalized = normalizeFeishuTargetId(raw); + if (!normalized) { + return undefined; + } + return normalized; +} + +function isLikelyFeishuChatId(raw: string | undefined): boolean { + const normalized = normalizeFeishuTargetId(raw); + if (!normalized) { + return false; + } + return normalized.startsWith("oc_"); +} + +function isLikelyFeishuUserId(raw: string | undefined): boolean { + const normalized = normalizeFeishuTargetId(raw); + if (!normalized) { + return false; + } + return 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") { + const trimmed = `${raw}`.trim(); + return trimmed ? 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; +} + function toConversationTargetFromCommand(ctx: PluginCommandContext): ConversationTarget | null { if (isTelegramChannel(ctx.channel)) { const chatId = normalizeTelegramChatId(ctx.to ?? ctx.from ?? ctx.senderId); @@ -556,6 +683,60 @@ function toConversationTargetFromCommand(ctx: PluginCommandContext): Conversatio if (isDiscordChannel(ctx.channel)) { return resolveDiscordCommandConversation(ctx); } + if (isFeishuChannel(ctx.channel)) { + const originatingTo = (() => { + const commandCtx = ctx as PluginCommandContext & { + originatingTo?: string; + OriginatingTo?: string; + }; + const lowerCamel = typeof commandCtx.originatingTo === "string" ? commandCtx.originatingTo.trim() : ""; + if (lowerCamel) { + return lowerCamel; + } + const legacy = typeof commandCtx.OriginatingTo === "string" ? commandCtx.OriginatingTo.trim() : ""; + return legacy || 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 +749,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 +781,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: @@ -889,6 +1100,32 @@ function extractReplyButtons(reply: ReplyPayload): PluginInteractiveButtons | un return rows.length > 0 ? rows : undefined; } +function formatPendingInputText(state: PendingInputState): string { + const lines = [state.promptText ?? "Codex needs input."]; + for (let index = 0; index < (state.actions ?? []).length; index += 1) { + const action = state.actions?.[index]; + if (!action) { + continue; + } + lines.push(`${index + 1}. ${action.label}`); + } + lines.push("Reply with /cas_reply to choose an action."); + return lines.join("\n"); +} + +function formatPendingQuestionnaireText(state: PendingInputState): string { + const questionnaire = state.questionnaire; + if (!questionnaire) { + return "That Codex questionnaire is no longer available. Please retry."; + } + return [ + formatPendingQuestionnairePrompt(questionnaire), + "Reply with /cas_q to select an option.", + "Reply with /cas_q prev or /cas_q next to navigate.", + "Reply with /cas_q freeform to answer the current question in free text.", + ].join("\n\n"); +} + function buildTelegramReplyMarkup(buttons?: PluginInteractiveButtons): { inline_keyboard: Array> } | undefined { if (!buttons || buttons.length === 0) { return undefined; @@ -902,6 +1139,59 @@ function buildTelegramReplyMarkup(buttons?: PluginInteractiveButtons): { inline_ ), }; } + +function parseInboundCodexCommand(content: string): { commandName: string; args: string } | null { + const normalized = normalizeOptionDashes(content).trim(); + if (!normalized.startsWith("/")) { + return null; + } + const match = normalized.match(/^\/([A-Za-z0-9_]+)(?:\s+([\s\S]*))?$/); + if (!match) { + return null; + } + const commandName = match[1]?.trim().toLowerCase(); + if (!commandName || (!COMMAND_NAME_SET.has(commandName) && !INTERNAL_FEISHU_INBOUND_COMMAND_SET.has(commandName))) { + return null; + } + return { + commandName, + args: match[2]?.trim() ?? "", + }; +} + +function extractCallbackTokenFromData(callbackData: string): string | undefined { + const trimmed = callbackData.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.startsWith(`${INTERACTIVE_NAMESPACE}:`)) { + const token = trimmed.slice(`${INTERACTIVE_NAMESPACE}:`.length).trim(); + return token || undefined; + } + return undefined; +} + +function getFeishuChatContextId(conversation: ConversationRef | ConversationTarget): string | undefined { + const rawConversationId = + conversation.parentConversationId ?? + conversation.conversationId.split(":topic:", 1)[0] ?? + conversation.conversationId; + return normalizeFeishuConversationId(rawConversationId); +} + +function isSameFeishuChatConversation( + left: ConversationRef | ConversationTarget, + right: ConversationRef | ConversationTarget, +): boolean { + const leftChatId = getFeishuChatContextId(left); + const rightChatId = getFeishuChatContextId(right); + return Boolean( + left.accountId?.trim() === right.accountId?.trim() && + leftChatId && + rightChatId && + leftChatId === rightChatId, + ); +} function parseFastAction( argsText: string, ): "toggle" | "on" | "off" | "status" | { error: string } { @@ -992,6 +1282,8 @@ function applyBindingPreferencesToThreadState( }; const nextState: ThreadState = { ...baseState, + threadName: baseState.threadName?.trim() || binding?.threadTitle?.trim(), + cwd: baseState.cwd?.trim() || binding?.workspaceDir?.trim(), model: preferredModel || baseState.model, serviceTier: preferredServiceTier ?? baseState.serviceTier, approvalPolicy: permissions.approvalPolicy || baseState.approvalPolicy, @@ -1088,6 +1380,36 @@ function formatFailureText(kind: "plan" | "review" | "compact", error: unknown): return `Codex ${kind} failed: ${message}`; } +function formatStructuredError(error: unknown): string { + if (!error || typeof error !== "object") { + return String(error); + } + const record = error as { + message?: unknown; + response?: { + status?: unknown; + data?: unknown; + }; + }; + const message = typeof record.message === "string" ? record.message : String(error); + const response = record.response; + if (!response || typeof response !== "object") { + return message; + } + const details: string[] = []; + if (typeof response.status === "number" || typeof response.status === "string") { + details.push(`status=${String(response.status)}`); + } + if (response.data !== undefined) { + try { + details.push(`data=${JSON.stringify(response.data)}`); + } catch { + details.push(`data=${String(response.data)}`); + } + } + return details.length ? `${message} (${details.join(" ")})` : message; +} + function formatInterruptedText(kind: "plan" | "review"): string { return `Codex ${kind} was interrupted before it finished.`; } @@ -1259,6 +1581,7 @@ function formatThreadSelectionFlags(parsed: ReturnType 0 ? `--page ${parsed.page + 1}` : "", parsed.syncTopic ? "--sync" : "", typeof parsed.requestedFast === "boolean" ? (parsed.requestedFast ? "--fast" : "--no-fast") : "", typeof parsed.requestedYolo === "boolean" ? (parsed.requestedYolo ? "--yolo" : "--no-yolo") : "", @@ -1269,6 +1592,189 @@ function formatThreadSelectionFlags(parsed: ReturnType 0) { + flags.push("--page", String(params.page + 1)); + } + if (params.parsed.syncTopic) { + flags.push("--sync"); + } + if (typeof params.parsed.requestedFast === "boolean") { + flags.push(params.parsed.requestedFast ? "--fast" : "--no-fast"); + } + if (typeof params.parsed.requestedYolo === "boolean") { + flags.push(params.parsed.requestedYolo ? "--yolo" : "--no-yolo"); + } + if (params.parsed.requestedModel) { + flags.push("--model", params.parsed.requestedModel); + } + const query = params.query?.trim(); + if (query) { + flags.push(query); + } + return `/cas_resume${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`; +} + +function formatFeishuThreadPickerText(params: { + introText: string; + threads: ThreadSummary[]; + parsed: ParsedThreadSelectionArgs; + page: number; + totalPages: number; + query?: string; +}): string { + const normalizedIntro = params.introText + .replace( + /^Tap a thread to resume it\..*$/m, + "Use card buttons below when available. If buttons are unavailable, run `/cas_resume `.", + ) + .replace( + /^Tap a project to show only that project's threads\..*$/m, + "Use card buttons below when available. If buttons are unavailable, run `/cas_resume --projects`.", + ); + const lines = [normalizedIntro]; + if (params.threads.length > 0) { + lines.push("", "Threads on this page:"); + params.threads.forEach((thread, index) => { + lines.push( + `[${index + 1}] ${getThreadDisplayTitle(thread)} | id: ${thread.threadId} | project: ${thread.projectKey?.trim() || "unknown"}`, + ); + }); + } else { + lines.push("", "No threads on this page."); + } + if (params.totalPages > 1) { + lines.push(""); + if (params.page > 0) { + lines.push( + `Prev page: ${buildCasResumeCommandFromParsed({ + parsed: params.parsed, + mode: "threads", + page: params.page - 1, + query: params.query, + })}`, + ); + } + if (params.page + 1 < params.totalPages) { + lines.push( + `Next page: ${buildCasResumeCommandFromParsed({ + parsed: params.parsed, + mode: "threads", + page: params.page + 1, + query: params.query, + })}`, + ); + } + } + lines.push( + "", + "Bind exact thread: /cas_resume ", + "Browse projects: /cas_resume --projects", + "Start new thread: /cas_resume --new ", + ); + return lines.join("\n"); +} + +function formatFeishuProjectPickerText(params: { + introText: string; + projects: ProjectSummary[]; + parsed: ParsedThreadSelectionArgs; + page: number; + totalPages: number; + action: "resume-thread" | "start-new-thread"; +}): string { + const normalizedIntro = params.introText + .replace( + /^Tap a project to start a fresh Codex thread there\..*$/m, + "Use card buttons below when available. If buttons are unavailable, run `/cas_resume --new `.", + ) + .replace( + /^Tap a project to show only that project's threads\..*$/m, + "Use card buttons below when available. If buttons are unavailable, run `/cas_resume --projects`.", + ); + const lines = [normalizedIntro]; + if (params.projects.length > 0) { + lines.push("", "Projects on this page:"); + params.projects.forEach((project, index) => { + lines.push(`[${index + 1}] ${project.name} (${project.threadCount} threads)`); + }); + } else { + lines.push("", "No projects on this page."); + } + if (params.totalPages > 1) { + lines.push(""); + const mode = params.action === "start-new-thread" ? "new-projects" : "projects"; + if (params.page > 0) { + lines.push( + `Prev page: ${buildCasResumeCommandFromParsed({ + parsed: params.parsed, + mode, + page: params.page - 1, + query: params.parsed.query, + })}`, + ); + } + if (params.page + 1 < params.totalPages) { + lines.push( + `Next page: ${buildCasResumeCommandFromParsed({ + parsed: params.parsed, + mode, + page: params.page + 1, + query: params.parsed.query, + })}`, + ); + } + } + lines.push( + "", + params.action === "start-new-thread" + ? "Start in one project: /cas_resume --new " + : "Browse a project: /cas_resume ", + ); + return lines.join("\n"); +} + +function compactFeishuCardText(text: string): string { + const lines = text + .split("\n") + .map((line) => line.trimEnd()); + const sectionStartPatterns = [ + /^Threads on this page:/, + /^Projects on this page:/, + /^Workspaces on this page:/, + /^Bind exact thread:/, + /^Bind exact project:/, + /^Browse projects:/, + /^Start new thread:/, + ]; + const firstSectionIndex = lines.findIndex((line) => + sectionStartPatterns.some((pattern) => pattern.test(line)), + ); + const head = firstSectionIndex >= 0 ? lines.slice(0, firstSectionIndex) : lines; + const compact = head.filter((line, index, arr) => !(line.length === 0 && arr[index - 1]?.length === 0)); + if (compact.length === 0) { + return "Use the buttons below to continue."; + } + compact.push("", "Use the buttons below to continue."); + return compact.join("\n").trim(); +} + function buildThreadOnlyName(params: { title?: string; projectKey?: string; threadId: string }): string | undefined { const threadName = params.title?.trim() || params.threadId.trim(); const projectName = path.basename(params.projectKey?.replace(/[\\/]+$/, "").trim() || ""); @@ -1347,6 +1853,27 @@ function listWorkspaceChoices( }); } +function filterThreadsBySelectionQuery< + T extends { threadId: string; title?: string; projectKey?: string }, +>(threads: T[], query?: string): T[] { + const normalizedQuery = query?.trim().toLowerCase(); + if (!normalizedQuery) { + return [...threads]; + } + return threads.filter((thread) => { + const threadId = thread.threadId?.trim().toLowerCase() || ""; + const title = thread.title?.trim().toLowerCase() || ""; + const projectPath = thread.projectKey?.trim().toLowerCase() || ""; + const projectName = getProjectName(thread.projectKey)?.trim().toLowerCase() || ""; + return ( + threadId.includes(normalizedQuery) || + title.includes(normalizedQuery) || + projectPath.includes(normalizedQuery) || + projectName.includes(normalizedQuery) + ); + }); +} + function summarizeTextForLog(text: string, maxChars = 120): string { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) { @@ -1364,6 +1891,17 @@ export class CodexPluginController { private readonly activeRuns = new Map(); private readonly threadChangesCache = new Map>(); private readonly store; + private feishuDirectCardSenderPromise: Promise< + | ((params: { + cfg: unknown; + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + replyToMessageId?: string; + }) => Promise) + | null + > | null = null; private serviceWorkspaceDir?: string; private lastRuntimeConfig?: unknown; private started = false; @@ -1417,16 +1955,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) { @@ -1472,6 +2001,154 @@ export class CodexPluginController { ].join(" "); } + private getFeishuContextCandidates( + event: Record | undefined, + ctx: Record | undefined, + ): string[] { + const metadata = + event?.metadata && typeof event.metadata === "object" + ? (event.metadata as Record) + : undefined; + const candidates = [ + typeof ctx?.conversationId === "string" ? ctx.conversationId : undefined, + typeof ctx?.to === "string" ? ctx.to : undefined, + typeof ctx?.from === "string" ? ctx.from : 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?.originatingTo === "string" ? event.originatingTo : undefined, + typeof event?.chatId === "string" ? event.chatId : undefined, + typeof metadata?.originatingTo === "string" ? metadata.originatingTo : undefined, + typeof metadata?.conversationId === "string" ? metadata.conversationId : undefined, + typeof metadata?.parentConversationId === "string" ? metadata.parentConversationId : undefined, + typeof metadata?.chatId === "string" ? metadata.chatId : undefined, + typeof metadata?.to === "string" ? metadata.to : undefined, + typeof metadata?.from === "string" ? metadata.from : undefined, + ]; + return candidates + .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) { + const mappedDmConversation = this.store.getFeishuDmConversation({ + accountId, + userId: directUser, + }); + if (mappedDmConversation) { + this.api.logger.warn( + `codex feishu before_dispatch recovered dm conversation account=${accountId} user=${directUser} conversation=${mappedDmConversation}`, + ); + return mappedDmConversation; + } + this.api.logger.warn( + `codex feishu before_dispatch skipped: non-chat conversationId=${directUser} account=${accountId}`, + ); + } + return undefined; + } + + 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) { + this.api.logger.warn( + `codex feishu before_dispatch skipped: missing conversationId account=${accountId} ctxConversation=${typeof ctx?.conversationId === "string" ? ctx.conversationId : ""}`, + ); + return { handled: false }; + } + + this.api.logger.warn( + `codex feishu before_dispatch attempt account=${accountId} conversation=${conversationId} sender=${typeof ctx?.senderId === "string" ? ctx.senderId : ""} prompt="${summarizeTextForLog(content)}"`, + ); + 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), + }); + const claimResult = await this.handleInboundClaim({ + content, + channel: "feishu", + accountId, + conversationId, + parentConversationId: conversationId, + isGroup: Boolean(event.isGroup), + metadata: + event.metadata && typeof event.metadata === "object" + ? (event.metadata as Record) + : undefined, + }); + this.api.logger.warn( + `codex feishu before_dispatch result handled=${claimResult.handled} account=${accountId} conversation=${conversationId}`, + ); + return claimResult; + } + async handleInboundClaim(event: { content: string; channel: string; @@ -1479,6 +2156,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 +2172,69 @@ 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; + this.api.logger.warn( + `codex feishu inbound command fallback name=${fallbackCommand.commandName} conversation=${conversation.conversationId} sourceConversation=${event.conversationId ?? ""}`, + ); + 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)) { + this.api.logger.warn( + `codex feishu inbound resolved account=${conversation.accountId} conversation=${conversation.conversationId} parent=${conversation.parentConversationId ?? ""} sourceConversation=${event.conversationId ?? ""} sourceParent=${event.parentConversationId ?? ""} thread=${event.threadId == null ? "" : String(event.threadId)}`, + ); + 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 + : undefined, + isGroup: event.isGroup, + }); + } const input = await buildInboundTurnInput(event); const requiresStructuredInput = !isQueueCompatibleTurnInput(event.content, input); const activeKey = buildConversationKey(conversation); @@ -1505,8 +2248,8 @@ export class CodexPluginController { await active.handle.interrupt().catch(() => undefined); } else { const pending = this.store.getPendingRequestByConversation(conversation); - if (pending?.state.questionnaire && !event.content.trim().startsWith("/")) { - const handled = await this.handlePendingQuestionnaireFreeformAnswer( + if (pending && !event.content.trim().startsWith("/")) { + const handled = await this.handlePendingTextReply( conversation, pending, active.handle, @@ -1542,6 +2285,16 @@ export class CodexPluginController { const existingBinding = this.store.getBinding(conversation); const hydratedBinding = existingBinding ? null : await this.hydrateApprovedBinding(conversation); const resolvedBinding = existingBinding ?? hydratedBinding?.binding ?? null; + const stalePending = this.store.getPendingRequestByConversation(conversation); + if ( + stalePending && + !event.content.trim().startsWith("/") && + this.matchesPendingTextReply(stalePending, event.content) + ) { + await this.store.removePendingRequest(stalePending.requestId); + await this.sendText(conversation, "No active Codex run is waiting for input."); + return { handled: true }; + } this.api.logger.debug?.( `codex inbound claim channel=${conversation.channel} account=${conversation.accountId} conversation=${conversation.conversationId} parent=${conversation.parentConversationId ?? ""} local=${resolvedBinding ? "yes" : "no"}`, ); @@ -1870,8 +2623,32 @@ export class CodexPluginController { async handleCommand(commandName: string, ctx: PluginCommandContext): Promise { await this.start(); this.lastRuntimeConfig = ctx.config; + 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() + : ""; + this.api.logger.warn( + `codex feishu command debug name=${commandName} sender=${ctx.senderId ?? ""} channelId=${typeof ctx.channelId === "string" ? ctx.channelId : ""} from=${ctx.from ?? ""} to=${ctx.to ?? ""} originatingTo=${originatingTo} thread=${ctx.messageThreadId == null ? "" : String(ctx.messageThreadId)}`, + ); + } 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() @@ -1882,7 +2659,18 @@ export class CodexPluginController { conversation && currentBinding && !existingBinding ? await this.hydrateApprovedBinding(conversation) : null; - const binding = existingBinding ?? hydratedBinding?.binding ?? null; + const localBinding = existingBinding ?? hydratedBinding?.binding ?? null; + const hostBindingUnavailableWithLocalFallback = + conversation !== null && + Boolean(bindingApi.getCurrentConversationBinding) && + !currentBinding && + Boolean(localBinding); + if (hostBindingUnavailableWithLocalFallback && conversation && localBinding) { + this.api.logger.debug?.( + `codex binding fallback conversation=${conversation.conversationId} channel=${conversation.channel} thread=${localBinding.threadId} hostBinding=no localBinding=yes`, + ); + } + const binding = localBinding; const args = ctx.args?.trim() ?? ""; const normalizedArgs = normalizeOptionDashes(args).trim(); if (normalizedArgs === "help" || normalizedArgs === "--help") { @@ -1893,6 +2681,11 @@ export class CodexPluginController { `codex discord command /${commandName} from=${ctx.from ?? ""} to=${ctx.to ?? ""} conversation=${conversation?.conversationId ?? ""}`, ); } + if (isFeishuChannel(ctx.channel)) { + this.api.logger.warn( + `codex feishu command resolved name=${commandName} conversation=${conversation?.conversationId ?? ""} parent=${conversation?.parentConversationId ?? ""} localBinding=${existingBinding ? "yes" : "no"} hostBinding=${currentBinding ? "yes" : "no"}`, + ); + } switch (commandName) { case "cas_resume": @@ -1907,14 +2700,21 @@ export class CodexPluginController { ); case "cas_detach": if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } + const hadLocalBinding = Boolean(binding); const detachResult = await bindingApi.detachConversationBinding?.(); await this.unbindConversation(conversation); + const removed = detachResult?.removed ?? hadLocalBinding; + const isFeishuConversation = isFeishuChannel(ctx.channel) || isFeishuChannel(conversation.channel); return { - text: detachResult?.removed - ? "Detached this conversation from Codex." - : "This conversation is not currently bound to Codex.", + text: removed + ? isFeishuConversation + ? "Detached this Feishu conversation from Codex. Future messages will fall back to the default codex-agent route." + : "Detached this conversation from Codex." + : isFeishuConversation + ? "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 +2749,12 @@ export class CodexPluginController { binding, Boolean(currentBinding || binding), ); + case "cas_click": + return await this.handleFeishuCardClickCommand(conversation, ctx); + case "cas_reply": + return await this.handlePendingInputReplyCommand(conversation, args); + case "cas_q": + return await this.handlePendingQuestionnaireCommand(conversation, args); case "cas_init": return await this.handlePromptAlias(conversation, binding, args, "/init"); case "cas_diff": @@ -1972,10 +2778,16 @@ export class CodexPluginController { requestConversationBinding?: PickerResponders["requestConversationBinding"], ): Promise { if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } if (parsed.listProjects || !parsed.query) { - const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread"); + const picker = await this.renderProjectPicker( + conversation, + binding, + parsed, + parsed.page ?? 0, + "start-new-thread", + ); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -1985,12 +2797,28 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + await this.sendReply(conversation, { + text: `${picker.feishuText ?? picker.text}\n\nReply with /cas_resume --new to start a thread there.`, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } const workspaceDir = await this.resolveNewThreadWorkspaceDir(binding, parsed); + this.api.logger.warn( + `codex cas_resume --new resolved workspaceDir=${workspaceDir ?? ""} query=${parsed.query || ""} syncTopic=${String(parsed.syncTopic)} channel=${channel}`, + ); if (!workspaceDir) { - const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread"); + const picker = await this.renderProjectPicker( + conversation, + binding, + parsed, + parsed.page ?? 0, + "start-new-thread", + ); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2002,6 +2830,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + await this.sendReply(conversation, { + text: `${picker.feishuText ?? picker.text}\n\nReply with /cas_resume --new to start a thread there.`, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } @@ -2017,12 +2852,19 @@ export class CodexPluginController { }, requestConversationBinding, ); + this.api.logger.warn( + `codex cas_resume --new result status=${result.status}${result.status === "bound" ? ` thread=${result.binding.threadId} cwd=${result.binding.workspaceDir}` : ""}`, + ); if (result.status === "pending") { return result.reply; } if (result.status === "error") { return { text: result.message }; } + if (isFeishuChannel(channel)) { + await this.sendBoundConversationNotifications(conversation); + return {}; + } return {}; } @@ -2034,11 +2876,11 @@ 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) - : await this.renderThreadPicker(conversation, binding, parsed, 0); + ? await this.renderProjectPicker(conversation, binding, parsed, parsed.page ?? 0) + : await this.renderThreadPicker(conversation, binding, parsed, parsed.page ?? 0); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2048,6 +2890,19 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + this.api.logger.warn( + `codex feishu picker dispatch marker conversation=${conversation.conversationId} buttons=${picker.buttons?.length ?? 0} hasFeishuText=${picker.feishuText ? "yes" : "no"}`, + ); + await this.sendReply(conversation, { + text: picker.feishuText ?? `${picker.text}\n\nReply with /cas_resume to bind an exact thread.`, + buttons: picker.buttons, + }); + this.api.logger.warn( + `codex feishu picker dispatch marker sent conversation=${conversation.conversationId}`, + ); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } @@ -2061,8 +2916,9 @@ export class CodexPluginController { hydratedPendingBind?: StoredPendingBind, ): Promise { const bindingApi = asScopedBindingApi(ctx); + const commandBindingRequest = bindingApi.requestConversationBinding; if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const parsed = parseThreadSelectionArgs(args); if (parsed.error) { @@ -2087,7 +2943,7 @@ export class CodexPluginController { binding, parsed, channel, - bindingApi.requestConversationBinding, + commandBindingRequest, ); } if ( @@ -2106,6 +2962,9 @@ export class CodexPluginController { } } await this.sendBoundConversationNotifications(conversation); + if (isFeishuChannel(channel)) { + return {}; + } return {}; } if (pendingBind && !binding && !parsed.listProjects && !parsed.query) { @@ -2130,7 +2989,7 @@ export class CodexPluginController { preferences, notifyBound: true, }, - bindingApi.requestConversationBinding, + commandBindingRequest, ); if (bindResult.status === "pending") { return bindResult.reply; @@ -2149,9 +3008,38 @@ export class CodexPluginController { } } await this.sendBoundConversationNotifications(conversation); + if (isFeishuChannel(channel)) { + return {}; + } return {}; } if (parsed.listProjects || !parsed.query) { + if (binding && isFeishuChannel(channel)) { + if (commandBindingRequest) { + const syncBindingResult = await this.requestConversationBinding( + conversation, + { + threadId: binding.threadId, + workspaceDir: binding.workspaceDir, + threadTitle: binding.threadTitle, + permissionsMode: this.getPermissionsMode(binding), + preferences: binding.preferences, + notifyBound: false, + }, + commandBindingRequest, + ); + if (syncBindingResult.status === "pending") { + return syncBindingResult.reply; + } + if (syncBindingResult.status === "error") { + return { text: syncBindingResult.message }; + } + } + 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); } @@ -2165,7 +3053,7 @@ export class CodexPluginController { return { text: `No Codex thread matched "${parsed.query}".` }; } if (selection.kind === "ambiguous") { - const picker = await this.renderThreadPicker(conversation, binding, parsed, 0); + const picker = await this.renderThreadPicker(conversation, binding, parsed, parsed.page ?? 0); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2177,6 +3065,13 @@ export class CodexPluginController { return { text: picker.text }; } } + if (isFeishuChannel(channel)) { + await this.sendReply(conversation, { + text: picker.feishuText ?? `${picker.text}\n\nReply with /cas_resume to bind an exact thread.`, + buttons: picker.buttons, + }); + return {}; + } return buildReplyWithButtons(picker.text, picker.buttons); } const targetPermissionsMode = this.resolveRequestedPermissionsMode( @@ -2203,7 +3098,7 @@ export class CodexPluginController { syncTopic: parsed.syncTopic, preferences, notifyBound: true, - }, bindingApi.requestConversationBinding); + }, commandBindingRequest); if (bindResult.status === "pending") { return bindResult.reply; } @@ -2221,6 +3116,9 @@ export class CodexPluginController { } } await this.sendBoundConversationNotifications(conversation); + if (isFeishuChannel(channel)) { + return {}; + } return {}; } @@ -2262,7 +3160,7 @@ export class CodexPluginController { note = buildPermissionsUnavailableNote(); const card = await this.buildStatusCard(conversation, binding, bindingActive); const text = `${card.text}\n\n${note}`; - if (!card.buttons || !conversation) { + if (!card.buttons || !conversation || isFeishuChannel(conversation.channel)) { return { text }; } return await this.sendStatusCardCommandReply(conversation, text, card.buttons); @@ -2303,7 +3201,9 @@ export class CodexPluginController { const card = await this.buildStatusCard(conversation, binding, bindingActive); const text = note ? `${card.text}\n\n${note}` : card.text; if (!card.buttons || !conversation) { - return { text }; + return { + text, + }; } return await this.sendStatusCardCommandReply(conversation, text, card.buttons); } @@ -2937,7 +3837,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) { @@ -2952,7 +3852,7 @@ export class CodexPluginController { args: string, ): Promise { if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const prompt = args.trim(); if (!prompt) { @@ -2972,7 +3872,7 @@ export class CodexPluginController { args: string, ): Promise { if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const parsed = parsePlanArgs(args); if (parsed.mode === "off") { @@ -3164,6 +4064,18 @@ export class CodexPluginController { return { text: picker.text }; } } + if (conversation && isFeishuChannel(conversation.channel) && picker.buttons) { + try { + await this.sendReplyWithDeliveryRef(conversation, { + text: picker.text, + buttons: picker.buttons, + }); + return {}; + } catch (error) { + this.api.logger.warn(`codex feishu skills send failed: ${String(error)}`); + return { text: picker.text }; + } + } return buildReplyWithButtons(picker.text, picker.buttons); } @@ -3246,15 +4158,23 @@ export class CodexPluginController { const profile = this.getPermissionsMode(binding); if (!binding) { const models = await this.client.listModels({ profile }); - return { text: formatModels(models) }; + return { + text: isFeishuChannel(conversation?.channel ?? "") + ? `Bind this Feishu conversation to a Codex thread before changing its model. Available models:\n\n${formatModels(models)}` + : formatModels(models), + }; } if (!trimmedArgs) { - if (!conversation) { + if (!conversation || isFeishuChannel(conversation.channel)) { const [models, { effectiveState }] = await Promise.all([ this.client.listModels({ profile, sessionKey: binding.sessionKey }), this.readEffectiveThreadState(binding), ]); - return { text: formatModels(models, effectiveState) }; + return { + text: isFeishuChannel(conversation?.channel ?? "") + ? `${formatModels(models, effectiveState)}\n\nReply with /cas_model to switch this bound thread.` + : formatModels(models, effectiveState), + }; } const picker = await this.buildModelPicker(conversation, binding); if (isDiscordChannel(conversation.channel) && picker.buttons) { @@ -3316,6 +4236,168 @@ 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 = (ctx.args?.trim() ?? "").split(/\s+/, 1)[0]?.trim(); + 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); + // Feishu card callbacks are bridged through synthetic `/cas_click` messages. + // In this path, host binding hooks can reject with "cannot bind this conversation"; + // fall back to plugin-local binding to make card buttons deterministic. + const requestConversationBinding = undefined; + 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, + detachConversationBinding: bindingApi.detachConversationBinding, + }); + return {}; + } + + private async handlePendingInputReplyCommand( + conversation: ConversationTarget | null, + args: string, + ): Promise { + if (!conversation) { + return buildSupportedConversationRequiredReply(); + } + const pending = this.store.getPendingRequestByConversation(conversation); + if (!pending || pending.state.questionnaire) { + return { text: "That Codex input option is invalid or expired. Reply with /cas_status and retry." }; + } + const active = this.activeRuns.get(buildConversationKey(conversation)); + if (!active) { + return { text: "No active Codex run is waiting for input." }; + } + const trimmed = args.trim(); + if (!trimmed) { + return { text: "Reply with /cas_reply to choose an action." }; + } + const numeric = Number.parseInt(trimmed, 10); + let actionIndex = Number.isInteger(numeric) && numeric > 0 ? numeric - 1 : -1; + if (actionIndex < 0) { + actionIndex = (pending.state.actions ?? []).findIndex((action) => { + return action.label.trim().toLowerCase() === trimmed.toLowerCase(); + }); + } + if (actionIndex < 0) { + return { text: "That Codex input option is invalid or expired. Reply with /cas_status and retry." }; + } + const submitted = await active.handle.submitPendingInput(actionIndex); + if (!submitted) { + return { text: "That Codex action is no longer available." }; + } + return { text: "Sent to Codex." }; + } + + private async handlePendingQuestionnaireCommand( + conversation: ConversationTarget | null, + args: string, + ): Promise { + if (!conversation) { + return buildSupportedConversationRequiredReply(); + } + const pending = this.store.getPendingRequestByConversation(conversation); + const questionnaire = pending?.state.questionnaire; + if (!pending || !questionnaire) { + return { text: "That Codex questionnaire is no longer available. Please retry." }; + } + const active = this.activeRuns.get(buildConversationKey(conversation)); + if (!active) { + return { text: "No active Codex run is waiting for input." }; + } + const raw = args.trim(); + if (!raw) { + return { text: formatPendingQuestionnaireText(pending.state) }; + } + const normalized = raw.toLowerCase(); + if (normalized === "freeform") { + questionnaire.awaitingFreeform = true; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + return { text: "Send your free-form answer in the next message and I’ll record it for the current question." }; + } + if (normalized === "prev") { + questionnaire.currentIndex = Math.max(0, questionnaire.currentIndex - 1); + questionnaire.awaitingFreeform = false; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + return { text: formatPendingQuestionnaireText(pending.state) }; + } + if (normalized === "next") { + if (!questionnaireCurrentQuestionHasAnswer(questionnaire)) { + return { text: "Answer this question first, or choose Free Form." }; + } + questionnaire.currentIndex = Math.min(questionnaire.questions.length - 1, questionnaire.currentIndex + 1); + questionnaire.awaitingFreeform = false; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + return { text: formatPendingQuestionnaireText(pending.state) }; + } + const question = questionnaire.questions[questionnaire.currentIndex]; + if (!question) { + return { text: "That Codex questionnaire is no longer available. Please retry." }; + } + const numeric = Number.parseInt(raw, 10); + const option = Number.isInteger(numeric) && numeric > 0 + ? question.options[numeric - 1] + : question.options.find((entry) => entry.key.toLowerCase() === normalized); + if (!option) { + return { text: "That Codex questionnaire is no longer available. Please retry." }; + } + questionnaire.answers[questionnaire.currentIndex] = { + kind: "option", + optionKey: option.key, + optionLabel: option.label, + }; + questionnaire.awaitingFreeform = false; + questionnaire.currentIndex = Math.min(questionnaire.questions.length - 1, questionnaire.currentIndex + 1); + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + if (questionnaireIsComplete(questionnaire)) { + const submitted = await active.handle.submitPendingInputPayload( + this.buildQuestionnaireSubmissionPayload(pending), + ); + if (!submitted) { + return { text: "That Codex questionnaire is no longer accepting answers." }; + } + await this.store.removePendingRequest(pending.requestId); + return { text: "Recorded your answers and sent them to Codex." }; + } + return { text: formatPendingQuestionnaireText(pending.state) }; + } + private async handlePromptAlias( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -3323,7 +4405,7 @@ export class CodexPluginController { alias: string, ): Promise { if (!conversation) { - return { text: "This command needs a Telegram or Discord conversation." }; + return buildSupportedConversationRequiredReply(); } const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, @@ -3490,6 +4572,71 @@ export class CodexPluginController { }): Promise { const key = buildConversationKey(params.conversation); const profile = this.getPermissionsMode(params.binding); + const enableFeishuAssistantStreaming = isFeishuChannel(params.conversation.channel); + let streamedAssistantText = ""; + let pendingAssistantText = ""; + let deltaFlushTimer: ReturnType | null = null; + let deltaFlushQueue = Promise.resolve(); + const flushAssistantDelta = async (force = false) => { + if (!enableFeishuAssistantStreaming) { + return; + } + const unsentLength = pendingAssistantText.length - streamedAssistantText.length; + if (unsentLength <= 0) { + return; + } + if (!force && unsentLength < FEISHU_ASSISTANT_DELTA_MIN_CHARS) { + return; + } + const nextChunk = pendingAssistantText.startsWith(streamedAssistantText) + ? pendingAssistantText.slice(streamedAssistantText.length) + : pendingAssistantText; + if (!nextChunk) { + return; + } + streamedAssistantText = pendingAssistantText; + await this.sendText(params.conversation, nextChunk); + }; + const scheduleAssistantDeltaFlush = () => { + if (!enableFeishuAssistantStreaming) { + return; + } + if (pendingAssistantText.length - streamedAssistantText.length < FEISHU_ASSISTANT_DELTA_MIN_CHARS) { + return; + } + if (deltaFlushTimer) { + return; + } + deltaFlushTimer = setTimeout(() => { + deltaFlushTimer = null; + deltaFlushQueue = deltaFlushQueue.then(() => flushAssistantDelta(true)); + }, FEISHU_ASSISTANT_DELTA_THROTTLE_MS); + }; + const flushRemainingAssistantDelta = async (finalText?: string) => { + if (!enableFeishuAssistantStreaming) { + return false; + } + if (deltaFlushTimer) { + clearTimeout(deltaFlushTimer); + deltaFlushTimer = null; + } + await deltaFlushQueue; + const completedText = typeof finalText === "string" && finalText ? finalText : pendingAssistantText; + if (!completedText) { + return streamedAssistantText.length > 0; + } + pendingAssistantText = completedText; + const tail = pendingAssistantText.startsWith(streamedAssistantText) + ? pendingAssistantText.slice(streamedAssistantText.length) + : pendingAssistantText === streamedAssistantText + ? "" + : pendingAssistantText; + if (tail) { + streamedAssistantText = pendingAssistantText; + await this.sendText(params.conversation, tail); + } + return streamedAssistantText.length > 0; + }; const existing = this.activeRuns.get(key); this.api.logger.debug?.( `codex turn request reason=${params.reason} ${this.formatConversationForLog(params.conversation)} workspace=${params.workspaceDir} existing=${existing ? existing.mode : "none"} profile=${profile} prompt="${summarizeTextForLog(params.prompt)}"`, @@ -3557,6 +4704,10 @@ export class CodexPluginController { ); await this.handlePendingInputState(params.conversation, params.workspaceDir, state, run); }, + onAssistantDelta: async (text) => { + pendingAssistantText = `${pendingAssistantText}${text}`; + scheduleAssistantDeltaFlush(); + }, onFileEdits: async (text) => { await this.sendText(params.conversation, text); }, @@ -3612,6 +4763,18 @@ export class CodexPluginController { this.api.logger.debug?.( `codex turn completed ${this.formatConversationForLog(params.conversation)} thread=${threadId ?? ""} aborted=${result.aborted ? "yes" : "no"} stoppedReason=${result.stoppedReason ?? "none"} terminalStatus=${result.terminalStatus ?? "none"} text=${result.text ? "yes" : "no"} plan=${result.planArtifact ? "yes" : "no"}`, ); + const streamedAssistantOutput = await flushRemainingAssistantDelta(result.text); + if ( + enableFeishuAssistantStreaming && + streamedAssistantOutput && + !result.planArtifact && + result.terminalStatus !== "failed" && + !result.aborted && + result.stoppedReason !== "approval" + ) { + await this.sendText(params.conversation, "Codex completed."); + return; + } const completionText = result.terminalStatus === "failed" ? await this.describeTurnFailure({ @@ -3629,6 +4792,10 @@ export class CodexPluginController { await this.sendText(params.conversation, completionText); }) .catch(async (error) => { + if (deltaFlushTimer) { + clearTimeout(deltaFlushTimer); + deltaFlushTimer = null; + } const message = error instanceof Error ? error.message : String(error); this.api.logger.warn( `codex turn failed ${this.formatConversationForLog(params.conversation)}: ${message}`, @@ -3643,6 +4810,10 @@ export class CodexPluginController { ); }) .finally(async () => { + if (deltaFlushTimer) { + clearTimeout(deltaFlushTimer); + deltaFlushTimer = null; + } typing?.stop(); this.activeRuns.delete(key); const pending = this.store.getPendingRequestByConversation(params.conversation); @@ -4138,6 +5309,10 @@ export class CodexPluginController { createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); + if (isFeishuChannel(conversation.channel)) { + await this.sendText(conversation, formatPendingInputText(state), { buttons }); + return; + } await this.sendText(conversation, state.promptText ?? "Codex needs input.", { buttons }); } @@ -4170,11 +5345,17 @@ export class CodexPluginController { return; } const buttons = await this.buildPendingQuestionnaireButtons(conversation, state); - const text = formatPendingQuestionnairePrompt(questionnaire); + const text = isFeishuChannel(conversation.channel) + ? formatPendingQuestionnaireText(state) + : formatPendingQuestionnairePrompt(questionnaire); if (opts?.editMessage) { await opts.editMessage(text, buttons); return; } + if (isFeishuChannel(conversation.channel)) { + await this.sendText(conversation, text, { buttons }); + return; + } await this.sendText(conversation, text, { buttons }); } @@ -4281,46 +5462,193 @@ export class CodexPluginController { })), ); } - return rows; + return rows; + } + + private async handlePendingQuestionnaireFreeformAnswer( + conversation: ConversationTarget, + pending: StoredPendingRequest, + run: ActiveCodexRun, + text: string, + ): Promise { + const questionnaire = pending.state.questionnaire; + const answerText = text.trim(); + if (!questionnaire || !answerText) { + return false; + } + questionnaire.answers[questionnaire.currentIndex] = { + kind: "text", + text: answerText, + }; + questionnaire.awaitingFreeform = false; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + if (questionnaireIsComplete(questionnaire)) { + const submitted = await run.submitPendingInputPayload( + this.buildQuestionnaireSubmissionPayload(pending), + ); + if (!submitted) { + return false; + } + await this.store.removePendingRequest(pending.requestId); + await this.sendText(conversation, "Recorded your answers and sent them to Codex."); + return true; + } + questionnaire.currentIndex = Math.min( + questionnaire.questions.length - 1, + questionnaire.currentIndex + 1, + ); + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + await this.sendPendingQuestionnaire(conversation, pending.state); + return true; + } + + private async handlePendingTextReply( + conversation: ConversationTarget, + pending: StoredPendingRequest, + run: ActiveCodexRun, + text: string, + ): Promise { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + if (pending.state.questionnaire) { + const questionnaire = pending.state.questionnaire; + if (questionnaire.awaitingFreeform) { + return await this.handlePendingQuestionnaireFreeformAnswer(conversation, pending, run, text); + } + const question = questionnaire.questions[questionnaire.currentIndex]; + if (!question) { + return false; + } + const normalized = trimmed.toLowerCase(); + if (normalized === "prev") { + questionnaire.currentIndex = Math.max(0, questionnaire.currentIndex - 1); + questionnaire.awaitingFreeform = false; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + await this.sendPendingQuestionnaire(conversation, pending.state); + return true; + } + if (normalized === "next") { + if (!questionnaireCurrentQuestionHasAnswer(questionnaire)) { + await this.sendText(conversation, "Answer this question first, or choose Free Form."); + return true; + } + questionnaire.currentIndex = Math.min(questionnaire.questions.length - 1, questionnaire.currentIndex + 1); + questionnaire.awaitingFreeform = false; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + await this.sendPendingQuestionnaire(conversation, pending.state); + return true; + } + if (normalized === "freeform") { + questionnaire.awaitingFreeform = true; + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + await this.sendText( + conversation, + "Send your free-form answer in the next message and I’ll record it for the current question.", + ); + return true; + } + const numeric = Number.parseInt(trimmed, 10); + const option = + Number.isInteger(numeric) && numeric > 0 + ? question.options[numeric - 1] + : question.options.find((entry) => { + const label = entry.label.trim().toLowerCase(); + return entry.key.toLowerCase() === normalized || label === normalized; + }); + if (!option) { + return false; + } + questionnaire.answers[questionnaire.currentIndex] = { + kind: "option", + optionKey: option.key, + optionLabel: option.label, + }; + questionnaire.awaitingFreeform = false; + questionnaire.currentIndex = Math.min(questionnaire.questions.length - 1, questionnaire.currentIndex + 1); + pending.updatedAt = Date.now(); + await this.store.upsertPendingRequest(pending); + if (questionnaireIsComplete(questionnaire)) { + const submitted = await run.submitPendingInputPayload( + this.buildQuestionnaireSubmissionPayload(pending), + ); + if (!submitted) { + await this.sendText(conversation, "That Codex questionnaire is no longer accepting answers."); + return true; + } + await this.store.removePendingRequest(pending.requestId); + await this.sendText(conversation, "Recorded your answers and sent them to Codex."); + return true; + } + await this.sendPendingQuestionnaire(conversation, pending.state); + return true; + } + + const numeric = Number.parseInt(trimmed, 10); + let actionIndex = Number.isInteger(numeric) && numeric > 0 ? numeric - 1 : -1; + if (actionIndex < 0) { + actionIndex = (pending.state.actions ?? []).findIndex((action) => { + return action.label.trim().toLowerCase() === trimmed.toLowerCase(); + }); + } + if (actionIndex < 0) { + return false; + } + const submitted = await run.submitPendingInput(actionIndex); + if (!submitted) { + await this.sendText(conversation, "That Codex action is no longer available."); + return true; + } + await this.sendText(conversation, "Sent to Codex."); + return true; } - private async handlePendingQuestionnaireFreeformAnswer( - conversation: ConversationTarget, + private matchesPendingTextReply( pending: StoredPendingRequest, - run: ActiveCodexRun, text: string, - ): Promise { - const questionnaire = pending.state.questionnaire; - const answerText = text.trim(); - if (!questionnaire || !answerText) { + ): boolean { + const trimmed = text.trim(); + if (!trimmed) { return false; } - questionnaire.answers[questionnaire.currentIndex] = { - kind: "text", - text: answerText, - }; - questionnaire.awaitingFreeform = false; - pending.updatedAt = Date.now(); - await this.store.upsertPendingRequest(pending); - if (questionnaireIsComplete(questionnaire)) { - const submitted = await run.submitPendingInputPayload( - this.buildQuestionnaireSubmissionPayload(pending), - ); - if (!submitted) { + const numeric = Number.parseInt(trimmed, 10); + const normalized = trimmed.toLowerCase(); + const normalizedWithoutPrefix = trimmed.replace(/^\d+[\s.)-]*/, "").trim().toLowerCase(); + if (pending.state.questionnaire) { + const questionnaire = pending.state.questionnaire; + if (normalized === "prev" || normalized === "next" || normalized === "freeform") { + return true; + } + const question = questionnaire.questions[questionnaire.currentIndex]; + if (!question) { return false; } - await this.store.removePendingRequest(pending.requestId); - await this.sendText(conversation, "Recorded your answers and sent them to Codex."); + if (Number.isInteger(numeric) && numeric > 0 && Boolean(question.options[numeric - 1])) { + return true; + } + return question.options.some((entry) => { + const label = entry.label.trim().toLowerCase(); + return ( + entry.key.toLowerCase() === normalized || + label === normalized || + (normalizedWithoutPrefix.length > 0 && label === normalizedWithoutPrefix) + ); + }); + } + const actions = pending.state.actions ?? []; + if (Number.isInteger(numeric) && numeric > 0 && numeric <= actions.length) { return true; } - questionnaire.currentIndex = Math.min( - questionnaire.questions.length - 1, - questionnaire.currentIndex + 1, - ); - pending.updatedAt = Date.now(); - await this.store.upsertPendingRequest(pending); - await this.sendPendingQuestionnaire(conversation, pending.state); - return true; + return actions.some((action) => { + const label = action.label.trim().toLowerCase(); + return label === normalized || (normalizedWithoutPrefix.length > 0 && label === normalizedWithoutPrefix); + }); } private resolveThreadWorkspaceDir( @@ -4406,9 +5734,12 @@ export class CodexPluginController { workspaceDir, filter: params.filterProjectsOnly ? undefined : params.parsed.query || undefined, }); + const queryFilteredThreads = params.filterProjectsOnly + ? threads + : filterThreadsBySelectionQuery(threads, params.parsed.query); return { workspaceDir, - threads: filterThreadsByProjectName(threads, params.projectName), + threads: filterThreadsByProjectName(queryFilteredThreads, params.projectName), }; } @@ -4643,8 +5974,8 @@ export class CodexPluginController { filter: parsed.query || undefined, }); if (globalResult.length > 0) { - threads = globalResult; - fallbackToGlobal = true; + threads = filterThreadsBySelectionQuery(globalResult, parsed.query); + fallbackToGlobal = threads.length > 0; } } const pageResult = paginateItems(threads, page); @@ -4658,16 +5989,26 @@ export class CodexPluginController { threads: pageResult.items, showProjectName: !projectName && (fallbackToGlobal || distinctProjects.size > 1), })) ?? []; + const pickerIntro = formatThreadPickerIntro({ + page: pageResult.page, + totalPages: pageResult.totalPages, + totalItems: pageResult.totalItems, + includeAll: workspaceDir == null || fallbackToGlobal, + query: parsed.query || undefined, + syncTopic: parsed.syncTopic, + workspaceDir: fallbackToGlobal ? undefined : workspaceDir, + projectName, + fallbackToGlobal, + }); return { - text: formatThreadPickerIntro({ + text: pickerIntro, + feishuText: formatFeishuThreadPickerText({ + introText: pickerIntro, + threads: pageResult.items, + parsed, page: pageResult.page, totalPages: pageResult.totalPages, - totalItems: pageResult.totalItems, - includeAll: workspaceDir == null || fallbackToGlobal, - syncTopic: parsed.syncTopic, - workspaceDir: fallbackToGlobal ? undefined : workspaceDir, - projectName, - fallbackToGlobal, + query: parsed.query || undefined, }), buttons: await this.appendThreadPickerControls({ conversation, @@ -4838,6 +6179,20 @@ export class CodexPluginController { workspaceDir, action, }), + feishuText: formatFeishuProjectPickerText({ + introText: formatProjectPickerIntro({ + page: projectOptions.page, + totalPages: projectOptions.totalPages, + totalItems: projectOptions.totalItems, + workspaceDir, + action, + }), + projects: projectOptions.items, + parsed, + page: projectOptions.page, + totalPages: projectOptions.totalPages, + action, + }), buttons, }; } @@ -5308,6 +6663,7 @@ export class CodexPluginController { } const active = this.activeRuns.get(buildConversationKey(callback.conversation)); if (!active) { + await this.store.removePendingRequest(pending.requestId); await responders.reply("No active Codex run is waiting for input."); return; } @@ -5331,6 +6687,7 @@ export class CodexPluginController { } const active = this.activeRuns.get(buildConversationKey(callback.conversation)); if (!active) { + await this.store.removePendingRequest(pending.requestId); await responders.reply("No active Codex run is waiting for input."); return; } @@ -6095,10 +7452,13 @@ export class CodexPluginController { overrides: CommandPreferenceOverrides, requestConversationBinding?: PickerResponders["requestConversationBinding"], ): Promise< - | { status: "bound" } + | { status: "bound"; binding: StoredBinding } | { status: "pending"; reply: ReplyPayload } | { status: "error"; message: string } > { + this.api.logger.warn( + `codex startNewThreadAndBindConversation begin workspaceDir=${workspaceDir} syncTopic=${String(syncTopic)} requestedModel=${overrides.requestedModel ?? ""} requestedFast=${String(overrides.requestedFast ?? false)} requestedYolo=${String(overrides.requestedYolo ?? false)} requestConversationBinding=${String(Boolean(requestConversationBinding))}`, + ); const profile = this.resolveRequestedPermissionsMode( this.getPermissionsMode(binding), overrides.requestedYolo, @@ -6108,7 +7468,11 @@ export class CodexPluginController { sessionKey: binding?.sessionKey, workspaceDir, model: overrides.requestedModel?.trim() || undefined, + strictNew: true, }); + this.api.logger.warn( + `codex startNewThreadAndBindConversation created thread=${created.threadId} cwd=${created.cwd ?? ""} name=${created.threadName ?? ""}`, + ); const preferences = this.buildBindingPreferencesWithOverrides( binding?.preferences, overrides, @@ -6127,6 +7491,9 @@ export class CodexPluginController { }, requestConversationBinding, ); + this.api.logger.warn( + `codex startNewThreadAndBindConversation bindResult status=${bindResult.status}${bindResult.status === "error" ? ` message=${bindResult.message}` : ""}`, + ); if (bindResult.status === "pending") { return bindResult; } @@ -6144,7 +7511,22 @@ export class CodexPluginController { } } await this.sendBoundConversationNotifications(conversation); - return { status: "bound" }; + this.api.logger.warn( + `codex startNewThreadAndBindConversation completed thread=${created.threadId} conversation=${this.formatConversationForLog(conversation)}`, + ); + return { + status: "bound", + binding: await this.store.getBinding(conversation) ?? { + conversation, + sessionKey: binding?.sessionKey ?? buildPluginSessionKey(created.threadId), + threadId: created.threadId, + workspaceDir: created.cwd?.trim() || workspaceDir, + permissionsMode: profile, + pendingPermissionsMode: undefined, + preferences, + updatedAt: Date.now(), + }, + }; } private async resolveSingleThread( @@ -6157,12 +7539,24 @@ export class CodexPluginController { | { kind: "ambiguous"; threads: Array<{ threadId: string; title?: string; projectKey?: string }> } > { const trimmed = filter.trim(); - const threads = await this.client.listThreads({ + let threads = await this.client.listThreads({ profile: "default", sessionKey, workspaceDir, filter: trimmed, }); + threads = filterThreadsBySelectionQuery(threads, trimmed); + if (threads.length === 0 && workspaceDir != null) { + const globalResult = await this.client.listThreads({ + profile: "default", + sessionKey, + workspaceDir: undefined, + filter: trimmed, + }); + if (globalResult.length > 0) { + threads = filterThreadsBySelectionQuery(globalResult, trimmed); + } + } return selectThreadFromMatches(threads, trimmed); } @@ -6320,6 +7714,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 +7733,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: { @@ -6623,7 +8029,6 @@ export class CodexPluginController { return formatCodexStatusText({ pluginVersion: PLUGIN_VERSION, threadState: displayThreadState, - bindingThreadTitle: binding?.threadTitle, account, rateLimits: limits, bindingActive, @@ -6714,29 +8119,17 @@ export class CodexPluginController { : []; let delivered: DeliveredMessageRef | null = null; if (hasMedia) { - const result = - chunks.length <= 1 && payload.buttons && outbound?.sendPayload - ? await outbound.sendPayload({ - cfg: this.getOpenClawConfig(), - to: conversation.parentConversationId ?? conversation.conversationId, - accountId: conversation.accountId, - threadId: conversation.threadId, - mediaLocalRoots, - payload: { - text: chunks[0] ?? text, - mediaUrl: payload.mediaUrl, - channelData: { - telegram: { - buttons: payload.buttons, - }, - }, - }, - }) - : await this.sendTelegramMediaChunk(outbound, conversation, chunks[0] ?? text, { - mediaUrl: payload.mediaUrl, - mediaLocalRoots, - buttons: chunks.length <= 1 ? payload.buttons : undefined, - }); + const result = await this.api.runtime.channel.telegram.sendMessageTelegram( + conversation.parentConversationId ?? conversation.conversationId, + chunks[0] ?? text, + { + accountId: conversation.accountId, + messageThreadId: getTelegramThreadId(conversation.threadId), + mediaUrl: payload.mediaUrl, + mediaLocalRoots, + buttons: chunks.length <= 1 ? payload.buttons : undefined, + }, + ); delivered = { provider: "telegram", messageId: result.messageId, @@ -6750,25 +8143,15 @@ export class CodexPluginController { if (!chunk) { continue; } - const result = - index === chunks.length - 1 && payload.buttons && outbound?.sendPayload - ? await outbound.sendPayload({ - cfg: this.getOpenClawConfig(), - to: conversation.parentConversationId ?? conversation.conversationId, - accountId: conversation.accountId, - threadId: conversation.threadId, - payload: { - text: chunk, - channelData: { - telegram: { - buttons: payload.buttons, - }, - }, - }, - }) - : await this.sendTelegramTextChunk(outbound, conversation, chunk, { - buttons: index === chunks.length - 1 ? payload.buttons : undefined, - }); + const result = await this.api.runtime.channel.telegram.sendMessageTelegram( + conversation.parentConversationId ?? conversation.conversationId, + chunk, + { + accountId: conversation.accountId, + messageThreadId: getTelegramThreadId(conversation.threadId), + buttons: index === chunks.length - 1 ? payload.buttons : undefined, + }, + ); if (index === chunks.length - 1 || !delivered) { delivered = { provider: "telegram", @@ -6791,25 +8174,15 @@ export class CodexPluginController { if (!chunk) { continue; } - const result = - index === textChunks.length - 1 && payload.buttons && outbound?.sendPayload - ? await outbound.sendPayload({ - cfg: this.getOpenClawConfig(), - to: conversation.parentConversationId ?? conversation.conversationId, - accountId: conversation.accountId, - threadId: conversation.threadId, - payload: { - text: chunk, - channelData: { - telegram: { - buttons: payload.buttons, - }, - }, - }, - }) - : await this.sendTelegramTextChunk(outbound, conversation, chunk, { - buttons: index === textChunks.length - 1 ? payload.buttons : undefined, - }); + const result = await this.api.runtime.channel.telegram.sendMessageTelegram( + conversation.parentConversationId ?? conversation.conversationId, + chunk, + { + accountId: conversation.accountId, + messageThreadId: getTelegramThreadId(conversation.threadId), + buttons: index === textChunks.length - 1 ? payload.buttons : undefined, + }, + ); if (!delivered || index === textChunks.length - 1) { delivered = { provider: "telegram", @@ -6949,6 +8322,46 @@ export class CodexPluginController { ); return delivered; } + if (isFeishuChannel(conversation.channel)) { + if (payload.buttons?.length) { + const cardSent = await this.sendFeishuCard(conversation, text, payload.buttons).catch((error) => { + this.api.logger.warn(`codex feishu card send failed: ${formatStructuredError(error)}`); + return false; + }); + if (cardSent) { + this.api.logger.debug?.( + `codex outbound send complete ${this.formatConversationForLog(conversation)} channel=feishu chunks=1 media=no buttons=${payload.buttons.length}`, + ); + 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); + } + this.api.logger.debug?.( + `codex outbound send complete ${this.formatConversationForLog(conversation)} channel=feishu chunks=${textChunks.length} media=no`, + ); + return null; + } return null; } @@ -7205,15 +8618,11 @@ export class CodexPluginController { refresh?: () => Promise; } | null> { if (isTelegramChannel(conversation.channel)) { - const legacyTyping = this.api.runtime.channel.telegram?.typing?.start; - if (typeof legacyTyping === "function") { - return await legacyTyping({ - to: conversation.parentConversationId ?? conversation.conversationId, - accountId: conversation.accountId, - messageThreadId: conversation.threadId, - }); - } - return await this.startTelegramTypingLease(conversation); + return await this.api.runtime.channel.telegram.typing.start({ + to: conversation.parentConversationId ?? conversation.conversationId, + accountId: conversation.accountId, + messageThreadId: getTelegramThreadId(conversation.threadId), + }); } if (isDiscordChannel(conversation.channel)) { if (conversation.conversationId.startsWith("user:")) { @@ -7260,6 +8669,9 @@ export class CodexPluginController { accountId: conversation.accountId, }); } + if (isFeishuChannel(conversation.channel)) { + return null; + } return null; } @@ -7294,7 +8706,14 @@ export class CodexPluginController { const textChunks = chunks.length > 0 ? chunks : [trimmed]; let firstDelivered: DeliveredMessageRef | null = null; for (const chunk of textChunks) { - const result = await this.sendTelegramTextChunk(outbound, conversation, chunk); + const result = await this.api.runtime.channel.telegram.sendMessageTelegram( + conversation.parentConversationId ?? conversation.conversationId, + chunk, + { + accountId: conversation.accountId, + messageThreadId: getTelegramThreadId(conversation.threadId), + }, + ); if (!firstDelivered) { firstDelivered = { provider: "telegram", @@ -7334,10 +8753,270 @@ 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; } + private async sendFeishuText( + conversation: ConversationTarget, + text: string, + ): Promise { + const to = conversation.parentConversationId ?? conversation.conversationId; + const channelRuntime = (this.api as unknown as { + config?: unknown; + runtime?: { channel?: Record }; + }).runtime?.channel as + | (Record & { + feishu?: { + sendMessageFeishu?: ( + to: string, + text: string, + opts?: { + accountId?: string; + replyInThread?: boolean; + }, + ) => Promise; + }; + outbound?: { + loadAdapter?: ( + channelId: string, + ) => Promise< + | { + sendText?: (ctx: Record) => Promise; + } + | undefined + >; + }; + }) + | undefined; + + if (typeof channelRuntime?.feishu?.sendMessageFeishu === "function") { + await channelRuntime.feishu.sendMessageFeishu(to, text, { + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + return; + } + + if (typeof channelRuntime?.outbound?.loadAdapter === "function") { + const outboundChannelId = conversation.channel === "lark" ? "feishu" : conversation.channel; + const adapter = await channelRuntime.outbound.loadAdapter(outboundChannelId); + if (typeof adapter?.sendText === "function") { + await adapter.sendText({ + to, + text, + accountId: conversation.accountId, + threadId: conversation.threadId ?? null, + cfg: (this.api as unknown as { config?: unknown }).config, + }); + 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> = []; + let droppedInvalidToken = 0; + let droppedMissingCallback = 0; + let createdActions = 0; + const cardText = compactFeishuCardText(text); + if (cardText.trim()) { + elements.push({ + tag: "markdown", + content: cardText, + }); + } + for (const row of buttons) { + const actions = row + .map((button) => { + const token = extractCallbackTokenFromData(button.callback_data); + if (!token) { + droppedInvalidToken += 1; + return null; + } + const callback = this.store.getCallback(token); + if (!callback) { + droppedMissingCallback += 1; + return null; + } + const value: Record = { + oc: "ocf1", + k: "quick", + a: FEISHU_CARD_CALLBACK_ACTION_ID, + q: `/cas_click ${token}`, + c: { + ...(chatId ? { h: chatId } : {}), + e: callback.expiresAt, + }, + }; + return { + tag: "button", + text: { + tag: "plain_text", + content: button.text, + }, + type: "primary", + value, + }; + }) + .filter(Boolean) as Array>; + if (actions.length > 0) { + createdActions += actions.length; + elements.push({ + tag: "action", + actions, + }); + } + } + this.api.logger.warn( + `codex feishu card build conversation=${conversation.conversationId} schema=1.0 layout=action rows=${buttons.length} createdActions=${createdActions} droppedInvalidToken=${droppedInvalidToken} droppedMissingCallback=${droppedMissingCallback}`, + ); + 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 channelRuntime = (this.api as unknown as { + runtime?: { channel?: Record }; + }).runtime?.channel as + | (Record & { + feishu?: { + sendCardFeishu?: (params: { + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + }) => Promise; + }; + }) + | undefined; + const sendCard = channelRuntime?.feishu?.sendCardFeishu; + const hasSendCard = typeof sendCard === "function"; + this.api.logger.warn( + `codex feishu sendCard capability conversation=${conversation.conversationId} available=${hasSendCard ? "yes" : "no"} rows=${buttons.length}`, + ); + if (hasSendCard) { + await sendCard({ + to, + card, + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + return true; + } + + const cfg = (this.api as unknown as { config?: unknown }).config ?? this.lastRuntimeConfig; + const directCardSender = await this.resolveFeishuDirectCardSender(); + if (directCardSender && cfg) { + await directCardSender({ + cfg, + to, + card, + accountId: conversation.accountId, + replyInThread: Boolean(conversation.threadId), + }); + this.api.logger.warn( + `codex feishu sendCard fallback used direct SDK path conversation=${conversation.conversationId}`, + ); + return true; + } + return false; + } + + 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 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 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") { + this.api.logger.warn( + `codex feishu direct card sender loaded from ${modulePath}`, + ); + return sendCardFeishu as (params: { + cfg: unknown; + to: string; + card: Record; + accountId?: string; + replyInThread?: boolean; + replyToMessageId?: string; + }) => Promise; + } + } catch (error) { + this.api.logger.warn( + `codex feishu direct card sender load failed from ${modulePath}: ${String(error)}`, + ); + } + } + this.api.logger.warn( + `codex feishu direct card sender unavailable: no loadable module candidate`, + ); + return null; + })(); + return await this.feishuDirectCardSenderPromise; + } + private async pinBindingMessage( conversation: ConversationTarget, delivered: DeliveredMessageRef | null, @@ -7601,12 +9280,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 +9302,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/format.test.ts b/src/format.test.ts index dd10d75..5fd60d0 100644 --- a/src/format.test.ts +++ b/src/format.test.ts @@ -1,630 +1,29 @@ -import os from "node:os"; -import { createRequire } from "node:module"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - buildCodexPlanMarkdownPreview, - formatAccountSummary, - formatBoundThreadSummary, - formatCodexPlanAttachmentFallback, - formatCodexPlanAttachmentSummary, - formatCodexPlanInlineText, - formatCodexPlanSteps, - formatCodexReviewFindingMessage, - formatCodexStatusText, - getCodexStatusTimeZoneLabel, - formatMcpServers, - formatModels, - formatSkills, - formatThreadPicker, - formatThreadPickerIntro, - formatThreadButtonLabel, - parseCodexReviewOutput, -} from "./format.js"; +import { describe, expect, it } from "vitest"; +import { formatThreadButtonLabel, formatThreadPicker } from "./format.js"; -const require = createRequire(import.meta.url); -const packageJson = require("../package.json") as { version?: string }; -const TEST_PLUGIN_VERSION = packageJson.version ?? "unknown"; -const TEST_WORKTREE_PATH = "/workspace/.codex/worktrees/41fb/openclaw"; -const TEST_PROJECT_PATH = "/workspace/openclaw"; -const TEST_EMAIL = "user@example.com"; -const TEST_MASKED_EMAIL = "use...@...ple.com"; - -function shortenHomePathForTest(value: string): string { - const home = os.homedir(); - if (value === home) { - return "~"; - } - if (value.startsWith(`${home}/`)) { - return `~/${value.slice(home.length + 1)}`; - } - return value; -} - -describe("formatThreadButtonLabel", () => { - it("uses worktree and age badges while keeping the project suffix at the end", () => { - expect( - formatThreadButtonLabel({ - thread: { - threadId: "019cdaf5-54be-7ba2-b610-dd71b0efb42b", - title: "App Server Redux - Plugin Surface Build", - projectKey: "/workspace/.codex/worktrees/cb00/openclaw", - updatedAt: Date.now() - 4 * 60_000, - createdAt: Date.now() - 10 * 60 * 60_000, - }, - includeProjectSuffix: true, - isWorktree: true, - hasChanges: true, - }), - ).toContain("🌿 ✏️ App Server Redux - Plugin Surface Build (openclaw) U:4m C:10h"); - }); - - it("falls back to the final workspace segment for non-worktree paths", () => { - expect( - formatThreadButtonLabel({ - thread: { - threadId: "019cbef1-376b-7312-98aa-24488c7499d4", - projectKey: "/workspace/.openclaw/workspace", - }, - includeProjectSuffix: true, - }), - ).toBe("019cbef1-376b-7312-98aa-24488c7499d4 (workspace)"); - }); - - it("falls back to the thread summary when the name is missing", () => { - expect( - formatThreadButtonLabel({ - thread: { - threadId: "019d2cbc-9fee-7862-8d02-683dfef71851", - summary: "What is wrong with this layout?", - projectKey: "/workspace/openclaw-app-server", - }, - includeProjectSuffix: false, - }), - ).toBe("What is wrong with this layout?"); - }); - - it("truncates long summary fallbacks before rendering", () => { +describe("thread title fallbacks", () => { + it("uses thread summary for button labels when title is missing", () => { const label = formatThreadButtonLabel({ thread: { - threadId: "thread-long", - summary: - "This is a very long first user prompt that should not become an enormous unnamed thread label in the resume picker UI", + threadId: "019d527d-6d72-7f11-81bb-2c2351705e10", + summary: "本项目最近一次提交的摘要", }, includeProjectSuffix: false, }); - expect(label).toContain("This is a very long first user"); - expect(label.length).toBeLessThanOrEqual(72); - expect(label).toContain("..."); - }); -}); - -describe("formatThreadPicker", () => { - it("uses the preview summary before falling back to the thread id", () => { - expect( - formatThreadPicker([ - { - threadId: "019d2cbc-9fee-7862-8d02-683dfef71851", - summary: "What is wrong with this layout?", - projectKey: "/workspace/openclaw-app-server", - }, - ]), - ).toContain("1. What is wrong with this layout?"); - }); -}); - -describe("formatBoundThreadSummary", () => { - it("includes project, thread metadata, and replay context", () => { - expect( - formatBoundThreadSummary({ - binding: { - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "chat-1", - }, - sessionKey: "openclaw-codex-app-server:thread:abc", - threadId: "019cc00d-6cf4-7c11-afcd-2673db349a21", - workspaceDir: TEST_WORKTREE_PATH, - threadTitle: "Fix Telegram approval flow", - updatedAt: 1, - }, - state: { - threadId: "019cc00d-6cf4-7c11-afcd-2673db349a21", - threadName: "Fix Telegram approval flow", - cwd: TEST_WORKTREE_PATH, - }, - }), - ).toBe( - [ - "Codex thread bound.", - "Project: openclaw", - "Thread Name: Fix Telegram approval flow", - "Thread ID: 019cc00d-6cf4-7c11-afcd-2673db349a21", - `Worktree Path: ${TEST_WORKTREE_PATH}`, - ].join("\n"), - ); - }); -}); - -describe("formatCodexStatusText", () => { - it("matches the old operational Codex status shape", () => { - const text = formatCodexStatusText({ - pluginVersion: TEST_PLUGIN_VERSION, - bindingActive: true, - threadState: { - threadId: "019cc00d-6cf4-7c11-afcd-2673db349a21", - threadName: "Fix Telegram approval flow", - model: "gpt-5.4", - modelProvider: "openai", - reasoningEffort: "high", - serviceTier: "default", - cwd: TEST_WORKTREE_PATH, - approvalPolicy: "on-request", - sandbox: "workspace-write", - }, - account: { - type: "chatgpt", - email: TEST_EMAIL, - planType: "pro", - }, - projectFolder: TEST_PROJECT_PATH, - worktreeFolder: TEST_WORKTREE_PATH, - planMode: false, - rateLimits: [ - { - name: "5h limit", - usedPercent: 15, - resetAt: new Date("2026-03-13T10:03:00-04:00").getTime(), - windowSeconds: 18_000, - }, - { - name: "Weekly limit", - usedPercent: 15, - resetAt: new Date("2026-03-14T10:03:00-04:00").getTime(), - windowSeconds: 604_800, - }, - ], - }); - - expect(text).toContain("Binding: Fix Telegram approval flow (openclaw)"); - expect(text).toContain(`Plugin version: ${TEST_PLUGIN_VERSION}`); - expect(text).toContain("Model: openai/gpt-5.4 · reasoning high"); - expect(text).toContain(`Project folder: ${shortenHomePathForTest(TEST_PROJECT_PATH)}`); - expect(text).toContain(`Worktree folder: ${shortenHomePathForTest(TEST_WORKTREE_PATH)}`); - expect(text).toContain("Fast mode: off"); - expect(text).toContain("Plan mode: off"); - expect(text).toContain("Context usage: unavailable until Codex emits a token-usage update"); - expect(text).toContain("Permissions: Default"); - expect(text).toContain(`Account: ${TEST_MASKED_EMAIL} (pro)`); - expect(text).toContain("Thread: 019cc00d-6cf4-7c11-afcd-2673db349a21"); - expect(text).toContain("Rate limits timezone:"); - expect(text).toContain("5h limit: 85% left"); - expect(text).toContain("Weekly limit: 85% left"); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("formats context usage once a fresh token snapshot exists", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.4", - modelProvider: "openai", - reasoningEffort: "high", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - contextUsage: { - totalTokens: 139_000, - contextWindow: 258_000, - }, - rateLimits: [ - { - name: "5h limit", - usedPercent: 4, - }, - ], - }); - - expect(text).toContain("Context usage: 139k / 258k tokens used (54% full)"); - }); - - it("falls back to the bound thread title when status has no live thread name", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "019d2cbc-9fee-7862-8d02-683dfef71851", - model: "gpt-5.4", - modelProvider: "openai", - reasoningEffort: "high", - cwd: "/repo/openclaw-app-server", - }, - projectFolder: "/repo/openclaw-app-server", - worktreeFolder: "/repo/openclaw-app-server", - rateLimits: [], - bindingThreadTitle: "What is wrong with this layout?", - }); - - expect(text).toContain("Binding: What is wrong with this layout? (openclaw-app-server)"); - }); - - it("shows plan mode on when the bound conversation has an active plan run", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.4", - modelProvider: "openai", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - planMode: true, - rateLimits: [], - }); - - expect(text).toContain("Plan mode: on"); - }); - - it("omits plan mode when the conversation is not bound", () => { - const text = formatCodexStatusText({ - bindingActive: false, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw/workspace", - rateLimits: [], - }); - - expect(text).not.toContain("Plan mode:"); - }); - - it("does not render a partial context usage line when only the window size is known", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.4", - modelProvider: "openai", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - contextUsage: { - contextWindow: 272_000, - }, - rateLimits: [], - }); - - expect(text).not.toContain("Context usage: ? / 272k"); - expect(text).toContain("Context usage: unavailable until Codex emits a token-usage update"); - }); - - it("hides non-matching model-specific rate-limit rows", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.4", - modelProvider: "openai", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - rateLimits: [ - { name: "5h limit", usedPercent: 4 }, - { name: "Weekly limit", usedPercent: 17 }, - { name: "GPT-5.3-Codex-Spark 5h limit", usedPercent: 0 }, - { name: "GPT-5.3-Codex-Spark Weekly limit", usedPercent: 0 }, - ], - }); - - expect(text).toContain("5h limit: 96% left"); - expect(text).toContain("Weekly limit: 83% left"); - expect(text).not.toContain("GPT-5.3-Codex-Spark 5h limit"); - expect(text).not.toContain("GPT-5.3-Codex-Spark Weekly limit"); + expect(label).toContain("本项目最近一次提交的摘要"); + expect(label).not.toContain("019d527d-6d72-7f11-81bb-2c2351705e10"); }); - it("groups model-specific rate-limit rows after generic rows", () => { - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.3-codex-spark", - modelProvider: "openai", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - rateLimits: [ - { name: "GPT-5.3-Codex-Spark Weekly limit", usedPercent: 0 }, - { name: "Weekly limit", usedPercent: 17 }, - { name: "GPT-5.3-Codex-Spark 5h limit", usedPercent: 0 }, - { name: "5h limit", usedPercent: 4 }, - ], - }); - - const genericFiveHourIndex = text.indexOf("5h limit: 96% left"); - const genericWeeklyIndex = text.indexOf("Weekly limit: 83% left"); - const sparkFiveHourIndex = text.indexOf("GPT-5.3-Codex-Spark 5h limit: 100% left"); - const sparkWeeklyIndex = text.indexOf("GPT-5.3-Codex-Spark Weekly limit: 100% left"); - - expect(genericFiveHourIndex).toBeGreaterThan(-1); - expect(genericWeeklyIndex).toBeGreaterThan(genericFiveHourIndex); - expect(sparkFiveHourIndex).toBeGreaterThan(genericWeeklyIndex); - expect(sparkWeeklyIndex).toBeGreaterThan(sparkFiveHourIndex); - }); - - it("formats reset windows in local time and rolls stale anchors forward", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-07T12:00:00-05:00")); - - const text = formatCodexStatusText({ - bindingActive: true, - threadState: { - threadId: "thread-123", - threadName: "Plan TASKS doc refresh", - model: "gpt-5.4", - modelProvider: "openai", - cwd: "/repo/openclaw", - }, - account: { - type: "chatgpt", - email: "user@example.com", - planType: "pro", - }, - projectFolder: "/repo/openclaw", - worktreeFolder: "/repo/openclaw", - rateLimits: [ - { - name: "5h limit", - usedPercent: 11, - resetAt: new Date("2026-01-21T07:28:00-05:00").getTime(), - windowSeconds: 18_000, - }, - { - name: "Weekly limit", - usedPercent: 20, - resetAt: new Date("2026-01-21T07:34:00-05:00").getTime(), - windowSeconds: 604_800, - }, - ], - }); - - const expectedFiveHourReset = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - }).format(new Date("2026-03-07T17:28:00Z")); - - expect(text).toContain(`Rate limits timezone: ${getCodexStatusTimeZoneLabel()}`); - expect(text).toContain(`5h limit: 89% left (resets ${expectedFiveHourReset})`); - expect(text).toContain("Weekly limit: 80% left (resets Mar 11)"); - expect(text).not.toContain("Jan 21"); - }); -}); - -describe("Codex plan delivery formatting", () => { - it("builds a truncated markdown preview for large plans", () => { - const preview = buildCodexPlanMarkdownPreview(`# Plan\n\n${"Long section.\n".repeat(300)}`, 120); - expect(preview).toContain("[Preview truncated. Open the attachment for the full plan.]"); - expect(preview?.length).toBeGreaterThan(120); - }); - - it("formats the attachment summary and fallback texts", () => { - const plan = { - explanation: "This needs the full rollout guide attached.", - steps: [{ step: "Write the rollout", status: "inProgress" as const }], - markdown: `# Plan\n\n${"Long section.\n".repeat(10)}`, - }; - expect(formatCodexPlanAttachmentSummary(plan)).toContain("Plan preview:"); - expect(formatCodexPlanAttachmentSummary(plan)).not.toContain( - "The full plan is attached as Markdown.", - ); - expect(formatCodexPlanAttachmentFallback(plan)).toContain( - "I couldn't attach the full Markdown plan here, so here's a condensed inline summary instead.", - ); - expect(formatCodexPlanAttachmentFallback(plan)).toContain("# Plan"); - }); -}); - -describe("formatThreadPickerIntro", () => { - it("includes a legend for resume badges", () => { - const text = formatThreadPickerIntro({ - page: 0, - totalPages: 7, - totalItems: 56, - includeAll: true, - }); - - expect(text).toContain("Legend: 🌿 worktree, ✏️ uncommitted changes, U updated, C created."); - }); - - it("mentions topic sync when the resume picker is in sync mode", () => { - const text = formatThreadPickerIntro({ - page: 0, - totalPages: 1, - totalItems: 3, - includeAll: true, - syncTopic: true, - }); - - expect(text).toContain("sync the current channel/topic name"); - }); - - it("shows fallback message when workspace threads fell back to global", () => { - const text = formatThreadPickerIntro({ - page: 0, - totalPages: 1, - totalItems: 5, - includeAll: true, - fallbackToGlobal: true, - }); - - expect(text).toContain("No threads in this workspace. Showing recent threads from all projects."); - }); - - it("does not show fallback message for normal global listing", () => { - const text = formatThreadPickerIntro({ - page: 0, - totalPages: 1, - totalItems: 5, - includeAll: true, - }); - - expect(text).not.toContain("No threads in this workspace"); - expect(text).toContain("Showing recent Codex threads across all projects."); - }); -}); - -describe("formatSkills", () => { - it("matches the old skill summary shape and filtering", () => { - expect( - formatSkills({ - workspaceDir: "/repo/openclaw", - filter: "creator", - skills: [ - { - cwd: "/repo/openclaw", - name: "skill-creator", - description: "Create or update a Codex skill", - enabled: true, - }, - { - cwd: "/repo/openclaw", - name: "legacy-helper", - description: "Old helper", - enabled: false, - }, - ], - }), - ).toContain("skill-creator - Create or update a Codex skill"); - }); -}); - -describe("formatAccountSummary", () => { - it("masks account emails in the detailed account summary", () => { - const text = formatAccountSummary( + it("uses thread summary in plain-text picker rows when title is missing", () => { + const text = formatThreadPicker([ { - type: "chatgpt", - email: TEST_EMAIL, - planType: "pro", + threadId: "019d5133-b02c-73f1-8574-5ddad7f8d0a5", + summary: "检查 MCP 状态与 skill", }, - [], - ); - - expect(text).toContain(`Email: ${TEST_MASKED_EMAIL}`); - expect(text).not.toContain(`Email: ${TEST_EMAIL}`); - }); -}); - -describe("formatMcpServers", () => { - it("matches the old MCP summary shape", () => { - expect( - formatMcpServers({ - servers: [ - { - name: "github", - authStatus: "authenticated", - toolCount: 12, - resourceCount: 3, - resourceTemplateCount: 1, - }, - ], - }), - ).toContain("github · auth=authenticated · tools=12 · resources=3 · templates=1"); - }); -}); - -describe("formatModels", () => { - it("shows the current model followed by the available list", () => { - const text = formatModels( - [ - { id: "gpt-5.3-codex", current: true }, - { id: "gpt-5.2-codex" }, - ], - { - threadId: "thread-1", - model: "gpt-5.3-codex", - }, - ); - - expect(text).toContain("Current model: gpt-5.3-codex"); - expect(text).toContain("Available models:"); - expect(text).toContain("- gpt-5.2-codex"); - }); -}); - -describe("parseCodexReviewOutput", () => { - it("parses summary text and structured findings from the old review format", () => { - const parsed = parseCodexReviewOutput([ - "Looks solid overall.", - "", - "[P1] Prefer Stylize helpers Location: /tmp/file.rs:10-20", - "Use .dim()/.bold() chaining instead of manual Style.", - "", - "[P2] Keep helper names consistent Location: /tmp/file.rs:30-35", - "Rename the helper to match the surrounding naming pattern.", - ].join("\n")); - - expect(parsed.summary).toBe("Looks solid overall."); - expect(parsed.findings).toHaveLength(2); - expect(formatCodexReviewFindingMessage({ finding: parsed.findings[0]!, index: 0 })).toContain( - "P1\nPrefer Stylize helpers\nLocation: /tmp/file.rs:10-20", - ); - }); -}); - -describe("formatCodexPlanInlineText", () => { - it("renders explanation, steps, and markdown for plan output", () => { - const plan = { - explanation: "Break the work into safe increments.", - steps: [ - { step: "Capture the current behavior", status: "completed" as const }, - { step: "Patch Telegram delivery", status: "inProgress" as const }, - ], - markdown: "# Plan\n\n- Patch the command", - }; + ]); - expect(formatCodexPlanSteps(plan.steps)).toContain("- [x] Capture the current behavior"); - expect(formatCodexPlanInlineText(plan)).toContain("Break the work into safe increments."); - expect(formatCodexPlanInlineText(plan)).toContain("# Plan"); + expect(text).toContain("1. 检查 MCP 状态与 skill"); + expect(text).not.toContain("1. 019d5133-b02c-73f1-8574-5ddad7f8d0a5"); }); }); diff --git a/src/format.ts b/src/format.ts index 41c34f4..852d699 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { formatModelCapabilitySuffix } from "./model-capabilities.js"; +import { getThreadNormalizedTitle } from "./thread-display.js"; import type { AccountSummary, ContextUsageSnapshot, @@ -15,7 +16,6 @@ import type { ThreadSummary, TurnResult, } from "./types.js"; -import { getThreadDisplayTitle } from "./thread-display.js"; import { getProjectName } from "./thread-picker.js"; function formatDateAge(value?: number): string | undefined { @@ -83,7 +83,7 @@ function formatMaskedEmail(email: string): string { } function formatThreadButtonTitle(thread: ThreadSummary): string { - return getThreadDisplayTitle(thread); + return getThreadNormalizedTitle(thread); } function formatCompactAge(value?: number): string | undefined { @@ -135,7 +135,7 @@ export function formatThreadPicker(threads: ThreadSummary[]): string { ...threads.slice(0, 10).map((thread, index) => { const age = formatDateAge(thread.updatedAt ?? thread.createdAt); const parts = [ - `${index + 1}. ${getThreadDisplayTitle(thread)}`, + `${index + 1}. ${formatThreadButtonTitle(thread)}`, age ? `updated ${age}` : "", thread.projectKey ? `cwd ${thread.projectKey}` : "", ].filter(Boolean); @@ -181,14 +181,18 @@ export function formatThreadPickerIntro(params: { totalPages: number; totalItems: number; includeAll: boolean; + query?: string; syncTopic?: boolean; projectName?: string; workspaceDir?: string; fallbackToGlobal?: boolean; }): string { const pageLabel = `Page ${params.page + 1}/${params.totalPages}`; + const normalizedQuery = params.query?.trim(); const scopeLabel = params.fallbackToGlobal - ? "No threads in this workspace. Showing recent threads from all projects." + ? normalizedQuery + ? `No threads in this workspace. Showing threads matching "${normalizedQuery}" from all projects.` + : "No threads in this workspace. Showing recent threads from all projects." : params.projectName ? `Showing recent Codex threads for ${params.projectName}.` : params.includeAll @@ -478,7 +482,7 @@ export function selectVisibleCodexRateLimits(params: { } return normalizeCodexModelKey(prefix) === currentModelKey; }) - .toSorted((left, right) => { + .sort((left: RateLimitSummary, right: RateLimitSummary) => { const leftName = splitCodexRateLimitName(left.name); const rightName = splitCodexRateLimitName(right.name); const leftPrefixBlank = leftName.prefix ? 1 : 0; @@ -526,7 +530,6 @@ export function formatCodexContextUsageSnapshot( export function formatCodexStatusText(params: { pluginVersion?: string; threadState?: ThreadState; - bindingThreadTitle?: string; account?: AccountSummary | null; rateLimits: RateLimitSummary[]; projectFolder?: string; @@ -538,9 +541,7 @@ export function formatCodexStatusText(params: { threadNote?: string; }): string { const lines = []; - const bindingThreadName = - params.threadState?.threadName?.trim() || - params.bindingThreadTitle?.trim(); + const bindingThreadName = params.threadState?.threadName?.trim(); const bindingProjectName = getProjectName(params.projectFolder ?? params.worktreeFolder); lines.push( params.bindingActive diff --git a/src/help.ts b/src/help.ts index 8a269d4..66d4307 100644 --- a/src/help.ts +++ b/src/help.ts @@ -16,12 +16,13 @@ type CommandHelpEntry = { export const COMMAND_HELP: Record = { cas_resume: { summary: COMMAND_SUMMARY.cas_resume, - usage: "/cas_resume [--projects|-p] [--new [project]] [--all|-a] [--cwd ] [--sync] [--model ] [--fast|--no-fast] [--yolo|--no-yolo] [filter]", + usage: "/cas_resume [--projects|-p] [--new [project]] [--all|-a] [--cwd ] [--page =1+] [--sync] [--model ] [--fast|--no-fast] [--yolo|--no-yolo] [filter]", flags: [ { flag: "--projects, --project, -p", description: "Browse projects first, then pick a thread." }, { flag: "--new [project]", description: "Start a new thread; optionally pass a project filter or workspace path." }, { flag: "--all, -a", description: "Search recent threads across projects." }, { flag: "--cwd ", description: "Restrict search to one workspace path." }, + { flag: "--page =1+", description: "Show a specific picker page (1-based), useful on text-only channels." }, { flag: "--sync", description: "Sync the chat/topic name to the selected thread title." }, { flag: "--model ", description: "Save a preferred model on the binding and apply it when possible." }, { flag: "--fast, --no-fast", description: "Enable or disable fast mode while binding or creating a thread." }, @@ -153,6 +154,30 @@ export const COMMAND_HELP: Record = { examples: ["/cas_permissions"], notes: "This shows account and permission status. To change permissions, use /cas_status --yolo or the status card toggle.", }, + cas_reply: { + summary: COMMAND_SUMMARY.cas_reply, + usage: "/cas_reply ", + flags: [{ flag: "", description: "Pick one pending Codex input action by 1-based index or exact label." }], + examples: [ + "/cas_reply 1", + "/cas_reply Allow once", + ], + }, + cas_q: { + summary: COMMAND_SUMMARY.cas_q, + usage: "/cas_q ", + flags: [ + { flag: "", description: "Choose the current questionnaire option by key or 1-based index." }, + { flag: "prev | next", description: "Navigate between questionnaire steps." }, + { flag: "freeform", description: "Answer the current question with the next non-command message." }, + ], + examples: [ + "/cas_q 1", + "/cas_q A", + "/cas_q next", + "/cas_q freeform", + ], + }, cas_init: { summary: COMMAND_SUMMARY.cas_init, usage: "/cas_init [args]", diff --git a/src/openclaw-plugin-sdk.d.ts b/src/openclaw-plugin-sdk.d.ts index bbee7c8..c87aa11 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; 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/thread-selection.test.ts b/src/thread-selection.test.ts index 445d7e8..92ee083 100644 --- a/src/thread-selection.test.ts +++ b/src/thread-selection.test.ts @@ -1,154 +1,19 @@ -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; -import { formatCommandUsage } from "./help.js"; -import { - parseThreadSelectionArgs, - selectThreadFromMatches, -} from "./thread-selection.js"; -import type { ThreadSummary } from "./types.js"; +import { parseThreadSelectionArgs } from "./thread-selection.js"; -const THREADS: ThreadSummary[] = [ - { - threadId: "thread-openclaw", - title: "OpenClaw work", - projectKey: "/workspace/openclaw", - }, - { - threadId: "thread-home", - title: "Home dotfiles", - projectKey: "/workspace/home", - }, -]; - -describe("thread selection args", () => { - it("parses --all without inventing a query", () => { - expect(parseThreadSelectionArgs("--all")).toEqual({ - includeAll: true, - listProjects: false, - startNew: false, - syncTopic: false, - cwd: undefined, - query: "", - }); - }); - - it("parses em dash all from Telegram-style input", () => { - expect(parseThreadSelectionArgs("—all")).toEqual({ - includeAll: true, - listProjects: false, - startNew: false, - syncTopic: false, - cwd: undefined, - query: "", - }); - }); - - it("parses --all with a target query", () => { - expect(parseThreadSelectionArgs("--all thread-home")).toEqual({ - includeAll: true, - listProjects: false, - startNew: false, - syncTopic: false, - cwd: undefined, - query: "thread-home", - }); - }); - - it("parses --projects and expands a home-relative cwd", () => { - expect(parseThreadSelectionArgs("--projects --cwd ~/github/openclaw")).toEqual({ - includeAll: false, - listProjects: true, - startNew: false, - syncTopic: false, - cwd: path.join(os.homedir(), "github/openclaw"), - query: "", - }); - }); - - it("parses --sync separately from the query text", () => { - expect(parseThreadSelectionArgs("—all —sync approvals")).toEqual({ - includeAll: true, - listProjects: false, - startNew: false, - syncTopic: true, - cwd: undefined, - query: "approvals", - }); - }); - - it("parses --new separately from the query text", () => { - expect(parseThreadSelectionArgs("--new openclaw")).toEqual({ - includeAll: false, - listProjects: false, - startNew: true, - syncTopic: false, - cwd: undefined, - query: "openclaw", - }); - }); - - it("returns the shared usage text when --cwd is missing its value", () => { - expect(parseThreadSelectionArgs("--cwd")).toEqual({ - includeAll: false, - listProjects: false, - startNew: false, - syncTopic: false, - cwd: undefined, - requestedModel: undefined, - requestedFast: undefined, - requestedYolo: undefined, - error: formatCommandUsage("cas_resume"), - query: "", - }); - }); -}); - -describe("thread selection", () => { - it("picks an exact thread id match", () => { - expect(selectThreadFromMatches(THREADS, "thread-home")).toEqual({ - kind: "unique", - thread: THREADS[1], - }); +describe("parseThreadSelectionArgs", () => { + it("normalizes angle-bracket thread ids", () => { + const parsed = parseThreadSelectionArgs("<019d5133-b02c-73f1-8574-5ddad7f8d0a5>"); + expect(parsed.query).toBe("019d5133-b02c-73f1-8574-5ddad7f8d0a5"); }); - it("picks an exact summary fallback match when the thread has no explicit title", () => { - const threads: ThreadSummary[] = [ - { - threadId: "019d2cbc-9fee-7862-8d02-683dfef71851", - summary: "What is wrong with this layout?", - projectKey: "/workspace/openclaw-app-server", - }, - ...THREADS, - ]; - - expect(selectThreadFromMatches(threads, "What is wrong with this layout?")).toEqual({ - kind: "unique", - thread: threads[0], - }); - }); - - it("matches the full normalized summary even when display text is truncated", () => { - const longSummary = - "This is a very long first user prompt that should still be matchable even if the visible picker label is truncated"; - const threads: ThreadSummary[] = [ - { - threadId: "thread-long", - summary: longSummary, - }, - ...THREADS, - ]; - - expect(selectThreadFromMatches(threads, longSummary)).toEqual({ - kind: "unique", - thread: threads[0], - }); + it("normalizes labeled thread ids wrapped in angle brackets", () => { + const parsed = parseThreadSelectionArgs(""); + expect(parsed.query).toBe("019d5134-da1d-7301-a247-f3b6fa97f30a"); }); - it("does not auto-pick the first fuzzy match when multiple threads exist", () => { - expect(selectThreadFromMatches(THREADS, "thread")).toEqual({ - kind: "ambiguous", - threads: THREADS, - }); + it("keeps normal free-text filters unchanged", () => { + const parsed = parseThreadSelectionArgs("release rollback plan"); + expect(parsed.query).toBe("release rollback plan"); }); }); diff --git a/src/thread-selection.ts b/src/thread-selection.ts index 5e62ed2..245cbb7 100644 --- a/src/thread-selection.ts +++ b/src/thread-selection.ts @@ -1,7 +1,6 @@ import os from "node:os"; import path from "node:path"; import { formatCommandUsage } from "./help.js"; -import { getThreadNormalizedTitle } from "./thread-display.js"; import type { ThreadSummary } from "./types.js"; export type ParsedThreadSelectionArgs = { @@ -9,6 +8,7 @@ export type ParsedThreadSelectionArgs = { listProjects: boolean; startNew: boolean; syncTopic: boolean; + page?: number; cwd?: string; requestedModel?: string; requestedFast?: boolean; @@ -28,6 +28,43 @@ function normalizeOptionDashes(text: string): string { .replace(/[\u2010-\u2015\u2212]/g, "-"); } +function unwrapPairedDelimiters(value: string): string { + let result = value.trim(); + const pairs: Array<[string, string]> = [ + ["<", ">"], + ["<", ">"], + ["《", "》"], + ["`", "`"], + ]; + let changed = true; + while (changed) { + changed = false; + for (const [left, right] of pairs) { + if (result.startsWith(left) && result.endsWith(right) && result.length > left.length + right.length) { + result = result.slice(left.length, result.length - right.length).trim(); + changed = true; + } + } + } + return result; +} + +function isLikelyThreadId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.trim()); +} + +function normalizeThreadSelectionQuery(rawQuery: string): string { + let query = unwrapPairedDelimiters(rawQuery); + const labeledMatch = query.match(/^(?:thread\s*id|id)\s*[::]\s*(.+)$/i); + if (labeledMatch?.[1]) { + const candidate = unwrapPairedDelimiters(labeledMatch[1]); + if (isLikelyThreadId(candidate)) { + return candidate; + } + } + return query; +} + export function parseThreadSelectionArgs(args: string): ParsedThreadSelectionArgs { const tokens = normalizeOptionDashes(args) .split(/\s+/) @@ -37,6 +74,7 @@ export function parseThreadSelectionArgs(args: string): ParsedThreadSelectionArg let listProjects = false; let startNew = false; let syncTopic = false; + let page: number | undefined; let cwd: string | undefined; let requestedModel: string | undefined; let requestedFast: boolean | undefined; @@ -62,6 +100,22 @@ export function parseThreadSelectionArgs(args: string): ParsedThreadSelectionArg syncTopic = true; continue; } + if (token === "--page" || token.startsWith("--page=")) { + const rawPageValue = + token === "--page" + ? tokens[index + 1]?.trim() + : token.slice("--page=".length).trim(); + const parsedPage = Number(rawPageValue); + if (!rawPageValue || !Number.isInteger(parsedPage) || parsedPage < 1) { + error = "Usage: /cas_resume [--projects|-p] [--new [project]] [--all|-a] [--cwd ] [--page =1+] [--sync] [--model ] [--fast|--no-fast] [--yolo|--no-yolo] [filter]"; + break; + } + page = parsedPage - 1; + if (token === "--page") { + index += 1; + } + continue; + } if (token === "--fast") { requestedFast = true; continue; @@ -106,12 +160,13 @@ export function parseThreadSelectionArgs(args: string): ParsedThreadSelectionArg listProjects, startNew, syncTopic, + page, cwd, requestedModel, requestedFast, requestedYolo, error, - query: queryTokens.join(" ").trim(), + query: normalizeThreadSelectionQuery(queryTokens.join(" ").trim()), }; } @@ -141,7 +196,7 @@ export function selectThreadFromMatches( const loweredQuery = trimmedQuery.toLowerCase(); const exactMatch = threads.find((thread) => thread.threadId === trimmedQuery) ?? - threads.find((thread) => getThreadNormalizedTitle(thread).toLowerCase() === loweredQuery); + threads.find((thread) => thread.title?.trim().toLowerCase() === loweredQuery); if (exactMatch) { return { kind: "unique", thread: exactMatch }; 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; diff --git a/tsconfig.json b/tsconfig.json index 74606dd..db1a158 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,16 @@ { "compilerOptions": { "target": "ES2022", - "lib": [ - "ES2023" - ], + "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, "strict": true, - "skipLibCheck": true, "noEmit": true, - "types": [ - "node", - "vitest/globals" - ] + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node", "vitest/globals"] }, - "include": [ - "index.ts", - "src/**/*.ts", - "src/**/*.d.ts" - ] + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["node_modules"] }