mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:40:43 +00:00
feat: Streamline Feishu channel onboarding with QR code scan-to-create flow (#65680)
Add QR-based app registration, improve Feishu onboarding flows, support direct login entry, add group chat policy setup, reduce log noise, and update docs.
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.60.0",
|
||||
"@sinclair/typebox": "0.34.49"
|
||||
"@sinclair/typebox": "0.34.49",
|
||||
"qrcode-terminal": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
|
||||
309
extensions/feishu/src/app-registration.ts
Normal file
309
extensions/feishu/src/app-registration.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Feishu app registration via OAuth device-code flow.
|
||||
*
|
||||
* Migrated from feishu-plugin-cli's `feishu-auth.ts` and `install-prompts.ts`.
|
||||
* Replaces axios with native fetch, removes inquirer/ora/chalk in favor of
|
||||
* the openclaw WizardPrompter surface.
|
||||
*/
|
||||
|
||||
import type { FeishuDomain } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FEISHU_ACCOUNTS_URL = "https://accounts.feishu.cn";
|
||||
const LARK_ACCOUNTS_URL = "https://accounts.larksuite.com";
|
||||
|
||||
const REGISTRATION_PATH = "/oauth/v1/app/registration";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppRegistrationResult {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
domain: FeishuDomain;
|
||||
openId?: string;
|
||||
}
|
||||
|
||||
interface InitResponse {
|
||||
nonce: string;
|
||||
supported_auth_methods: string[];
|
||||
}
|
||||
|
||||
export interface BeginResult {
|
||||
deviceCode: string;
|
||||
qrUrl: string;
|
||||
userCode: string;
|
||||
interval: number;
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface RawBeginResponse {
|
||||
device_code: string;
|
||||
verification_uri: string;
|
||||
user_code: string;
|
||||
verification_uri_complete: string;
|
||||
interval: number;
|
||||
expire_in: number;
|
||||
}
|
||||
|
||||
interface PollResponse {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
user_info?: {
|
||||
open_id?: string;
|
||||
tenant_brand?: "feishu" | "lark";
|
||||
};
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export type PollOutcome =
|
||||
| { status: "success"; result: AppRegistrationResult }
|
||||
| { status: "access_denied" }
|
||||
| { status: "expired" }
|
||||
| { status: "timeout" }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function accountsBaseUrl(domain: FeishuDomain): string {
|
||||
return domain === "lark" ? LARK_ACCOUNTS_URL : FEISHU_ACCOUNTS_URL;
|
||||
}
|
||||
|
||||
async function postRegistration<T>(baseUrl: string, body: Record<string, string>): Promise<T> {
|
||||
const response = await fetch(`${baseUrl}${REGISTRATION_PATH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
// The poll endpoint returns 4xx for pending/error states with a JSON body.
|
||||
const data = (await response.json()) as T;
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Step 1: Initialize registration and verify the environment supports
|
||||
* `client_secret` auth.
|
||||
*
|
||||
* @throws If the environment does not support `client_secret`.
|
||||
*/
|
||||
export async function initAppRegistration(domain: FeishuDomain = "feishu"): Promise<void> {
|
||||
const baseUrl = accountsBaseUrl(domain);
|
||||
const res = await postRegistration<InitResponse>(baseUrl, { action: "init" });
|
||||
|
||||
if (!res.supported_auth_methods?.includes("client_secret")) {
|
||||
throw new Error("Current environment does not support client_secret auth method");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Begin the device-code flow. Returns a device code and a QR URL
|
||||
* that the user should scan with Feishu/Lark mobile app.
|
||||
*/
|
||||
export async function beginAppRegistration(domain: FeishuDomain = "feishu"): Promise<BeginResult> {
|
||||
const baseUrl = accountsBaseUrl(domain);
|
||||
const res = await postRegistration<RawBeginResponse>(baseUrl, {
|
||||
action: "begin",
|
||||
archetype: "PersonalAgent",
|
||||
auth_method: "client_secret",
|
||||
request_user_info: "open_id",
|
||||
});
|
||||
|
||||
const qrUrl = new URL(res.verification_uri_complete);
|
||||
qrUrl.searchParams.set("from", "oc_onboard");
|
||||
qrUrl.searchParams.set("tp", "ob_cli_app");
|
||||
|
||||
return {
|
||||
deviceCode: res.device_code,
|
||||
qrUrl: qrUrl.toString(),
|
||||
userCode: res.user_code,
|
||||
interval: res.interval || 5,
|
||||
expireIn: res.expire_in || 600,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Poll for authorization result until success, denial, expiry, or
|
||||
* timeout. Automatically handles domain switching when `tenant_brand` is
|
||||
* detected as "lark".
|
||||
*/
|
||||
export async function pollAppRegistration(params: {
|
||||
deviceCode: string;
|
||||
interval: number;
|
||||
expireIn: number;
|
||||
initialDomain?: FeishuDomain;
|
||||
abortSignal?: AbortSignal;
|
||||
/** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */
|
||||
tp?: string;
|
||||
}): Promise<PollOutcome> {
|
||||
const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params;
|
||||
let currentInterval = params.interval;
|
||||
let domain: FeishuDomain = initialDomain;
|
||||
let domainSwitched = false;
|
||||
|
||||
const deadline = Date.now() + expireIn * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (abortSignal?.aborted) {
|
||||
return { status: "timeout" };
|
||||
}
|
||||
|
||||
const baseUrl = accountsBaseUrl(domain);
|
||||
|
||||
let pollRes: PollResponse;
|
||||
try {
|
||||
pollRes = await postRegistration<PollResponse>(baseUrl, {
|
||||
action: "poll",
|
||||
device_code: deviceCode,
|
||||
...(tp ? { tp } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Transient network error — keep polling.
|
||||
await sleep(currentInterval * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Domain auto-detection: switch to lark if tenant_brand says so.
|
||||
if (pollRes.user_info?.tenant_brand) {
|
||||
const isLark = pollRes.user_info.tenant_brand === "lark";
|
||||
if (!domainSwitched && isLark) {
|
||||
domain = "lark";
|
||||
domainSwitched = true;
|
||||
// Retry poll immediately with the correct domain.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Success.
|
||||
if (pollRes.client_id && pollRes.client_secret) {
|
||||
return {
|
||||
status: "success",
|
||||
result: {
|
||||
appId: pollRes.client_id,
|
||||
appSecret: pollRes.client_secret,
|
||||
domain,
|
||||
openId: pollRes.user_info?.open_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Error handling.
|
||||
if (pollRes.error) {
|
||||
if (pollRes.error === "authorization_pending") {
|
||||
// Continue waiting.
|
||||
} else if (pollRes.error === "slow_down") {
|
||||
currentInterval += 5;
|
||||
} else if (pollRes.error === "access_denied") {
|
||||
return { status: "access_denied" };
|
||||
} else if (pollRes.error === "expired_token") {
|
||||
return { status: "expired" };
|
||||
} else {
|
||||
return {
|
||||
status: "error",
|
||||
message: `${pollRes.error}: ${pollRes.error_description ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(currentInterval * 1000);
|
||||
}
|
||||
|
||||
return { status: "timeout" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Print QR code directly to stdout.
|
||||
*
|
||||
* QR codes must be printed without any surrounding box/border decoration,
|
||||
* otherwise the pattern is corrupted and cannot be scanned.
|
||||
*/
|
||||
export async function printQrCode(url: string): Promise<void> {
|
||||
const mod = await import("qrcode-terminal");
|
||||
const qrcode = mod.default ?? mod;
|
||||
qrcode.generate(url, { small: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the app owner's open_id using the application.v6.application.get API.
|
||||
*
|
||||
* Used during setup to auto-populate security policy allowlists.
|
||||
* Returns undefined on any failure (fail-open).
|
||||
*/
|
||||
export async function getAppOwnerOpenId(params: {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
domain?: FeishuDomain;
|
||||
}): Promise<string | undefined> {
|
||||
const baseUrl =
|
||||
params.domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
|
||||
|
||||
try {
|
||||
// First, get a tenant_access_token.
|
||||
const tokenRes = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ app_id: params.appId, app_secret: params.appSecret }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
code?: number;
|
||||
tenant_access_token?: string;
|
||||
};
|
||||
if (!tokenData.tenant_access_token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Query app info for the owner's open_id.
|
||||
const appRes = await fetch(
|
||||
`${baseUrl}/open-apis/application/v6/applications/${params.appId}?user_id_type=open_id`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.tenant_access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
const appData = (await appRes.json()) as {
|
||||
code?: number;
|
||||
data?: {
|
||||
app?: {
|
||||
owner?: { owner_id?: string; owner_type?: number; type?: number };
|
||||
creator_id?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (appData.code !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const app = appData.data?.app;
|
||||
const owner = app?.owner;
|
||||
const ownerType = owner?.owner_type ?? owner?.type;
|
||||
// owner_type=2 means enterprise member; use owner_id. Otherwise fallback to creator_id.
|
||||
return ownerType === 2 && owner?.owner_id
|
||||
? owner.owner_id
|
||||
: (app?.creator_id ?? owner?.owner_id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -730,5 +730,5 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
},
|
||||
});
|
||||
|
||||
api.logger.info?.("feishu_bitable: Registered bitable tools");
|
||||
api.logger.debug?.("feishu_bitable: Registered bitable tools");
|
||||
}
|
||||
|
||||
@@ -1091,6 +1091,18 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
fallbackTo,
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg }) => {
|
||||
const { createClackPrompter } = await import("openclaw/plugin-sdk/feishu");
|
||||
const { writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
|
||||
const prompter = createClackPrompter();
|
||||
const { runFeishuLogin } = await import("./setup-surface.js");
|
||||
const nextCfg = await runFeishuLogin({ cfg, prompter });
|
||||
if (nextCfg !== cfg) {
|
||||
await writeConfigFile(nextCfg);
|
||||
}
|
||||
},
|
||||
},
|
||||
setup: feishuSetupAdapter,
|
||||
setupWizard: feishuSetupWizard,
|
||||
messaging: {
|
||||
|
||||
@@ -189,5 +189,5 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
{ name: "feishu_chat" },
|
||||
);
|
||||
|
||||
api.logger.info?.("feishu_chat: Registered feishu_chat tool");
|
||||
api.logger.debug?.("feishu_chat: Registered feishu_chat tool");
|
||||
}
|
||||
|
||||
@@ -1617,6 +1617,6 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
}
|
||||
|
||||
if (registered.length > 0) {
|
||||
api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
|
||||
api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,5 +845,5 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
{ name: "feishu_drive" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_drive: Registered feishu_drive tool`);
|
||||
api.logger.debug?.(`feishu_drive: Registered feishu_drive tool`);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ export async function fetchBotIdentityForMonitor(
|
||||
return { botOpenId: result.botOpenId, botName: result.botName };
|
||||
}
|
||||
|
||||
if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) {
|
||||
const probeError = result.error ?? undefined;
|
||||
if (options.abortSignal?.aborted || isAbortErrorMessage(probeError)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isTimeoutErrorMessage(result.error)) {
|
||||
if (isTimeoutErrorMessage(probeError)) {
|
||||
const error = options.runtime?.error ?? console.error;
|
||||
error(
|
||||
`feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
|
||||
|
||||
@@ -171,5 +171,5 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
{ name: "feishu_perm" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_perm: Registered feishu_perm tool`);
|
||||
api.logger.debug?.(`feishu_perm: Registered feishu_perm tool`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
|
||||
import {
|
||||
@@ -6,45 +5,28 @@ import {
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
import { feishuSetupAdapter } from "./setup-core.js";
|
||||
import { feishuSetupWizard } from "./setup-surface.js";
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
|
||||
const baseStatusContext = {
|
||||
accountOverrides: {},
|
||||
};
|
||||
|
||||
const feishuSetupPlugin = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu/Lark (飞书)",
|
||||
docsPath: "/channels/feishu",
|
||||
blurb: "飞书/Lark enterprise messaging.",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg: unknown) => listFeishuAccountIds(cfg as never),
|
||||
defaultAccountId: (cfg: unknown) => resolveDefaultFeishuAccountId(cfg as never),
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveFeishuAccount),
|
||||
},
|
||||
setup: feishuSetupAdapter,
|
||||
setupWizard: feishuSetupWizard,
|
||||
} as const;
|
||||
|
||||
async function withEnvVars(values: Record<string, string | undefined>, run: () => Promise<void>) {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
@@ -83,54 +65,21 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
|
||||
});
|
||||
}
|
||||
|
||||
const feishuConfigure = createPluginSetupWizardConfigure(feishuSetupPlugin);
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuSetupPlugin);
|
||||
const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin);
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
|
||||
type FeishuConfigureRuntime = Parameters<typeof feishuConfigure>[0]["runtime"];
|
||||
|
||||
describe("feishu setup wizard", () => {
|
||||
it("setup adapter preserves a selected named account id", () => {
|
||||
expect(
|
||||
feishuSetupPlugin.setup?.resolveAccountId?.({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
input: {},
|
||||
} as never),
|
||||
).toBe("work");
|
||||
});
|
||||
|
||||
it("setup adapter uses configured defaultAccount when accountId is omitted", () => {
|
||||
expect(
|
||||
feishuSetupPlugin.setup?.resolveAccountId?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: undefined,
|
||||
input: {},
|
||||
} as never),
|
||||
).toBe("work");
|
||||
});
|
||||
|
||||
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
|
||||
const text = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("cli_from_prompt")
|
||||
.mockResolvedValueOnce("secret_from_prompt")
|
||||
.mockResolvedValueOnce("oc_group_1");
|
||||
.mockResolvedValueOnce("secret_from_prompt");
|
||||
const prompter = createTestWizardPrompter({
|
||||
text,
|
||||
confirm: vi.fn(async () => true),
|
||||
select: vi.fn(
|
||||
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
|
||||
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "bot",
|
||||
) as never,
|
||||
});
|
||||
|
||||
@@ -150,131 +99,6 @@ describe("feishu setup wizard", () => {
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("writes selected-account credentials instead of overwriting the channel root", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Enter Feishu App Secret") {
|
||||
return "work-secret"; // pragma: allowlist secret
|
||||
}
|
||||
if (message === "Enter Feishu App ID") {
|
||||
return "work-app";
|
||||
}
|
||||
if (message === "Group chat allowlist (chat_ids)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
select: vi.fn(
|
||||
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "websocket",
|
||||
) as never,
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: feishuConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "top-level-app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
prompter,
|
||||
accountOverrides: {
|
||||
feishu: "work",
|
||||
},
|
||||
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
|
||||
});
|
||||
|
||||
expect(result.cfg.channels?.feishu?.appId).toBe("top-level-app");
|
||||
expect(result.cfg.channels?.feishu?.appSecret).toBe("top-level-secret");
|
||||
expect(result.cfg.channels?.feishu?.accounts?.work).toMatchObject({
|
||||
enabled: true,
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted finalize writes", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Enter Feishu App Secret") {
|
||||
return "work-secret"; // pragma: allowlist secret
|
||||
}
|
||||
if (message === "Enter Feishu App ID") {
|
||||
return "work-app";
|
||||
}
|
||||
if (message === "Feishu webhook path") {
|
||||
return "/feishu/events";
|
||||
}
|
||||
if (message === "Group chat allowlist (chat_ids)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
select: vi.fn(
|
||||
async ({ message, initialValue }: { message: string; initialValue?: string }) => {
|
||||
if (message === "Feishu connection mode") {
|
||||
return initialValue ?? "websocket";
|
||||
}
|
||||
if (message === "Which Feishu domain?") {
|
||||
return initialValue ?? "feishu";
|
||||
}
|
||||
if (message === "Group chat policy") {
|
||||
return "disabled";
|
||||
}
|
||||
return initialValue ?? "websocket";
|
||||
},
|
||||
) as never,
|
||||
note: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const setupWizard = feishuSetupPlugin.setupWizard;
|
||||
if (!setupWizard || !("finalize" in setupWizard) || !setupWizard.finalize) {
|
||||
throw new Error("feishu setupWizard.finalize unavailable");
|
||||
}
|
||||
|
||||
const result = await setupWizard.finalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "top-level-app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "work",
|
||||
credentialValues: {},
|
||||
forceAllowFrom: false,
|
||||
prompter,
|
||||
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result && typeof result === "object" && "cfg" in result).toBe(true);
|
||||
const nextCfg =
|
||||
result && typeof result === "object" && "cfg" in result ? result.cfg : undefined;
|
||||
expect(nextCfg?.channels?.feishu).toBeDefined();
|
||||
expect(nextCfg?.channels?.feishu?.appId).toBe("top-level-app");
|
||||
expect(nextCfg?.channels?.feishu?.appSecret).toBe("top-level-secret");
|
||||
expect(nextCfg?.channels?.feishu?.accounts?.work).toMatchObject({
|
||||
enabled: true,
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
@@ -319,97 +143,6 @@ describe("feishu setup wizard status", () => {
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("setup status honors the selected named account", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "top_level_app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountOverrides: {
|
||||
feishu: "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "work",
|
||||
appId: "top_level_app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
alerts: {
|
||||
appId: "alerts-app",
|
||||
appSecret: "alerts-secret", // pragma: allowlist secret
|
||||
},
|
||||
work: {
|
||||
appId: "",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
allowFrom: ["ou_root"],
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["ou_work"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
expect(feishuSetupWizard.dmPolicy?.getCurrent?.(cfg as never)).toBe("allowlist");
|
||||
expect(feishuSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg as never)).toEqual({
|
||||
policyKey: "channels.feishu.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.feishu.accounts.work.allowFrom",
|
||||
});
|
||||
|
||||
const next = feishuSetupWizard.dmPolicy?.setPolicy?.(cfg as never, "open");
|
||||
const workAccount = next?.channels?.feishu?.accounts?.work as
|
||||
| {
|
||||
dmPolicy?: string;
|
||||
allowFrom?: string[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(next?.channels?.feishu?.dmPolicy).toBeUndefined();
|
||||
expect(next?.channels?.feishu?.allowFrom).toEqual(["ou_root"]);
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
expect(workAccount?.allowFrom).toEqual(["ou_work", "*"]);
|
||||
});
|
||||
|
||||
it("treats env SecretRef appId as not configured when env var is missing", async () => {
|
||||
const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST";
|
||||
const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
buildSingleChannelSecretPromptState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
@@ -9,31 +8,82 @@ import {
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { inspectFeishuCredentials, resolveDefaultFeishuAccountId } from "./accounts.js";
|
||||
import {
|
||||
inspectFeishuCredentials,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
import { normalizeString } from "./comment-shared.js";
|
||||
beginAppRegistration,
|
||||
getAppOwnerOpenId,
|
||||
initAppRegistration,
|
||||
pollAppRegistration,
|
||||
printQrCode,
|
||||
type AppRegistrationResult,
|
||||
} from "./app-registration.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import type { FeishuAccountConfig, FeishuConfig } from "./types.js";
|
||||
import type { FeishuConfig, FeishuDomain } from "./types.js";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
type ScopedFeishuConfig = Partial<FeishuConfig> & Partial<FeishuAccountConfig>;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return feishuCfg ?? {};
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return feishuCfg?.accounts?.[accountId] ?? {};
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
const isAppIdConfigured = (value: unknown): boolean => {
|
||||
const asString = normalizeString(value);
|
||||
if (asString) {
|
||||
return true;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const rec = value as Record<string, unknown>;
|
||||
const source = normalizeString(rec.source)?.toLowerCase();
|
||||
const id = normalizeString(rec.id);
|
||||
if (source === "env" && id) {
|
||||
return Boolean(normalizeString(process.env[id]));
|
||||
}
|
||||
return hasConfiguredSecretInput(value);
|
||||
};
|
||||
|
||||
const topLevelConfigured =
|
||||
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
|
||||
const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
|
||||
if (!account || typeof account !== "object") {
|
||||
return false;
|
||||
}
|
||||
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
|
||||
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
|
||||
const accountAppIdConfigured = hasOwnAppId
|
||||
? isAppIdConfigured((account as Record<string, unknown>).appId)
|
||||
: isAppIdConfigured(feishuCfg?.appId);
|
||||
const accountSecretConfigured = hasOwnAppSecret
|
||||
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
||||
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
return accountAppIdConfigured && accountSecretConfigured;
|
||||
});
|
||||
|
||||
return topLevelConfigured || accountConfigured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch feishu config at the correct location based on accountId.
|
||||
* - DEFAULT_ACCOUNT_ID → writes to top-level channels.feishu
|
||||
* - named account → writes to channels.feishu.accounts[accountId]
|
||||
*/
|
||||
function patchFeishuConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
@@ -66,85 +116,20 @@ function patchFeishuConfig(
|
||||
});
|
||||
}
|
||||
|
||||
function setFeishuAllowFrom(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): OpenClawConfig {
|
||||
return patchFeishuConfig(cfg, accountId, { allowFrom });
|
||||
}
|
||||
|
||||
function setFeishuGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): OpenClawConfig {
|
||||
return patchFeishuConfig(cfg, accountId, { groupPolicy });
|
||||
}
|
||||
|
||||
function setFeishuGroupAllowFrom(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupAllowFrom: string[],
|
||||
): OpenClawConfig {
|
||||
return patchFeishuConfig(cfg, accountId, { groupAllowFrom });
|
||||
}
|
||||
|
||||
function isFeishuConfigured(cfg: OpenClawConfig, accountId?: string | null): boolean {
|
||||
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
|
||||
const resolvedAccountId = normalizeString(accountId) ?? resolveDefaultFeishuAccountId(cfg);
|
||||
|
||||
const isAppIdConfigured = (value: unknown): boolean => {
|
||||
const asString = normalizeString(value);
|
||||
if (asString) {
|
||||
return true;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const rec = value as Record<string, unknown>;
|
||||
const source = normalizeOptionalLowercaseString(normalizeString(rec.source));
|
||||
const id = normalizeString(rec.id);
|
||||
if (source === "env" && id) {
|
||||
return Boolean(normalizeString(process.env[id]));
|
||||
}
|
||||
return hasConfiguredSecretInput(value);
|
||||
};
|
||||
|
||||
const topLevelConfigured =
|
||||
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
|
||||
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
return topLevelConfigured;
|
||||
}
|
||||
|
||||
const account = feishuCfg.accounts?.[resolvedAccountId];
|
||||
if (!account || typeof account !== "object") {
|
||||
return topLevelConfigured;
|
||||
}
|
||||
|
||||
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
|
||||
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
|
||||
const accountAppIdConfigured = hasOwnAppId
|
||||
? isAppIdConfigured((account as Record<string, unknown>).appId)
|
||||
: isAppIdConfigured(feishuCfg?.appId);
|
||||
const accountSecretConfigured = hasOwnAppSecret
|
||||
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
||||
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
|
||||
return accountAppIdConfigured && accountSecretConfigured;
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
accountId?: string;
|
||||
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
||||
}): Promise<OpenClawConfig> {
|
||||
const existingAllowFrom =
|
||||
resolveFeishuAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).config.allowFrom ?? [];
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolvedAccountId = params.accountId ?? resolveDefaultFeishuAccountId(params.cfg);
|
||||
const account =
|
||||
resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? (feishuCfg?.accounts?.[resolvedAccountId] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
const existingAllowFrom = (account?.allowFrom ?? feishuCfg?.allowFrom ?? []) as Array<
|
||||
string | number
|
||||
>;
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Feishu DMs by open_id or user_id.",
|
||||
@@ -162,7 +147,7 @@ async function promptFeishuAllowFrom(params: {
|
||||
existingAllowFrom.length > 0 ? existingAllowFrom.map(String).join(", ") : undefined,
|
||||
});
|
||||
const mergedAllowFrom = mergeAllowFromEntries(existingAllowFrom, splitSetupEntries(entry));
|
||||
return setFeishuAllowFrom(params.cfg, params.accountId, mergedAllowFrom);
|
||||
return patchFeishuConfig(params.cfg, resolvedAccountId, { allowFrom: mergedAllowFrom });
|
||||
}
|
||||
|
||||
async function noteFeishuCredentialHelp(
|
||||
@@ -212,36 +197,322 @@ const feishuDmPolicy: ChannelSetupDmPolicy = {
|
||||
allowFromKey: "channels.feishu.allowFrom",
|
||||
};
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveFeishuAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultFeishuAccountId(cfg),
|
||||
}).config.dmPolicy ?? "pairing",
|
||||
getCurrent: (cfg, accountId) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
||||
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const account = feishuCfg?.accounts?.[resolvedAccountId] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (account?.dmPolicy) {
|
||||
return account.dmPolicy as DmPolicy;
|
||||
}
|
||||
}
|
||||
return (feishuCfg?.dmPolicy as DmPolicy | undefined) ?? "pairing";
|
||||
},
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
||||
const currentAllowFrom = resolveFeishuAccount({
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
}).config.allowFrom;
|
||||
return patchFeishuConfig(cfg, resolvedAccountId, {
|
||||
dmPolicy: policy,
|
||||
...(policy === "open" ? { allowFrom: mergeAllowFromEntries(currentAllowFrom, ["*"]) } : {}),
|
||||
...(policy === "open" ? { allowFrom: mergeAllowFromEntries([], ["*"]) } : {}),
|
||||
});
|
||||
},
|
||||
promptAllowFrom: async ({ cfg, accountId, prompter }) =>
|
||||
await promptFeishuAllowFrom({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultFeishuAccountId(cfg),
|
||||
prompter,
|
||||
}),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
|
||||
type WizardPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security policy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyNewAppSecurityPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
openId: string | undefined,
|
||||
groupPolicy: "allowlist" | "open" | "disabled",
|
||||
): OpenClawConfig {
|
||||
let next = cfg;
|
||||
|
||||
if (openId) {
|
||||
// dmPolicy=allowlist, allowFrom=[openId]
|
||||
next = patchFeishuConfig(next, accountId, { dmPolicy: "allowlist", allowFrom: [openId] });
|
||||
}
|
||||
|
||||
// Apply group policy.
|
||||
const groupPatch: Record<string, unknown> = { groupPolicy };
|
||||
if (groupPolicy === "open") {
|
||||
groupPatch.requireMention = true;
|
||||
}
|
||||
next = patchFeishuConfig(next, accountId, groupPatch);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan-to-create flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistrationResult | null> {
|
||||
try {
|
||||
await initAppRegistration("feishu");
|
||||
} catch {
|
||||
await prompter.note(
|
||||
"Scan-to-create is not available in this environment. Falling back to manual input.",
|
||||
"Feishu setup",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const begin = await beginAppRegistration("feishu");
|
||||
|
||||
await prompter.note("Scan the QR with Lark/Feishu on your phone.", "Feishu scan-to-create");
|
||||
await printQrCode(begin.qrUrl);
|
||||
|
||||
const progress = prompter.progress("Fetching configuration results...");
|
||||
|
||||
const outcome = await pollAppRegistration({
|
||||
deviceCode: begin.deviceCode,
|
||||
interval: begin.interval,
|
||||
expireIn: begin.expireIn,
|
||||
initialDomain: "feishu",
|
||||
tp: "ob_app",
|
||||
});
|
||||
|
||||
switch (outcome.status) {
|
||||
case "success":
|
||||
progress.stop("Scan completed.");
|
||||
return outcome.result;
|
||||
case "access_denied":
|
||||
progress.stop("User denied authorization. Falling back to manual input.");
|
||||
return null;
|
||||
case "expired":
|
||||
progress.stop("Session expired. Falling back to manual input.");
|
||||
return null;
|
||||
case "timeout":
|
||||
progress.stop("Scan timed out. Falling back to manual input.");
|
||||
return null;
|
||||
case "error":
|
||||
progress.stop(`Registration error: ${outcome.message}. Falling back to manual input.`);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New app configuration flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runNewAppFlow(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
options: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["options"];
|
||||
}): Promise<{ cfg: OpenClawConfig }> {
|
||||
const { prompter, options } = params;
|
||||
let next = params.cfg;
|
||||
|
||||
// Resolve target account: defaultAccount > first account key > top-level.
|
||||
const targetAccountId = resolveDefaultFeishuAccountId(next);
|
||||
|
||||
// ----- QR scan flow -----
|
||||
let appId: string | null = null;
|
||||
let appSecret: SecretInput | null = null;
|
||||
let appSecretProbeValue: string | null = null;
|
||||
let scanDomain: FeishuDomain | undefined;
|
||||
let scanOpenId: string | undefined;
|
||||
|
||||
const scanResult = await runScanToCreate(prompter);
|
||||
if (scanResult) {
|
||||
appId = scanResult.appId;
|
||||
appSecret = scanResult.appSecret;
|
||||
appSecretProbeValue = scanResult.appSecret;
|
||||
scanDomain = scanResult.domain;
|
||||
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),
|
||||
});
|
||||
|
||||
const appSecretResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "feishu",
|
||||
credentialLabel: "App Secret",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured: false,
|
||||
canUseEnv: false,
|
||||
hasConfigToken: false,
|
||||
envPrompt: "",
|
||||
keepPrompt: "Feishu App Secret already configured. Keep it?",
|
||||
inputPrompt: "Enter Feishu App Secret",
|
||||
preferredEnvVar: "FEISHU_APP_SECRET",
|
||||
});
|
||||
if (appSecretResult.action === "set") {
|
||||
appSecret = appSecretResult.value;
|
||||
appSecretProbeValue = appSecretResult.resolvedValue;
|
||||
}
|
||||
|
||||
// Fetch openId via API for manual flow.
|
||||
if (appId && appSecretProbeValue) {
|
||||
scanOpenId = await getAppOwnerOpenId({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue,
|
||||
domain: scanDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Group chat policy -----
|
||||
const groupPolicy = (await prompter.select({
|
||||
message: "Group chat policy",
|
||||
options: [
|
||||
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
|
||||
{ value: "open", label: "Open - respond in all groups (requires mention)" },
|
||||
{ value: "disabled", label: "Disabled - don't respond in groups" },
|
||||
],
|
||||
initialValue: "allowlist",
|
||||
})) as "allowlist" | "open" | "disabled";
|
||||
|
||||
// ----- Apply credentials & security policy -----
|
||||
const configProgress = prompter.progress("Configuring...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (appId && appSecret) {
|
||||
next = patchFeishuConfig(next, targetAccountId, {
|
||||
appId,
|
||||
appSecret,
|
||||
connectionMode: "websocket",
|
||||
...(scanDomain ? { domain: scanDomain } : {}),
|
||||
});
|
||||
} else if (scanDomain) {
|
||||
next = patchFeishuConfig(next, targetAccountId, { domain: scanDomain });
|
||||
}
|
||||
|
||||
next = applyNewAppSecurityPolicy(next, targetAccountId, scanOpenId, groupPolicy);
|
||||
|
||||
configProgress.stop("Bot configured.");
|
||||
|
||||
return { cfg: next };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit configuration flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runEditFlow(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
options: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["options"];
|
||||
}): Promise<{ cfg: OpenClawConfig } | null> {
|
||||
const { prompter, options } = params;
|
||||
const next = params.cfg;
|
||||
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
// Check existing appId (top-level or first configured account).
|
||||
// Supports both plain string and SecretRef (env-backed) appId values.
|
||||
const resolveAppIdLabel = (value: unknown): string | undefined => {
|
||||
const asString = normalizeString(value);
|
||||
if (asString) {
|
||||
return asString;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
if (normalizeString(rec.source) && normalizeString(rec.id)) {
|
||||
const envValue = normalizeString(process.env[rec.id as string]);
|
||||
return envValue ?? `env:${String(rec.id)}`;
|
||||
}
|
||||
if (hasConfiguredSecretInput(value)) {
|
||||
return "(configured)";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const existingAppId =
|
||||
resolveAppIdLabel(feishuCfg?.appId) ??
|
||||
Object.values(feishuCfg?.accounts ?? {}).reduce<string | undefined>((found, account) => {
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
if (account && typeof account === "object") {
|
||||
return resolveAppIdLabel((account as Record<string, unknown>).appId);
|
||||
}
|
||||
return undefined;
|
||||
}, undefined);
|
||||
if (existingAppId) {
|
||||
const useExisting = await prompter.confirm({
|
||||
message: `We found an existing bot (App ID: ${existingAppId}). Use it for this setup?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (!useExisting) {
|
||||
// User wants a new bot — run new app flow.
|
||||
return runNewAppFlow({ cfg: next, prompter, options });
|
||||
}
|
||||
} else {
|
||||
// No existing appId — run new app flow.
|
||||
return runNewAppFlow({ cfg: next, prompter, options });
|
||||
}
|
||||
|
||||
await prompter.note("Bot configured.", "");
|
||||
|
||||
return { cfg: next };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone login entry point (for `channels login --channel feishu`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runFeishuLogin(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const options = {};
|
||||
const alreadyConfigured = isFeishuConfigured(cfg);
|
||||
|
||||
if (alreadyConfigured) {
|
||||
const result = await runEditFlow({ cfg, prompter, options });
|
||||
if (result === null) {
|
||||
return cfg;
|
||||
}
|
||||
return result.cfg;
|
||||
}
|
||||
|
||||
const result = await runNewAppFlow({ cfg, prompter, options });
|
||||
return result.cfg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { feishuSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const feishuSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) =>
|
||||
normalizeString(accountOverride) ?? defaultAccountId,
|
||||
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId, cfg }) =>
|
||||
(typeof accountOverride === "string" && accountOverride.trim()
|
||||
? accountOverride.trim()
|
||||
: undefined) ??
|
||||
resolveDefaultFeishuAccountId(cfg) ??
|
||||
defaultAccountId,
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
@@ -250,22 +521,10 @@ export const feishuSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "needs app creds",
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg, accountId }) => isFeishuConfigured(cfg, accountId),
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
const resolvedCredentials = accountId
|
||||
? (() => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
return account.configured && account.appId && account.appSecret
|
||||
? {
|
||||
appId: account.appId,
|
||||
appSecret: account.appSecret,
|
||||
encryptKey: account.encryptKey,
|
||||
verificationToken: account.verificationToken,
|
||||
domain: account.domain,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: inspectFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined);
|
||||
resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg),
|
||||
resolveStatusLines: async ({ cfg, configured }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolvedCredentials = inspectFeishuCredentials(feishuCfg);
|
||||
let probeResult = null;
|
||||
if (configured && resolvedCredentials) {
|
||||
try {
|
||||
@@ -281,215 +540,43 @@ export const feishuSetupWizard: ChannelSetupWizard = {
|
||||
return ["Feishu: configured (connection not verified)"];
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, accountId, prompter, options }) => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
||||
const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
|
||||
const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId);
|
||||
const resolved =
|
||||
resolvedAccount.configured && resolvedAccount.appId && resolvedAccount.appSecret
|
||||
? {
|
||||
appId: resolvedAccount.appId,
|
||||
appSecret: resolvedAccount.appSecret,
|
||||
encryptKey: resolvedAccount.encryptKey,
|
||||
verificationToken: resolvedAccount.verificationToken,
|
||||
domain: resolvedAccount.domain,
|
||||
}
|
||||
: null;
|
||||
const hasConfigSecret = hasConfiguredSecretInput(scopedConfig.appSecret);
|
||||
const hasConfigCreds = Boolean(
|
||||
typeof scopedConfig.appId === "string" && scopedConfig.appId.trim() && hasConfigSecret,
|
||||
);
|
||||
const appSecretPromptState = buildSingleChannelSecretPromptState({
|
||||
accountConfigured: Boolean(resolved),
|
||||
hasConfigToken: hasConfigSecret,
|
||||
allowEnv: !hasConfigCreds && Boolean(process.env.FEISHU_APP_ID?.trim()),
|
||||
envValue: process.env.FEISHU_APP_SECRET,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appSecret: SecretInput | null = null;
|
||||
let appSecretProbeValue: string | null = null;
|
||||
// -------------------------------------------------------------------------
|
||||
// prepare: determine flow based on existing configuration
|
||||
// -------------------------------------------------------------------------
|
||||
prepare: async ({ cfg, credentialValues }) => {
|
||||
const alreadyConfigured = isFeishuConfigured(cfg);
|
||||
|
||||
if (!resolved) {
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
if (alreadyConfigured) {
|
||||
return {
|
||||
credentialValues: { ...credentialValues, _flow: "edit" },
|
||||
};
|
||||
}
|
||||
|
||||
const appSecretResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "feishu",
|
||||
credentialLabel: "App Secret",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured: appSecretPromptState.accountConfigured,
|
||||
canUseEnv: appSecretPromptState.canUseEnv,
|
||||
hasConfigToken: appSecretPromptState.hasConfigToken,
|
||||
envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
|
||||
keepPrompt: "Feishu App Secret already configured. Keep it?",
|
||||
inputPrompt: "Enter Feishu App Secret",
|
||||
preferredEnvVar: "FEISHU_APP_SECRET",
|
||||
});
|
||||
|
||||
if (appSecretResult.action === "use-env") {
|
||||
next = patchFeishuConfig(next, resolvedAccountId, {});
|
||||
} else if (appSecretResult.action === "set") {
|
||||
appSecret = appSecretResult.value;
|
||||
appSecretProbeValue = appSecretResult.resolvedValue;
|
||||
appId = await promptFeishuAppId({
|
||||
prompter,
|
||||
initialValue:
|
||||
normalizeString(scopedConfig.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
|
||||
});
|
||||
}
|
||||
|
||||
if (appId && appSecret) {
|
||||
next = patchFeishuConfig(next, resolvedAccountId, {
|
||||
appId,
|
||||
appSecret,
|
||||
});
|
||||
|
||||
try {
|
||||
const probe = await probeFeishu({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue ?? undefined,
|
||||
domain: resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain,
|
||||
});
|
||||
if (probe.ok) {
|
||||
await prompter.note(
|
||||
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`Connection failed: ${probe.error ?? "unknown error"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
|
||||
}
|
||||
}
|
||||
|
||||
const currentMode =
|
||||
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.connectionMode ??
|
||||
"websocket";
|
||||
const connectionMode = (await prompter.select({
|
||||
message: "Feishu connection mode",
|
||||
options: [
|
||||
{ value: "websocket", label: "WebSocket (default)" },
|
||||
{ value: "webhook", label: "Webhook" },
|
||||
],
|
||||
initialValue: currentMode,
|
||||
})) as "websocket" | "webhook";
|
||||
next = patchFeishuConfig(next, resolvedAccountId, { connectionMode });
|
||||
|
||||
if (connectionMode === "webhook") {
|
||||
const currentVerificationToken = getScopedFeishuConfig(
|
||||
next,
|
||||
resolvedAccountId,
|
||||
).verificationToken;
|
||||
const verificationTokenResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "feishu-webhook",
|
||||
credentialLabel: "verification token",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
|
||||
hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
keepPrompt: "Feishu verification token already configured. Keep it?",
|
||||
inputPrompt: "Enter Feishu verification token",
|
||||
preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
|
||||
});
|
||||
if (verificationTokenResult.action === "set") {
|
||||
next = patchFeishuConfig(next, resolvedAccountId, {
|
||||
verificationToken: verificationTokenResult.value,
|
||||
});
|
||||
}
|
||||
|
||||
const currentEncryptKey = getScopedFeishuConfig(next, resolvedAccountId).encryptKey;
|
||||
const encryptKeyResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "feishu-webhook",
|
||||
credentialLabel: "encrypt key",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
|
||||
hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
keepPrompt: "Feishu encrypt key already configured. Keep it?",
|
||||
inputPrompt: "Enter Feishu encrypt key",
|
||||
preferredEnvVar: "FEISHU_ENCRYPT_KEY",
|
||||
});
|
||||
if (encryptKeyResult.action === "set") {
|
||||
next = patchFeishuConfig(next, resolvedAccountId, {
|
||||
encryptKey: encryptKeyResult.value,
|
||||
});
|
||||
}
|
||||
|
||||
const currentWebhookPath = getScopedFeishuConfig(next, resolvedAccountId).webhookPath;
|
||||
const webhookPath = (
|
||||
await prompter.text({
|
||||
message: "Feishu webhook path",
|
||||
initialValue: currentWebhookPath ?? "/feishu/events",
|
||||
validate: (value) => ((value ?? "").trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim();
|
||||
next = patchFeishuConfig(next, resolvedAccountId, { webhookPath });
|
||||
}
|
||||
|
||||
const currentDomain = resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain;
|
||||
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,
|
||||
});
|
||||
next = patchFeishuConfig(next, resolvedAccountId, {
|
||||
domain: domain as "feishu" | "lark",
|
||||
});
|
||||
|
||||
const groupPolicy = (await prompter.select({
|
||||
message: "Group chat policy",
|
||||
options: [
|
||||
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
|
||||
{ value: "open", label: "Open - respond in all groups (requires mention)" },
|
||||
{ value: "disabled", label: "Disabled - don't respond in groups" },
|
||||
],
|
||||
initialValue:
|
||||
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupPolicy ??
|
||||
"allowlist",
|
||||
})) as "allowlist" | "open" | "disabled";
|
||||
next = setFeishuGroupPolicy(next, resolvedAccountId, groupPolicy);
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
const existing =
|
||||
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupAllowFrom ??
|
||||
[];
|
||||
const entry = await prompter.text({
|
||||
message: "Group chat allowlist (chat_ids)",
|
||||
placeholder: "oc_xxxxx, oc_yyyyy",
|
||||
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
|
||||
});
|
||||
if (entry) {
|
||||
const parts = splitSetupEntries(entry);
|
||||
if (parts.length > 0) {
|
||||
next = setFeishuGroupAllowFrom(next, resolvedAccountId, parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next };
|
||||
return {
|
||||
credentialValues: { ...credentialValues, _flow: "new" },
|
||||
};
|
||||
},
|
||||
|
||||
credentials: [],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// finalize: run the appropriate flow
|
||||
// -------------------------------------------------------------------------
|
||||
finalize: async ({ cfg, prompter, options, credentialValues }) => {
|
||||
const flow = credentialValues._flow ?? "new";
|
||||
|
||||
if (flow === "edit") {
|
||||
const result = await runEditFlow({ cfg, prompter, options });
|
||||
if (result === null) {
|
||||
return { cfg };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return runNewAppFlow({ cfg, prompter, options });
|
||||
},
|
||||
|
||||
dmPolicy: feishuDmPolicy,
|
||||
disable: (cfg) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
|
||||
@@ -76,11 +76,11 @@ export type FeishuMessageInfo = {
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type FeishuProbeResult = BaseProbeResult<string> & {
|
||||
export interface FeishuProbeResult extends BaseProbeResult {
|
||||
appId?: string;
|
||||
botName?: string;
|
||||
botOpenId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type FeishuMediaInfo = {
|
||||
path: string;
|
||||
|
||||
@@ -228,5 +228,5 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
{ name: "feishu_wiki" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_wiki: Registered feishu_wiki tool`);
|
||||
api.logger.debug?.(`feishu_wiki: Registered feishu_wiki tool`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user