From 0dea9e3c87a8eb36dbe102205a1934702b10db75 Mon Sep 17 00:00:00 2001 From: Mack Date: Wed, 9 Sep 2026 15:58:01 +0800 Subject: [PATCH] fix(feishu): report real connection state and diagnostics Derive status from the SDK WebSocket lifecycle instead of monitor presence. Preserve authentication and reconnect failures for channel diagnostics, clear them after recovery, and keep commands consistent with account state. Pin the tested Lark SDK, prepare Feishu 0.2.1, and run plugin regressions in CI. Verified 153 tests, typecheck, build, translations, and the original invalid-credential and disconnect probes. --- .github/workflows/validate.yml | 3 + plugins/feishu/locales/en.json | 5 +- plugins/feishu/locales/es.json | 5 +- plugins/feishu/locales/ja.json | 5 +- plugins/feishu/locales/ko.json | 5 +- plugins/feishu/locales/zh-CN.json | 5 +- plugins/feishu/locales/zh-TW.json | 5 +- plugins/feishu/package.json | 4 +- plugins/feishu/src/api/client.ts | 33 ++- plugins/feishu/src/commands/feishu.ts | 28 +- .../feishu/src/gateway/connection-error.ts | 25 ++ plugins/feishu/src/gateway/monitor.ts | 18 +- plugins/feishu/src/gateway/ws-gateway.ts | 80 +++++- plugins/feishu/src/index.ts | 64 ++++- plugins/feishu/tests/client.test.ts | 18 +- plugins/feishu/tests/connection-error.test.ts | 30 +++ plugins/feishu/tests/gateway.test.ts | 250 ++++++++++++++++++ pnpm-lock.yaml | 34 +-- 18 files changed, 523 insertions(+), 94 deletions(-) create mode 100644 plugins/feishu/src/gateway/connection-error.ts create mode 100644 plugins/feishu/tests/connection-error.test.ts create mode 100644 plugins/feishu/tests/gateway.test.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4878c5d..a4e3fb7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -29,6 +29,9 @@ jobs: - name: Lint run: pnpm lint + - name: Test plugins + run: pnpm test + - name: Validate plugin translations run: pnpm build:registry diff --git a/plugins/feishu/locales/en.json b/plugins/feishu/locales/en.json index 2b25523..a964bef 100644 --- a/plugins/feishu/locales/en.json +++ b/plugins/feishu/locales/en.json @@ -23,5 +23,8 @@ "config.domain": "Domain", "label": "Feishu", "description": "Chat with Cola in Feishu", - "channel.groupDisabled": "Group chat is not enabled. Please message the bot directly." + "channel.groupDisabled": "Group chat is not enabled. Please message the bot directly.", + "status.connecting": "Connecting…", + "status.reconnecting": "Connection lost; reconnecting…", + "status.failed": "Connection failed" } diff --git a/plugins/feishu/locales/es.json b/plugins/feishu/locales/es.json index 79a7054..dc094ae 100644 --- a/plugins/feishu/locales/es.json +++ b/plugins/feishu/locales/es.json @@ -23,5 +23,8 @@ "config.domain": "Dominio", "label": "Feishu", "description": "Chatea con Cola en Feishu", - "channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot." + "channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot.", + "status.connecting": "Conectando…", + "status.reconnecting": "Conexión perdida; reconectando…", + "status.failed": "Error de conexión" } diff --git a/plugins/feishu/locales/ja.json b/plugins/feishu/locales/ja.json index 4d06cc6..0e84dda 100644 --- a/plugins/feishu/locales/ja.json +++ b/plugins/feishu/locales/ja.json @@ -23,5 +23,8 @@ "config.domain": "ドメイン", "label": "Feishu", "description": "Feishu で Cola と会話", - "channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。" + "channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。", + "status.connecting": "接続中…", + "status.reconnecting": "接続が切れました。再接続中…", + "status.failed": "接続に失敗しました" } diff --git a/plugins/feishu/locales/ko.json b/plugins/feishu/locales/ko.json index ab8ccd8..1f128ed 100644 --- a/plugins/feishu/locales/ko.json +++ b/plugins/feishu/locales/ko.json @@ -23,5 +23,8 @@ "config.domain": "도메인", "label": "Feishu", "description": "Feishu에서 Cola와 대화하세요", - "channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요." + "channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요.", + "status.connecting": "연결 중…", + "status.reconnecting": "연결이 끊어졌습니다. 다시 연결 중…", + "status.failed": "연결 실패" } diff --git a/plugins/feishu/locales/zh-CN.json b/plugins/feishu/locales/zh-CN.json index 7aa1331..6ac27fe 100644 --- a/plugins/feishu/locales/zh-CN.json +++ b/plugins/feishu/locales/zh-CN.json @@ -23,5 +23,8 @@ "config.domain": "域名", "label": "飞书", "description": "在飞书中与 Cola 对话", - "channel.groupDisabled": "尚未启用群聊,请私信机器人。" + "channel.groupDisabled": "尚未启用群聊,请私信机器人。", + "status.connecting": "正在连接…", + "status.reconnecting": "连接已断开,正在重连…", + "status.failed": "连接失败" } diff --git a/plugins/feishu/locales/zh-TW.json b/plugins/feishu/locales/zh-TW.json index d2b6a5a..b3b68f5 100644 --- a/plugins/feishu/locales/zh-TW.json +++ b/plugins/feishu/locales/zh-TW.json @@ -23,5 +23,8 @@ "config.domain": "網域", "label": "飛書", "description": "在飛書中與 Cola 對話", - "channel.groupDisabled": "尚未啟用群聊,請私訊機器人。" + "channel.groupDisabled": "尚未啟用群聊,請私訊機器人。", + "status.connecting": "正在連線…", + "status.reconnecting": "連線已中斷,正在重新連線…", + "status.failed": "連線失敗" } diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index 11cc211..1383244 100644 --- a/plugins/feishu/package.json +++ b/plugins/feishu/package.json @@ -1,6 +1,6 @@ { "name": "cola-plugin-feishu", - "version": "0.2.0", + "version": "0.2.1", "description": "Feishu/Lark channel plugin for Cola", "license": "Apache-2.0", "type": "module", @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@larksuiteoapi/node-sdk": "^1.61.1", + "@larksuiteoapi/node-sdk": "1.73.3", "@marswave/cola-plugin-sdk": "0.0.5" }, "devDependencies": { diff --git a/plugins/feishu/src/api/client.ts b/plugins/feishu/src/api/client.ts index 31a8f03..5a10cf7 100644 --- a/plugins/feishu/src/api/client.ts +++ b/plugins/feishu/src/api/client.ts @@ -1,5 +1,4 @@ import * as lark from "@larksuiteoapi/node-sdk"; -import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import type { FeishuDomain, FeishuAccountConfig } from "./types.js"; function resolveDomain(domain: FeishuDomain | undefined): lark.Domain | string { @@ -41,7 +40,15 @@ export function createLarkClient(accountId: string, config: FeishuAccountConfig) return client; } -export function createLarkWSClient(config: FeishuAccountConfig): lark.WSClient { +type WSLifecycleOptions = Pick< + ConstructorParameters[0], + "logger" | "onReady" | "onError" | "onReconnecting" | "onReconnected" +>; + +export function createLarkWSClient( + config: FeishuAccountConfig, + lifecycle: WSLifecycleOptions, +): lark.WSClient { const { appId, appSecret, domain } = config; if (!appId || !appSecret) { throw new Error("Feishu WSClient requires appId and appSecret"); @@ -52,6 +59,7 @@ export function createLarkWSClient(config: FeishuAccountConfig): lark.WSClient { appSecret, domain: resolveDomain(domain), loggerLevel: lark.LoggerLevel.info, + ...lifecycle, }); } @@ -64,22 +72,13 @@ export function createEventDispatcher(config: FeishuAccountConfig): lark.EventDi /** * Fetch the bot's own open_id (used to detect @bot mentions in group chats). - * Best-effort: returns undefined on failure so group gating degrades gracefully. */ -export async function fetchBotOpenId( - client: lark.Client, - logger: PluginLogger, -): Promise { - try { - const res = (await client.request({ - method: "GET", - url: "/open-apis/bot/v3/info", - })) as { bot?: { open_id?: string } }; - return res?.bot?.open_id; - } catch (err) { - logger.warn("Failed to fetch Feishu bot open_id (group @mention detection disabled)", err); - return undefined; - } +export async function fetchBotOpenId(client: lark.Client): Promise { + const res = (await client.request({ + method: "GET", + url: "/open-apis/bot/v3/info", + })) as { bot?: { open_id?: string } }; + return res?.bot?.open_id; } export function getLarkClient(accountId: string): lark.Client | undefined { diff --git a/plugins/feishu/src/commands/feishu.ts b/plugins/feishu/src/commands/feishu.ts index 94651d7..a73eab6 100644 --- a/plugins/feishu/src/commands/feishu.ts +++ b/plugins/feishu/src/commands/feishu.ts @@ -1,10 +1,15 @@ import { pluginMessage as m, joinPluginText as join } from "@marswave/cola-plugin-sdk"; -import type { PluginText, PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; -import type { MonitorHandle } from "../gateway/monitor.js"; +import type { + ChannelStatusResult, + PluginText, + PluginCommandDefinition, +} from "@marswave/cola-plugin-sdk"; +import type { FeishuPluginConfig } from "../api/types.js"; +import { parseAccountConfigs } from "../auth/accounts.js"; import { redactSecret } from "../util/redact.js"; export function createFeishuCommands( - getMonitors: () => Map, + getAccountStatus: (accountId: string) => ChannelStatusResult, ): PluginCommandDefinition[] { return [ { @@ -24,20 +29,25 @@ export function createFeishuCommands( ], async execute(ctx) { const sub = ctx.args.trim() || "status"; - const monitors = getMonitors(); + const accounts = parseAccountConfigs(ctx.config as unknown as FeishuPluginConfig); if (sub === "status") { - if (monitors.size === 0) { + if (accounts.size === 0) { return { reply: m("status.noAccounts", "No accounts configured") }; } const lines: PluginText[] = [ m("command.statusTitle", "**{{name}} Status**", { name: m("label", "Feishu") }), "", ]; - for (const [id] of monitors) { + for (const [id] of accounts) { + const status = getAccountStatus(id); lines.push( - m("", "- **{{id}}**: {{status}}", { id, status: m("status.connected", "Connected") }), + m("", "- **{{id}}**: {{status}}", { + id, + status: status.message ?? m("status.disconnected", "Disconnected"), + }), ); + if (status.details) lines.push(status.details); } return { reply: join(lines) }; } @@ -57,9 +67,7 @@ export function createFeishuCommands( ? redactSecret(acct.appId) : m("state.missing", "Missing"); const domain = (acct.domain as string) ?? "feishu"; - const active = monitors.has(id) - ? m("status.connected", "Connected") - : m("status.disconnected", "Disconnected"); + const active = getAccountStatus(id).message ?? m("status.disconnected", "Disconnected"); lines.push( m( "command.accountLine", diff --git a/plugins/feishu/src/gateway/connection-error.ts b/plugins/feishu/src/gateway/connection-error.ts new file mode 100644 index 0000000..6f80f3c --- /dev/null +++ b/plugins/feishu/src/gateway/connection-error.ts @@ -0,0 +1,25 @@ +/** Keep SDK diagnostics without serializing request headers, credentials, or stacks. */ +export function describeConnectionError(error: unknown): string { + function parts(value: unknown, depth: number): string[] { + if (depth > 4) return []; + if (typeof value === "string") { + const text = value.trim(); + return text && text !== "[ws]" ? [text] : []; + } + if (Array.isArray(value)) return value.flatMap((item) => parts(item, depth + 1)); + if (!value || typeof value !== "object") return []; + const record = value as Record; + const code = + typeof record.code === "string" || typeof record.code === "number" + ? [`code: ${record.code}`] + : []; + const response = record.response as { data?: unknown } | undefined; + return [ + ...code, + ...parts(record.message ?? record.msg, depth + 1), + ...parts(response?.data, depth + 1), + ...parts(record.cause, depth + 1), + ]; + } + return [...new Set(parts(error, 0))].join("; ").slice(0, 1000) || "Feishu connection failed"; +} diff --git a/plugins/feishu/src/gateway/monitor.ts b/plugins/feishu/src/gateway/monitor.ts index d18484b..1f1690e 100644 --- a/plugins/feishu/src/gateway/monitor.ts +++ b/plugins/feishu/src/gateway/monitor.ts @@ -1,6 +1,6 @@ import type { PluginRuntime } from "@marswave/cola-plugin-sdk"; import type * as lark from "@larksuiteoapi/node-sdk"; -import type { PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk"; +import type { ChannelStatusResult, PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk"; import type { FeishuAccountConfig } from "../api/types.js"; import { createLarkClient, createEventDispatcher, fetchBotOpenId } from "../api/client.js"; import { registerMessageHandler, registerReactionHandler } from "./event-handler.js"; @@ -8,12 +8,14 @@ import { startWSGateway } from "./ws-gateway.js"; import { MessageDedup } from "./dedup.js"; import { ChatMap } from "./chat-map.js"; import { GroupContextTracker } from "./group-context.js"; +import { describeConnectionError } from "./connection-error.js"; export type MonitorHandle = { accountId: string; client: lark.Client; chatMap: ChatMap; cleanup: () => void; + getStatus: () => ChannelStatusResult; }; /** @@ -30,6 +32,7 @@ export async function startMonitor(opts: { groupEnabled: boolean; }): Promise { const { accountId, config, deliver, logger, abortSignal, groupEnabled } = opts; + abortSignal.throwIfAborted(); // Create client and dispatcher const client = createLarkClient(accountId, config); @@ -39,7 +42,15 @@ export async function startMonitor(opts: { const groupContext = new GroupContextTracker(); // Bot open_id is required to detect @bot mentions in group chats. - const botOpenId = await fetchBotOpenId(client, logger); + let botOpenId: string | undefined; + let initialError: string | undefined; + try { + botOpenId = await fetchBotOpenId(client); + } catch (err) { + initialError = describeConnectionError(err); + logger.warn(`feishu[${accountId}]: Failed to fetch bot open_id: ${initialError}`); + } + abortSignal.throwIfAborted(); const deps = { client, @@ -58,7 +69,7 @@ export async function startMonitor(opts: { registerMessageHandler(dispatcher, deps); registerReactionHandler(dispatcher, deps); - const handle = startWSGateway(accountId, config, dispatcher, abortSignal, logger); + const handle = startWSGateway(accountId, config, dispatcher, abortSignal, logger, initialError); logger.info(`feishu[${accountId}]: monitor started (mode=websocket)`); @@ -67,5 +78,6 @@ export async function startMonitor(opts: { client, chatMap, cleanup: handle.cleanup, + getStatus: handle.getStatus, }; } diff --git a/plugins/feishu/src/gateway/ws-gateway.ts b/plugins/feishu/src/gateway/ws-gateway.ts index fa1cda8..e771d9a 100644 --- a/plugins/feishu/src/gateway/ws-gateway.ts +++ b/plugins/feishu/src/gateway/ws-gateway.ts @@ -1,11 +1,14 @@ import type * as lark from "@larksuiteoapi/node-sdk"; import { createLarkWSClient } from "../api/client.js"; import type { FeishuAccountConfig } from "../api/types.js"; -import type { PluginLogger } from "@marswave/cola-plugin-sdk"; +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; +import type { ChannelStatusResult, PluginLogger } from "@marswave/cola-plugin-sdk"; +import { describeConnectionError } from "./connection-error.js"; export type WSGatewayHandle = { wsClient: lark.WSClient; cleanup: () => void; + getStatus: () => ChannelStatusResult; }; /** @@ -17,17 +20,54 @@ export function startWSGateway( eventDispatcher: lark.EventDispatcher, abortSignal: AbortSignal, logger: PluginLogger, + initialError?: string, ): WSGatewayHandle { logger.info(`feishu[${accountId}]: starting WebSocket connection...`); - const wsClient = createLarkWSClient(config); let cleanedUp = false; + const errors: string[] = initialError ? [initialError] : []; + const recordError = (error: unknown) => { + if (cleanedUp) return; + const details = describeConnectionError(error); + if (!errors.includes(details)) { + // Preserve the initial cause when retries add generic connection failures. + if (errors.length === 3) errors.splice(1, 1); + errors.push(details); + } + logger.error(`feishu[${accountId}]: ${details}`); + }; + const ready = () => { + if (cleanedUp) return; + errors.length = 0; + logger.info(`feishu[${accountId}]: WebSocket connected`); + }; + const wsClient = createLarkWSClient(config, { + onReady: ready, + onReconnected: ready, + onError: recordError, + onReconnecting: () => { + if (!cleanedUp && errors.length === 0) { + errors.push("WebSocket connection closed; reconnecting"); + } + }, + // Retryable discovery/transport errors are logged without firing onError. + logger: { + error: (...args: unknown[]) => recordError(args), + warn: (...args: unknown[]) => + logger.warn(`feishu[${accountId}]: ${describeConnectionError(args)}`), + info: (...args: unknown[]) => + logger.info(`feishu[${accountId}]: ${describeConnectionError(args)}`), + debug: () => {}, + trace: () => {}, + }, + }); const cleanup = () => { if (cleanedUp) return; cleanedUp = true; + abortSignal.removeEventListener("abort", handleAbort); try { - wsClient.close(); + wsClient.close({ force: true }); } catch (err) { logger.warn(`feishu[${accountId}]: error closing WebSocket`, err); } @@ -42,9 +82,37 @@ export function startWSGateway( cleanup(); } else { abortSignal.addEventListener("abort", handleAbort, { once: true }); - void wsClient.start({ eventDispatcher }); - logger.info(`feishu[${accountId}]: WebSocket client started`); + try { + void wsClient.start({ eventDispatcher }).catch(recordError); + } catch (err) { + recordError(err); + } } - return { wsClient, cleanup }; + return { + wsClient, + cleanup, + getStatus(): ChannelStatusResult { + const state = cleanedUp ? "idle" : wsClient.getConnectionStatus().state; + const connected = state === "connected"; + let message = m("status.disconnected", "Disconnected"); + if (connected) { + message = m("status.connected", "Connected"); + } else if (!cleanedUp) { + if (state === "reconnecting") { + message = m("status.reconnecting", "Connection lost; reconnecting…"); + } else if (state === "failed" || errors.length > 0) { + message = m("status.failed", "Connection failed"); + } else if (state === "connecting") { + message = m("status.connecting", "Connecting…"); + } + } + return { + connected, + configured: true, + message, + details: connected || cleanedUp ? undefined : errors.join("\n") || undefined, + }; + }, + }; } diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index e6634db..c01ca48 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -15,13 +15,34 @@ import { startMonitor, type MonitorHandle } from "./gateway/monitor.js"; import { sendText, sendMedia, sendReaction } from "./outbound/send.js"; import { createFeishuCommands } from "./commands/feishu.js"; import { clearClientCache } from "./api/client.js"; +import { describeConnectionError } from "./gateway/connection-error.js"; type FeishuGatewayState = { monitors: Map; + failures: Map; }; // Module-level monitor registry — populated by gateway.start, read by outbound/tools let activeMonitors = new Map(); +let activeFailures = new Map(); + +function getAccountStatus( + accountId: string, + monitors?: Map, + failures?: Map, +): ChannelStatusResult { + const monitor = monitors?.get(accountId); + if (monitor) return monitor.getStatus(); + const details = failures?.get(accountId); + return { + connected: false, + configured: true, + message: details + ? m("status.failed", "Connection failed") + : m("status.disconnected", "Disconnected"), + details, + }; +} function trimRecipientPrefix(to: string): string { const separator = to.indexOf(":"); @@ -124,7 +145,7 @@ export default defineChannel({ auth: createFeishuAuth(), - commands: createFeishuCommands(() => activeMonitors), + commands: createFeishuCommands((id) => getAccountStatus(id, activeMonitors, activeFailures)), gateway: { async start(ctx: GatewayContext) { @@ -134,6 +155,10 @@ export default defineChannel({ const monitors = new Map(); ctx.state.monitors = monitors; + const failures = new Map(); + ctx.state.failures = failures; + activeMonitors = monitors; + activeFailures = failures; // One-time migration: move any legacy authorizedOpenIds into SDK identity // bindings so previously-authorized users keep access under the access gate. @@ -164,15 +189,19 @@ export default defineChannel({ abortSignal: ctx.abortSignal, groupEnabled, }); + if (ctx.abortSignal.aborted || ctx.state.monitors !== monitors) { + handle.cleanup(); + break; + } monitors.set(accountId, handle); } catch (err) { - ctx.logger.error(`Failed to start monitor for account ${accountId}`, err); + if (ctx.abortSignal.aborted || ctx.state.monitors !== monitors) break; + const details = describeConnectionError(err); + failures.set(accountId, details); + ctx.logger.error(`Failed to start monitor for account ${accountId}: ${details}`); } } - // Update module-level reference - activeMonitors = monitors; - ctx.logger.info(`Feishu gateway started with ${monitors.size} account(s)`); }, @@ -185,7 +214,10 @@ export default defineChannel({ handle.cleanup(); } monitors.clear(); + ctx.state.monitors = new Map(); + ctx.state.failures?.clear(); activeMonitors = new Map(); + activeFailures = new Map(); clearClientCache(); }, @@ -195,18 +227,32 @@ export default defineChannel({ }, getStatus(ctx: GatewayContext): ChannelStatusResult { - const monitors = ctx.state.monitors; - if (!monitors || monitors.size === 0) { + const accounts = parseAccountConfigs(ctx.config as unknown as FeishuPluginConfig); + if (accounts.size === 0) { return { connected: false, configured: false, message: m("status.noAccounts", "No accounts configured"), }; } + const statuses = [...accounts.keys()].map((id) => ({ + id, + status: getAccountStatus(id, ctx.state.monitors, ctx.state.failures), + })); + const connectedCount = statuses.filter(({ status }) => status.connected).length; + const details = + statuses + .filter(({ status }) => status.details) + .map(({ id, status }) => `${id}: ${status.details}`) + .join("\n") || undefined; return { - connected: true, + connected: connectedCount > 0, configured: true, - message: m("status.accounts", "Connected accounts: {{count}}", { count: monitors.size }), + message: + connectedCount > 0 + ? m("status.accounts", "Connected accounts: {{count}}", { count: connectedCount }) + : (statuses.find(({ status }) => status.details) ?? statuses[0]).status.message, + details, }; }, }, diff --git a/plugins/feishu/tests/client.test.ts b/plugins/feishu/tests/client.test.ts index c7b09ae..ae3f0d0 100644 --- a/plugins/feishu/tests/client.test.ts +++ b/plugins/feishu/tests/client.test.ts @@ -1,18 +1,13 @@ import type * as lark from "@larksuiteoapi/node-sdk"; -import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import { describe, expect, it, vi } from "vitest"; import { fetchBotOpenId } from "../src/api/client.js"; -function makeLogger(): PluginLogger { - return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; -} - describe("fetchBotOpenId", () => { it("returns the bot open_id from /open-apis/bot/v3/info", async () => { const request = vi.fn(async () => ({ bot: { open_id: "ou_bot" } })); const client = { request } as unknown as lark.Client; - const openId = await fetchBotOpenId(client, makeLogger()); + const openId = await fetchBotOpenId(client); expect(openId).toBe("ou_bot"); expect(request).toHaveBeenCalledWith( @@ -20,21 +15,16 @@ describe("fetchBotOpenId", () => { ); }); - it("returns undefined and warns when the request fails", async () => { + it("preserves request failures for connection diagnostics", async () => { const request = vi.fn(async () => { throw new Error("network"); }); const client = { request } as unknown as lark.Client; - const logger = makeLogger(); - - const openId = await fetchBotOpenId(client, logger); - - expect(openId).toBeUndefined(); - expect(logger.warn).toHaveBeenCalled(); + await expect(fetchBotOpenId(client)).rejects.toThrow("network"); }); it("returns undefined when the response has no bot open_id", async () => { const client = { request: vi.fn(async () => ({})) } as unknown as lark.Client; - expect(await fetchBotOpenId(client, makeLogger())).toBeUndefined(); + expect(await fetchBotOpenId(client)).toBeUndefined(); }); }); diff --git a/plugins/feishu/tests/connection-error.test.ts b/plugins/feishu/tests/connection-error.test.ts new file mode 100644 index 0000000..5d00572 --- /dev/null +++ b/plugins/feishu/tests/connection-error.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { describeConnectionError } from "../src/gateway/connection-error.js"; + +describe("connection diagnostics", () => { + it("keeps error messages and API codes without request credentials", () => { + const error = Object.assign(new Error("Request failed"), { + response: { data: { code: 10013, msg: "app secret invalid" } }, + config: { headers: { Authorization: "private-token" }, appSecret: "private-secret" }, + }); + expect(describeConnectionError(error)).toBe("Request failed; code: 10013; app secret invalid"); + }); + + it("unwraps the SDK logger's nested argument arrays", () => { + expect(describeConnectionError([["[ws]", "code: 514, invalid credentials"]])).toBe( + "code: 514, invalid credentials", + ); + }); + + it.each([undefined, null, {}, new Error(), ["[ws]", " "]])( + "provides a nonempty fallback for an empty error: %s", + (error) => expect(describeConnectionError(error)).toBe("Feishu connection failed"), + ); + + it("bounds long errors and handles circular causes", () => { + const error = new Error("Connection failed"); + error.cause = error; + expect(describeConnectionError(error)).toBe("Connection failed"); + expect(describeConnectionError("x".repeat(2000))).toHaveLength(1000); + }); +}); diff --git a/plugins/feishu/tests/gateway.test.ts b/plugins/feishu/tests/gateway.test.ts new file mode 100644 index 0000000..36dc33b --- /dev/null +++ b/plugins/feishu/tests/gateway.test.ts @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { getEventListeners } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import http from "node:http"; +import type { Socket } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import feishu from "../src/index.js"; +import zhCN from "../locales/zh-CN.json"; + +const gateway = feishu.channel!.gateway!; +type Context = Parameters[0]; + +let directory: string; +let server: http.Server; +let context: Context; +let controller: AbortController; +let mode: "ready" | "invalid-id" | "invalid-secret" | "unavailable"; +let holdHandshake: boolean; +let releaseHandshake: (() => void) | undefined; +let connectionCount: number; +const sockets = new Set(); +const webSockets = new Set(); + +function status() { + return gateway.getStatus!(context); +} + +async function commandReply(args: string) { + const command = feishu.commands![0]; + const result = await command.execute({ config: context.config, args } as Parameters< + typeof command.execute + >[0]); + return resolvePluginText(result?.reply, { "zh-CN": zhCN }, "zh-CN"); +} + +beforeEach(async () => { + mode = "ready"; + holdHandshake = false; + releaseHandshake = undefined; + connectionCount = 0; + directory = await mkdtemp(path.join(os.tmpdir(), "cola-feishu-gateway-test-")); + server = http.createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const input = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + const invalidSecret = + mode === "invalid-secret" || input.app_secret === "invalid" || input.AppSecret === "invalid"; + const error = + mode === "invalid-id" + ? { code: 10014, msg: "app id not exists" } + : invalidSecret + ? { code: 10013, msg: "app secret invalid" } + : undefined; + let body: unknown; + if (request.url?.includes("/auth/")) { + body = error ?? { code: 0, msg: "ok", tenant_access_token: "local-token", expire: 7200 }; + } else if (request.url?.includes("/bot/v3/info")) { + body = { code: 0, msg: "ok", bot: { open_id: "ou_local_bot" } }; + } else if (request.url === "/callback/ws/endpoint") { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing server address"); + body = error + ? { code: 514, msg: error.msg, data: {} } + : mode === "unavailable" + ? { code: 1000040343, msg: "QA simulated connection outage", data: {} } + : { + code: 0, + msg: "ok", + data: { + URL: `ws://127.0.0.1:${address.port}/ws?device_id=qa&service_id=1`, + ClientConfig: { + PingInterval: 60, + ReconnectCount: -1, + ReconnectInterval: 0.05, + ReconnectNonce: 0, + }, + }, + }; + } else { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + server.on("upgrade", (request, socket: Socket) => { + socket.resume(); + socket.on("end", () => socket.end()); + const accept = createHash("sha1") + .update(request.headers["sec-websocket-key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + const open = () => { + if (socket.destroyed) return; + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + webSockets.add(socket); + socket.on("close", () => webSockets.delete(socket)); + connectionCount++; + }; + if (holdHandshake) releaseHandshake = open; + else open(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing server address"); + controller = new AbortController(); + context = { + config: { + pluginDir: directory, + accounts: { + default: { + appId: "cli_0000000000000000", + appSecret: "local-secret", + domain: `http://127.0.0.1:${address.port}`, + }, + }, + }, + state: {}, + runtime: { identity: { bind: vi.fn(), resolve: vi.fn() } }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + abortSignal: controller.signal, + deliver: vi.fn(), + } as unknown as Context; +}); + +afterEach(async () => { + controller.abort(); + await gateway.stop!(context); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + webSockets.clear(); + await rm(directory, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("Feishu connection status with the real SDK", () => { + it.each([ + ["invalid-id", "app id not exists"], + ["invalid-secret", "app secret invalid"], + ] as const)("reports %s without claiming a connection", async (failure, reason) => { + mode = failure; + await gateway.start(context); + expect(status()).toMatchObject({ connected: false, configured: true }); + await vi.waitFor(() => expect(status().details).toContain(reason)); + expect(connectionCount).toBe(0); + expect(resolvePluginText(status().message, { "zh-CN": zhCN }, "zh-CN")).toBe("连接失败"); + expect(await commandReply("status")).toContain("连接失败"); + expect(await commandReply("status")).toContain(reason); + expect(await commandReply("accounts")).toContain("连接失败"); + }); + + it("waits for the WebSocket handshake before reporting connected", async () => { + holdHandshake = true; + await gateway.start(context); + await vi.waitFor(() => expect(releaseHandshake).toBeTypeOf("function")); + expect(status()).toMatchObject({ connected: false, configured: true }); + expect(resolvePluginText(status().message, { "zh-CN": zhCN }, "zh-CN")).toBe("正在连接…"); + releaseHandshake!(); + await vi.waitFor(() => expect(status().connected).toBe(true)); + expect(status().details).toBeUndefined(); + expect(await commandReply("status")).toContain("已连接"); + }); + + it("exposes retryable startup errors that only appear in SDK logs", async () => { + mode = "unavailable"; + await gateway.start(context); + await vi.waitFor(() => expect(status().details).toContain("QA simulated connection outage")); + expect(status()).toMatchObject({ connected: false, configured: true }); + expect(connectionCount).toBe(0); + }); + + it("reports an invalid App ID format without waiting for a connection callback", async () => { + const accounts = context.config.accounts as Record>; + accounts.default.appId = "invalid-app-id"; + await gateway.start(context); + await vi.waitFor(() => expect(status().details).toBeTruthy()); + expect(status()).toMatchObject({ connected: false, configured: true }); + expect(connectionCount).toBe(0); + expect(resolvePluginText(status().message, { "zh-CN": zhCN }, "zh-CN")).toBe("连接失败"); + }); + + it("reports a dropped connection and clears diagnostics after recovery", async () => { + await gateway.start(context); + await vi.waitFor(() => expect(connectionCount).toBe(1)); + await vi.waitFor(() => expect(status().connected).toBe(true)); + mode = "unavailable"; + for (const socket of webSockets) socket.destroy(); + await vi.waitFor(() => expect(status().connected).toBe(false)); + await vi.waitFor(() => expect(status().details).toContain("QA simulated connection outage")); + expect(resolvePluginText(status().message, { "zh-CN": zhCN }, "zh-CN")).toBe( + "连接已断开,正在重连…", + ); + mode = "ready"; + await vi.waitFor(() => expect(status().connected).toBe(true)); + expect(status().details).toBeUndefined(); + expect(connectionCount).toBe(2); + }); + + it("counts only connected accounts and reports failures alongside healthy accounts", async () => { + const accounts = context.config.accounts as Record>; + accounts.broken = { ...accounts.default, appSecret: "invalid" }; + await gateway.start(context); + await vi.waitFor(() => expect(connectionCount).toBe(1)); + await vi.waitFor(() => expect(status().details).toContain("broken: ")); + expect(status()).toMatchObject({ connected: true, configured: true }); + expect(status().details).toContain("app secret invalid"); + expect(resolvePluginText(status().message, { "zh-CN": zhCN }, "zh-CN")).toBe("已连接账号数:1"); + }); + + it("cleans up an interrupted handshake without a late connection", async () => { + holdHandshake = true; + await gateway.start(context); + await vi.waitFor(() => expect(releaseHandshake).toBeTypeOf("function")); + await gateway.stop!(context); + releaseHandshake!(); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + expect(status().connected).toBe(false); + await vi.waitFor(() => expect(webSockets.size).toBe(0)); + }); + + it("replaces a failed connection after updating credentials", async () => { + const accounts = context.config.accounts as Record>; + accounts.default.appSecret = "invalid"; + await gateway.start(context); + await vi.waitFor(() => expect(status().details).toContain("app secret invalid")); + accounts.default.appSecret = "corrected-secret"; + await gateway.reload!(context); + await vi.waitFor(() => expect(status().connected).toBe(true)); + expect(status().details).toBeUndefined(); + expect(connectionCount).toBe(1); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(1); + }); + + it("does not start a connection after cancellation", async () => { + controller.abort(); + await gateway.start(context); + expect(status()).toMatchObject({ connected: false, configured: true }); + expect(status().details).toBeUndefined(); + expect(connectionCount).toBe(0); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38fb8b0..571f32b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: plugins/feishu: dependencies: '@larksuiteoapi/node-sdk': - specifier: ^1.61.1 - version: 1.66.0 + specifier: 1.73.3 + version: 1.73.3 '@marswave/cola-plugin-sdk': specifier: 0.0.5 version: 0.0.5 @@ -390,11 +390,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@larksuiteoapi/node-sdk@1.66.0': - resolution: {integrity: sha512-ueKbbdvmVGVie3KvKbvHZqvDC/gg3M0rRDeyQanQWK+i2bQgiiTpIfpqVWvxuTgprV31yqV7HPMjN6KegWSCfA==} - - '@marswave/cola-plugin-sdk@0.0.3': - resolution: {integrity: sha512-oO1rCwUXZqKlJdE80VclGmsMKW/yU6BbgKcHAnWR0RzksEder56QWSoH7ckAAmWnw4BY2ATFlRWw5BRLMVYIgA==} + '@larksuiteoapi/node-sdk@1.73.3': + resolution: {integrity: sha512-14Zj8r3f5CEIvpRuzb5HSYyp6O092N1kQkGHg6Kwi/c7B0CmXeCHBVM3Pmn0ozP5JvvAQd/PXBzW48rMH6cj4Q==} '@marswave/cola-plugin-sdk@0.0.5': resolution: {integrity: sha512-1KVGbtUf4Yj6yK2pn0KsoFiSi5cc53/QUJ+/U1MQpT2zQaXb1KLndJP7VUlp6SlqmnpLjzGok+8CtIxKZQBqbg==} @@ -887,9 +884,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - axios@1.18.0: resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} @@ -1223,9 +1217,6 @@ packages: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -1608,9 +1599,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@larksuiteoapi/node-sdk@1.66.0': + '@larksuiteoapi/node-sdk@1.73.3': dependencies: - axios: 1.13.6 + axios: 1.18.0 lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -1620,10 +1611,9 @@ snapshots: transitivePeerDependencies: - bufferutil - debug + - supports-color - utf-8-validate - '@marswave/cola-plugin-sdk@0.0.3': {} - '@marswave/cola-plugin-sdk@0.0.5': {} '@oxfmt/binding-android-arm-eabi@0.42.0': @@ -1942,14 +1932,6 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.6: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.18.0: dependencies: follow-redirects: 1.16.0 @@ -2326,8 +2308,6 @@ snapshots: '@types/node': 22.19.17 long: 5.3.2 - proxy-from-env@1.1.0: {} - proxy-from-env@2.1.0: {} qs@6.15.1: