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:
mazhe-nerd
2026-04-13 18:03:44 +08:00
committed by GitHub
parent 3b9fb972da
commit 9e2ac8a1cb
23 changed files with 909 additions and 1116 deletions

View File

@@ -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:*",

View 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));
}

View File

@@ -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");
}

View File

@@ -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: {

View File

@@ -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");
}

View File

@@ -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(", ")}`);
}
}

View File

@@ -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`);
}

View File

@@ -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`,

View File

@@ -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`);
}

View File

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

View File

@@ -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({

View File

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

View File

@@ -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`);
}