diff --git a/CHANGELOG.md b/CHANGELOG.md index f31040b7e65..4282d9a4e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao. - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 8620c8e673b..04fedd65820 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -23,7 +23,7 @@ Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade ```bash openclaw channels login --channel feishu ``` - Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically. + Choose manual setup to paste an App ID and App Secret from Feishu Open Platform, or choose QR setup to create a bot automatically. If the domestic Feishu mobile app does not react to the QR code, rerun setup and choose manual setup. @@ -211,6 +211,13 @@ Feishu/Lark does not support native slash-command menus, so send these as plain 5. Ensure the gateway is running: `openclaw gateway status` 6. Check logs: `openclaw logs --follow` +### QR setup does not react in the Feishu mobile app + +1. Rerun setup: `openclaw channels login --channel feishu` +2. Choose manual setup +3. In Feishu Open Platform, create a self-built app and copy its App ID and App Secret +4. Paste those credentials into the setup wizard + ### App Secret leaked 1. Reset the App Secret in Feishu Open Platform / Lark Developer diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index af7463735cd..e39e5beb476 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -167,7 +167,7 @@ export async function pollAppRegistration(params: { expireIn: number; initialDomain?: FeishuDomain; abortSignal?: AbortSignal; - /** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */ + /** Registration type parameter. The CLI bot QR flow uses "ob_cli_app". */ tp?: string; }): Promise { const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params; diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index a18dfa2648a..74cd93a5355 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -8,7 +8,19 @@ import { import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuProbeResult } from "./types.js"; -const { probeFeishuMock } = vi.hoisted(() => ({ +const { + beginAppRegistrationMock, + getAppOwnerOpenIdMock, + initAppRegistrationMock, + pollAppRegistrationMock, + printQrCodeMock, + probeFeishuMock, +} = vi.hoisted(() => ({ + beginAppRegistrationMock: vi.fn(), + getAppOwnerOpenIdMock: vi.fn(), + initAppRegistrationMock: vi.fn(), + pollAppRegistrationMock: vi.fn(), + printQrCodeMock: vi.fn(), probeFeishuMock: vi.fn<() => Promise>(async () => ({ ok: false, error: "mocked", @@ -20,13 +32,11 @@ vi.mock("./probe.js", () => ({ })); vi.mock("./app-registration.js", () => ({ - initAppRegistration: vi.fn(async () => { - throw new Error("mocked: scan-to-create not available"); - }), - beginAppRegistration: vi.fn(), - pollAppRegistration: vi.fn(), - printQrCode: vi.fn(async () => {}), - getAppOwnerOpenId: vi.fn(async () => undefined), + initAppRegistration: initAppRegistrationMock, + beginAppRegistration: beginAppRegistrationMock, + pollAppRegistration: pollAppRegistrationMock, + printQrCode: printQrCodeMock, + getAppOwnerOpenId: getAppOwnerOpenIdMock, })); import { feishuPlugin } from "./channel.js"; @@ -86,6 +96,116 @@ describe("feishu setup wizard", () => { beforeEach(() => { probeFeishuMock.mockReset(); probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" }); + initAppRegistrationMock.mockReset(); + initAppRegistrationMock.mockRejectedValue(new Error("mocked: scan-to-create not available")); + beginAppRegistrationMock.mockReset(); + pollAppRegistrationMock.mockReset(); + printQrCodeMock.mockReset(); + printQrCodeMock.mockResolvedValue(undefined); + getAppOwnerOpenIdMock.mockReset(); + getAppOwnerOpenIdMock.mockResolvedValue(undefined); + }); + + it("uses manual credentials by default instead of starting scan-to-create", async () => { + const text = vi.fn().mockResolvedValueOnce("cli_manual").mockResolvedValueOnce("secret_manual"); + const prompter = createTestWizardPrompter({ text }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).not.toHaveBeenCalled(); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_manual", + appSecret: "secret_manual", + connectionMode: "websocket", + domain: "feishu", + }); + }); + + it("passes selected domain through scan-to-create and poll", async () => { + initAppRegistrationMock.mockResolvedValueOnce(undefined); + beginAppRegistrationMock.mockResolvedValueOnce({ + deviceCode: "device-code", + qrUrl: "https://accounts.larksuite.com/qr", + userCode: "user-code", + interval: 1, + expireIn: 10, + }); + pollAppRegistrationMock.mockResolvedValueOnce({ + status: "success", + result: { + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + openId: "ou_owner", + }, + }); + const prompter = createTestWizardPrompter({ + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("lark") + .mockResolvedValueOnce("open") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(beginAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(pollAppRegistrationMock).toHaveBeenCalledWith( + expect.objectContaining({ + deviceCode: "device-code", + initialDomain: "lark", + tp: "ob_cli_app", + }), + ); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + groupPolicy: "open", + requireMention: true, + }); + }); + + it("falls back to manual credentials when selected scan-to-create is unavailable", async () => { + const text = vi + .fn() + .mockResolvedValueOnce("cli_from_fallback") + .mockResolvedValueOnce("secret_from_fallback"); + const prompter = createTestWizardPrompter({ + text, + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("feishu") + .mockResolvedValueOnce("allowlist") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("feishu"); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_from_fallback", + appSecret: "secret_from_fallback", + domain: "feishu", + }); }); it("prompts over SecretRef appId/appSecret config objects", async () => { diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 5f4fc8008c3..8f02da89563 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -17,6 +17,7 @@ import type { AppRegistrationResult } from "./app-registration.js"; import type { FeishuConfig, FeishuDomain } from "./types.js"; const channel = "feishu" as const; +const SCAN_TO_CREATE_TP = "ob_cli_app"; // --------------------------------------------------------------------------- // Helpers @@ -213,6 +214,7 @@ const feishuDmPolicy: ChannelSetupDmPolicy = { }; type WizardPrompter = Parameters>[0]["prompter"]; +type FeishuSetupMethod = "manual" | "scan"; // --------------------------------------------------------------------------- // Security policy helpers @@ -245,11 +247,39 @@ function applyNewAppSecurityPolicy( // Scan-to-create flow // --------------------------------------------------------------------------- -async function runScanToCreate(prompter: WizardPrompter): Promise { +async function promptFeishuDomain(params: { + prompter: WizardPrompter; + initialValue?: FeishuDomain; +}): Promise { + return (await params.prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: params.initialValue ?? "feishu", + })) as FeishuDomain; +} + +async function promptFeishuSetupMethod(prompter: WizardPrompter): Promise { + return (await prompter.select({ + message: "How do you want to connect Feishu?", + options: [ + { value: "manual", label: "Enter App ID and App Secret manually" }, + { value: "scan", label: "Scan a QR code to create a bot automatically" }, + ], + initialValue: "manual", + })) as FeishuSetupMethod; +} + +async function runScanToCreate( + prompter: WizardPrompter, + domain: FeishuDomain, +): Promise { const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } = await import("./app-registration.js"); try { - await initAppRegistration("feishu"); + await initAppRegistration(domain); } catch { await prompter.note( "Scan-to-create is not available in this environment. Falling back to manual input.", @@ -258,9 +288,12 @@ async function runScanToCreate(prompter: WizardPrompter): Promise