mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:54:47 +00:00
fix(feishu): make manual setup the default
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user