fix(feishu): make manual setup the default

This commit is contained in:
Peter Steinberger
2026-05-11 13:29:03 +01:00
parent 2838eb4d8e
commit da7cc2b11c
5 changed files with 188 additions and 31 deletions

View File

@@ -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.

View File

@@ -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.
</Step>
<Step title="After setup completes, restart the gateway to apply the changes">
@@ -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

View File

@@ -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<PollOutcome> {
const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params;

View File

@@ -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<FeishuProbeResult>>(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 () => {

View File

@@ -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<NonNullable<ChannelSetupWizard["finalize"]>>[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<AppRegistrationResult | null> {
async function promptFeishuDomain(params: {
prompter: WizardPrompter;
initialValue?: FeishuDomain;
}): Promise<FeishuDomain> {
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<FeishuSetupMethod> {
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<AppRegistrationResult | null> {
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<AppRegistratio
return null;
}
const begin = await beginAppRegistration("feishu");
const begin = await beginAppRegistration(domain);
await prompter.note("Scan the QR with Lark/Feishu on your phone.", "Feishu scan-to-create");
await prompter.note(
"Scan the QR with Lark/Feishu on your phone. If the mobile app does not react, rerun setup and choose manual input.",
"Feishu scan-to-create",
);
await printQrCode(begin.qrUrl);
const progress = prompter.progress("Fetching configuration results...");
@@ -269,8 +302,8 @@ async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistratio
deviceCode: begin.deviceCode,
interval: begin.interval,
expireIn: begin.expireIn,
initialDomain: "feishu",
tp: "ob_app",
initialDomain: domain,
tp: SCAN_TO_CREATE_TP,
});
switch (outcome.status) {
@@ -314,8 +347,17 @@ async function runNewAppFlow(params: {
let appSecretProbeValue: string | null = null;
let scanDomain: FeishuDomain | undefined;
let scanOpenId: string | undefined;
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
const currentDomain = feishuCfg?.domain ?? "feishu";
const setupMethod = await promptFeishuSetupMethod(prompter);
const selectedDomain = await promptFeishuDomain({
prompter,
initialValue: currentDomain,
});
scanDomain = selectedDomain;
const scanResult = await runScanToCreate(prompter);
const scanResult =
setupMethod === "scan" ? await runScanToCreate(prompter, selectedDomain) : null;
if (scanResult) {
appId = scanResult.appId;
appSecret = scanResult.appSecret;
@@ -324,21 +366,8 @@ async function runNewAppFlow(params: {
scanOpenId = scanResult.openId;
} else {
// Fallback to manual input: collect domain, appId, appSecret.
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
await noteFeishuCredentialHelp(prompter);
// Domain selection first (needed for API calls).
const currentDomain = feishuCfg?.domain ?? "feishu";
const domain = (await prompter.select({
message: "Which Feishu domain?",
options: [
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
{ value: "lark", label: "Lark (larksuite.com) - International" },
],
initialValue: currentDomain,
})) as FeishuDomain;
scanDomain = domain;
appId = await promptFeishuAppId({
prompter,
initialValue: normalizeString(process.env.FEISHU_APP_ID),
@@ -369,7 +398,7 @@ async function runNewAppFlow(params: {
scanOpenId = await getAppOwnerOpenId({
appId,
appSecret: appSecretProbeValue,
domain: scanDomain,
domain: selectedDomain,
});
}
}