360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
import type {
|
|
ChannelOnboardingAdapter,
|
|
ChannelOnboardingDmPolicy,
|
|
ClawdbotConfig,
|
|
DmPolicy,
|
|
WizardPrompter,
|
|
} from "openclaw/plugin-sdk";
|
|
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
|
|
import { resolveFeishuCredentials } from "./accounts.js";
|
|
import { probeFeishu } from "./probe.js";
|
|
import type { FeishuConfig } from "./types.js";
|
|
|
|
const channel = "feishu" as const;
|
|
|
|
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
|
const allowFrom =
|
|
dmPolicy === "open"
|
|
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
|
: undefined;
|
|
return {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
feishu: {
|
|
...cfg.channels?.feishu,
|
|
dmPolicy,
|
|
...(allowFrom ? { allowFrom } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
|
return {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
feishu: {
|
|
...cfg.channels?.feishu,
|
|
allowFrom,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseAllowFromInput(raw: string): string[] {
|
|
return raw
|
|
.split(/[\n,;]+/g)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function promptFeishuAllowFrom(params: {
|
|
cfg: ClawdbotConfig;
|
|
prompter: WizardPrompter;
|
|
}): Promise<ClawdbotConfig> {
|
|
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
|
|
await params.prompter.note(
|
|
[
|
|
"Allowlist Feishu DMs by open_id or user_id.",
|
|
"You can find user open_id in Feishu admin console or via API.",
|
|
"Examples:",
|
|
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
].join("\n"),
|
|
"Feishu allowlist",
|
|
);
|
|
|
|
while (true) {
|
|
const entry = await params.prompter.text({
|
|
message: "Feishu allowFrom (user open_ids)",
|
|
placeholder: "ou_xxxxx, ou_yyyyy",
|
|
initialValue: existing[0] ? String(existing[0]) : undefined,
|
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
});
|
|
const parts = parseAllowFromInput(String(entry));
|
|
if (parts.length === 0) {
|
|
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
|
|
continue;
|
|
}
|
|
|
|
const unique = [
|
|
...new Set([
|
|
...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
|
|
...parts,
|
|
]),
|
|
];
|
|
return setFeishuAllowFrom(params.cfg, unique);
|
|
}
|
|
}
|
|
|
|
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
|
await prompter.note(
|
|
[
|
|
"1) Go to Feishu Open Platform (open.feishu.cn)",
|
|
"2) Create a self-built app",
|
|
"3) Get App ID and App Secret from Credentials page",
|
|
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
|
|
"5) Publish the app or add it to a test group",
|
|
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
|
|
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
|
|
].join("\n"),
|
|
"Feishu credentials",
|
|
);
|
|
}
|
|
|
|
function setFeishuGroupPolicy(
|
|
cfg: ClawdbotConfig,
|
|
groupPolicy: "open" | "allowlist" | "disabled",
|
|
): ClawdbotConfig {
|
|
return {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
feishu: {
|
|
...cfg.channels?.feishu,
|
|
enabled: true,
|
|
groupPolicy,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
|
|
return {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
feishu: {
|
|
...cfg.channels?.feishu,
|
|
groupAllowFrom,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
label: "Feishu",
|
|
channel,
|
|
policyKey: "channels.feishu.dmPolicy",
|
|
allowFromKey: "channels.feishu.allowFrom",
|
|
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
|
|
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
|
|
promptAllowFrom: promptFeishuAllowFrom,
|
|
};
|
|
|
|
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
channel,
|
|
getStatus: async ({ cfg }) => {
|
|
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
|
|
|
|
// Try to probe if configured
|
|
let probeResult = null;
|
|
if (configured && feishuCfg) {
|
|
try {
|
|
probeResult = await probeFeishu(feishuCfg);
|
|
} catch {
|
|
// Ignore probe errors
|
|
}
|
|
}
|
|
|
|
const statusLines: string[] = [];
|
|
if (!configured) {
|
|
statusLines.push("Feishu: needs app credentials");
|
|
} else if (probeResult?.ok) {
|
|
statusLines.push(
|
|
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
|
|
);
|
|
} else {
|
|
statusLines.push("Feishu: configured (connection not verified)");
|
|
}
|
|
|
|
return {
|
|
channel,
|
|
configured,
|
|
statusLines,
|
|
selectionHint: configured ? "configured" : "needs app creds",
|
|
quickstartScore: configured ? 2 : 0,
|
|
};
|
|
},
|
|
|
|
configure: async ({ cfg, prompter }) => {
|
|
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
const resolved = resolveFeishuCredentials(feishuCfg);
|
|
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
|
|
const canUseEnv = Boolean(
|
|
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
|
|
);
|
|
|
|
let next = cfg;
|
|
let appId: string | null = null;
|
|
let appSecret: string | null = null;
|
|
|
|
if (!resolved) {
|
|
await noteFeishuCredentialHelp(prompter);
|
|
}
|
|
|
|
if (canUseEnv) {
|
|
const keepEnv = await prompter.confirm({
|
|
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
|
|
initialValue: true,
|
|
});
|
|
if (keepEnv) {
|
|
next = {
|
|
...next,
|
|
channels: {
|
|
...next.channels,
|
|
feishu: { ...next.channels?.feishu, enabled: true },
|
|
},
|
|
};
|
|
} else {
|
|
appId = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App ID",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
appSecret = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App Secret",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
}
|
|
} else if (hasConfigCreds) {
|
|
const keep = await prompter.confirm({
|
|
message: "Feishu credentials already configured. Keep them?",
|
|
initialValue: true,
|
|
});
|
|
if (!keep) {
|
|
appId = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App ID",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
appSecret = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App Secret",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
}
|
|
} else {
|
|
appId = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App ID",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
appSecret = String(
|
|
await prompter.text({
|
|
message: "Enter Feishu App Secret",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
).trim();
|
|
}
|
|
|
|
if (appId && appSecret) {
|
|
next = {
|
|
...next,
|
|
channels: {
|
|
...next.channels,
|
|
feishu: {
|
|
...next.channels?.feishu,
|
|
enabled: true,
|
|
appId,
|
|
appSecret,
|
|
},
|
|
},
|
|
};
|
|
|
|
// Test connection
|
|
const testCfg = next.channels?.feishu as FeishuConfig;
|
|
try {
|
|
const probe = await probeFeishu(testCfg);
|
|
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");
|
|
}
|
|
}
|
|
|
|
// Domain selection
|
|
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.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,
|
|
});
|
|
if (domain) {
|
|
next = {
|
|
...next,
|
|
channels: {
|
|
...next.channels,
|
|
feishu: {
|
|
...next.channels?.feishu,
|
|
domain: domain as "feishu" | "lark",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// Group 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: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
|
|
});
|
|
if (groupPolicy) {
|
|
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
|
|
}
|
|
|
|
// Group allowlist if needed
|
|
if (groupPolicy === "allowlist") {
|
|
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.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 = parseAllowFromInput(String(entry));
|
|
if (parts.length > 0) {
|
|
next = setFeishuGroupAllowFrom(next, parts);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
|
},
|
|
|
|
dmPolicy,
|
|
|
|
disable: (cfg) => ({
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
feishu: { ...cfg.channels?.feishu, enabled: false },
|
|
},
|
|
}),
|
|
};
|