From d7c39c139ff40815478f9e06b0888e57bd93fad5 Mon Sep 17 00:00:00 2001 From: harper Date: Thu, 2 Jul 2026 02:00:55 +0800 Subject: [PATCH 1/3] fix: use plugin server url for v2 client --- src/index.ts | 15 ++++++++++++++- tests/index-v2-client.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/index-v2-client.test.ts diff --git a/src/index.ts b/src/index.ts index f1fdc14..1922e6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,19 @@ function loadFeishuRuntimePrompt(): string { /** 缓存的飞书运行时 prompt,在模块加载时一次性读取 */ const feishuRuntimePrompt = loadFeishuRuntimePrompt() +/** + * 构建插件内部 OpenCode v2 client 配置。 + * + * 插件宿主可能使用动态端口启动 OpenCode server;这里必须使用 ctx.serverUrl, + * 否则 abort_reply 等后台回调会退回 SDK 默认地址并在动态端口下失败。 + */ +export function buildV2ClientConfig(input: { serverUrl: URL; directory: string }): NonNullable[0]> { + return { + baseUrl: input.serverUrl.toString(), + directory: input.directory || undefined, + } +} + /** * OpenCode 插件入口导出。 * @@ -144,7 +157,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { const botOpenId = await fetchBotOpenId(larkClient, log) // v2 client 主要用于权限审批/问答交互回调。 - const v2Client = createOpencodeClient({ directory: resolvedConfig.directory || undefined }) + const v2Client = createOpencodeClient(buildV2ClientConfig({ serverUrl: ctx.serverUrl, directory: resolvedConfig.directory })) const interactiveDeps: InteractiveDeps = { feishuClient: larkClient, log, v2Client } // 启动飞书 WebSocket 网关(复用 larkClient) diff --git a/tests/index-v2-client.test.ts b/tests/index-v2-client.test.ts new file mode 100644 index 0000000..39fc5f8 --- /dev/null +++ b/tests/index-v2-client.test.ts @@ -0,0 +1,32 @@ +/** + * index.ts v2 client config regression test. + * + * Ensures the Feishu plugin binds its secondary OpenCode SDK client + * to the running server URL supplied by the plugin host. + * Run: npx tsx --test tests/index-v2-client.test.ts + */ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { buildV2ClientConfig } from "../src/index.js" + +describe("buildV2ClientConfig", () => { + it("uses plugin ctx.serverUrl as SDK baseUrl", () => { + const config = buildV2ClientConfig({ + serverUrl: new URL("http://127.0.0.1:55249"), + directory: "/repo", + }) + + assert.equal(config.baseUrl, "http://127.0.0.1:55249/") + assert.equal(config.directory, "/repo") + }) + + it("omits blank directory while preserving baseUrl", () => { + const config = buildV2ClientConfig({ + serverUrl: new URL("http://localhost:4096"), + directory: "", + }) + + assert.equal(config.baseUrl, "http://localhost:4096/") + assert.equal(config.directory, undefined) + }) +}) From ab0dc3a3b05ff7b93f1cbffcaeab0c6fce313be9 Mon Sep 17 00:00:00 2001 From: harper Date: Thu, 2 Jul 2026 10:31:13 +0800 Subject: [PATCH 2/3] fix: support text fallback for pending questions --- src/handler/interactive.ts | 57 +++++++-- src/handler/pending-questions.ts | 203 +++++++++++++++++++++++++++++++ src/handler/session-queue.ts | 14 +++ 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 src/handler/pending-questions.ts diff --git a/src/handler/interactive.ts b/src/handler/interactive.ts index ae5e1ee..5d3cbac 100644 --- a/src/handler/interactive.ts +++ b/src/handler/interactive.ts @@ -16,6 +16,11 @@ import { resetAbortForRun, } from "./reply-run-registry.js" import { emit } from "./action-bus.js" +import { + buildQuestionFallbackText, + clearPendingQuestionByRequestId, + registerPendingQuestion, +} from "./pending-questions.js" /** * form_submit toast 文案常量。 @@ -426,13 +431,49 @@ export function handleQuestionRequested( sessionId: string, ): void { const requestId = String(request.id ?? "") - sendRequestCard({ - requestId, - chatId, - deps, - card: buildQuestionCardDSL(request, chatId, chatType, sessionId), - missingClientMessage: "OpenCode client 未配置,跳过问答卡片发送", - sendFailureMessage: "发送问答卡片失败", + if (!deps.v2Client) { + deps.log("warn", "OpenCode client 未配置,跳过问答卡片发送", { requestId }) + return + } + if (!requestId || !markSeen(requestId)) return + + registerPendingQuestion({ request, chatId, sessionId }) + + void (async () => { + const res = await sender.sendInteractiveCard( + deps.feishuClient, + chatId, + buildQuestionCardDSL(request, chatId, chatType, sessionId), + deps.log, + ) + if (res.ok) return + + deps.log("error", "发送问答卡片失败,回退纯文本问题", { + requestId, + chatId, + error: res.error ?? "unknown", + }) + const fallback = await sender.sendTextMessage( + deps.feishuClient, + chatId, + buildQuestionFallbackText(request), + deps.log, + ) + if (!fallback.ok) { + unmarkSeen(requestId) + deps.log("error", "发送问答纯文本 fallback 失败", { + requestId, + chatId, + error: fallback.error ?? "unknown", + }) + } + })().catch((err) => { + unmarkSeen(requestId) + deps.log("error", "发送问答卡片失败", { + requestId, + chatId, + error: err instanceof Error ? err.message : String(err), + }) }) } @@ -562,6 +603,7 @@ export async function handleCardAction( reply: value.reply, }).then(() => emitPhase("completed", successBody)).catch(onReplyFailed) } else { + clearPendingQuestionByRequestId(value.requestId) void deps.v2Client.question.reply({ requestID: value.requestId, answers: value.answers, @@ -599,6 +641,7 @@ export function buildCallbackResponse(action: CardActionData, log?: LogFn): obje } if (value.action === "question_reply") { + clearPendingQuestionByRequestId(value.requestId) return { toast: { type: "info", content: "📨 已收到回答,正在转交..." }, } diff --git a/src/handler/pending-questions.ts b/src/handler/pending-questions.ts new file mode 100644 index 0000000..eab6197 --- /dev/null +++ b/src/handler/pending-questions.ts @@ -0,0 +1,203 @@ +import type { QuestionRequest, LogFn } from "../types.js" +import * as sender from "../feishu/sender.js" +import type * as Lark from "@larksuiteoapi/node-sdk" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { TtlMap } from "../utils/ttl-map.js" +import { emit } from "./action-bus.js" + +const PENDING_QUESTION_TTL_MS = 10 * 60 * 1_000 + +interface PendingQuestion { + requestId: string + sessionId: string + chatId: string + request: QuestionRequest +} + +export interface PendingQuestionDeps { + feishuClient: InstanceType + log: LogFn + v2Client?: OpencodeClient +} + +export interface ParsedPendingQuestionReply { + answers: string[][] + displayText: string +} + +const pendingByChatId = new TtlMap(PENDING_QUESTION_TTL_MS) +const chatIdByRequestId = new TtlMap(PENDING_QUESTION_TTL_MS) + +export function registerPendingQuestion(params: { + request: QuestionRequest + chatId: string + sessionId: string +}): void { + const requestId = String(params.request.id ?? "") + if (!requestId) return + + pendingByChatId.set(params.chatId, { + requestId, + sessionId: params.sessionId, + chatId: params.chatId, + request: params.request, + }) + chatIdByRequestId.set(requestId, params.chatId) +} + +export function clearPendingQuestionByRequestId(requestId: string): void { + const chatId = chatIdByRequestId.get(requestId) + if (chatId) pendingByChatId.delete(chatId) + chatIdByRequestId.delete(requestId) +} + +export function buildQuestionFallbackText(request: QuestionRequest): string { + const question = getFirstQuestion(request) + const header = String(question?.header ?? "AI 提问") + const questionText = String(question?.question ?? "请选择") + const options = normalizeOptions(request) + + const lines = [`${header}`, "", questionText] + if (options.length > 0) { + lines.push("", "可选回复:") + options.forEach((option, idx) => { + lines.push(`${idx + 1}. ${option.label}`) + }) + } + lines.push("", "你可以直接回复 1/2,或回复“继续”“取消”。") + return lines.join("\n") +} + +export function parsePendingQuestionReply( + content: string, + request: QuestionRequest, +): ParsedPendingQuestionReply | undefined { + const text = content.trim() + if (!text) return undefined + + const options = normalizeOptions(request) + const lower = text.toLowerCase() + + if (/^[1-9]\d*$/.test(text)) { + const idx = Number(text) - 1 + if (options.length === 0) return { answers: [[text]], displayText: text } + if (idx >= 0 && idx < options.length) { + const selected = options[idx] + return { answers: [[selected.value]], displayText: selected.label } + } + return undefined + } + + if (isContinueText(lower, text)) { + const selected = findOption(options, ["继续", "continue", "proceed", "yes", "allow", "approve"]) ?? options[0] + return { + answers: [[selected?.value ?? text]], + displayText: selected?.label ?? text, + } + } + + if (isCancelText(lower, text)) { + const selected = findOption(options, ["取消", "cancel", "stop", "reject", "deny", "no"]) + return { + answers: [[selected?.value ?? text]], + displayText: selected?.label ?? text, + } + } + + return undefined +} + +export async function tryResolvePendingQuestionText(params: { + chatId: string + content: string + deps: PendingQuestionDeps +}): Promise { + const pending = pendingByChatId.get(params.chatId) + if (!pending) return false + + const parsed = parsePendingQuestionReply(params.content, pending.request) + if (!parsed) return false + + if (!params.deps.v2Client) { + params.deps.log("warn", "OpenCode client 未配置,无法处理纯文本问答回复", { + requestId: pending.requestId, + sessionId: pending.sessionId, + chatId: params.chatId, + }) + await sender.sendTextMessage(params.deps.feishuClient, params.chatId, "当前环境无法提交这个回答,请稍后重试。", params.deps.log) + return true + } + + try { + await params.deps.v2Client.question.reply({ + requestID: pending.requestId, + answers: parsed.answers, + }) + clearPendingQuestionByRequestId(pending.requestId) + emitQuestionPhase(pending.sessionId, "completed", "用户已通过纯文本回答问题。", params.deps.log) + await sender.sendTextMessage(params.deps.feishuClient, params.chatId, `已收到选择:${parsed.displayText}`, params.deps.log) + } catch (err) { + params.deps.log("error", "纯文本问答回复提交失败", { + requestId: pending.requestId, + sessionId: pending.sessionId, + chatId: params.chatId, + error: err instanceof Error ? err.message : String(err), + }) + emitQuestionPhase(pending.sessionId, "error", "问答回调转发失败。", params.deps.log) + await sender.sendTextMessage(params.deps.feishuClient, params.chatId, "提交回答失败,请稍后重试。", params.deps.log) + } + + return true +} + +function emitQuestionPhase( + sessionId: string, + status: "completed" | "error", + body: string, + log: LogFn, +): void { + emit(sessionId, { + type: "details-updated", + sessionId, + phase: { + phaseId: "question", + label: "等待答复", + status, + body, + updatedAt: new Date().toISOString(), + }, + }, log) +} + +function getFirstQuestion(request: QuestionRequest): NonNullable[number] | undefined { + return request.questions?.[0] +} + +function normalizeOptions(request: QuestionRequest): Array<{ label: string; value: string }> { + const rawOptions = getFirstQuestion(request)?.options + if (!Array.isArray(rawOptions)) return [] + return rawOptions + .map((option, idx) => ({ + label: String(option.label ?? option.value ?? `选项 ${idx + 1}`), + value: String(option.value ?? option.label ?? ""), + })) + .filter((option) => option.value.trim().length > 0 || option.label.trim().length > 0) +} + +function findOption( + options: Array<{ label: string; value: string }>, + needles: string[], +): { label: string; value: string } | undefined { + return options.find((option) => { + const haystack = `${option.label}\n${option.value}`.toLowerCase() + return needles.some((needle) => haystack.includes(needle.toLowerCase())) + }) +} + +function isContinueText(lower: string, raw: string): boolean { + return lower === "continue" || lower === "go on" || raw === "继续" || raw === "继续执行" +} + +function isCancelText(lower: string, raw: string): boolean { + return lower === "cancel" || lower === "stop" || raw === "取消" || raw === "停止" +} diff --git a/src/handler/session-queue.ts b/src/handler/session-queue.ts index 0456cdf..ba673f7 100644 --- a/src/handler/session-queue.ts +++ b/src/handler/session-queue.ts @@ -13,6 +13,7 @@ import type { FeishuMessageContext } from "../types.js" import { handleChat, type ChatDeps } from "./chat.js" import { buildSessionKey } from "../session.js" +import { tryResolvePendingQuestionText } from "./pending-questions.js" /** 单条待处理消息及其运行依赖。 */ interface QueuedMessage { @@ -59,6 +60,19 @@ function cleanupStateIfIdle(sessionKey: string, state: QueueState): void { * - 需要回复的消息则按 sessionKey 归并到串行队列 */ export async function enqueueMessage(ctx: FeishuMessageContext, deps: ChatDeps): Promise { + if ( + ctx.shouldReply && + ctx.messageType === "text" && + deps.interactiveDeps && + await tryResolvePendingQuestionText({ + chatId: ctx.chatId, + content: ctx.content, + deps: deps.interactiveDeps, + }) + ) { + return + } + // 静默消息只做上下文同步,不需要排队等待 UI 回复链路。 if (!ctx.shouldReply) { await handleChat(ctx, deps) From 19e6a6d6d8a979bb2fdad67b4977ffa282aa1641 Mon Sep 17 00:00:00 2001 From: Harper <6678954+chenhaipeng@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:37:15 +0800 Subject: [PATCH 3/3] Update src/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1922e6c..d740ad6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,9 +85,9 @@ const feishuRuntimePrompt = loadFeishuRuntimePrompt() * 插件宿主可能使用动态端口启动 OpenCode server;这里必须使用 ctx.serverUrl, * 否则 abort_reply 等后台回调会退回 SDK 默认地址并在动态端口下失败。 */ -export function buildV2ClientConfig(input: { serverUrl: URL; directory: string }): NonNullable[0]> { +export function buildV2ClientConfig(input: { serverUrl?: URL; directory: string }): NonNullable[0]> { return { - baseUrl: input.serverUrl.toString(), + baseUrl: input.serverUrl?.toString(), directory: input.directory || undefined, } }