Skip to content

Commit bdedce3

Browse files
authored
Merge pull request #198 from OpenBMB/feat/im-channel-settings
feat: IM channel settings UI with Feishu QR and WeChat setup
2 parents d910662 + f86e18d commit bdedce3

15 files changed

Lines changed: 1681 additions & 22 deletions

File tree

src/cli/commands/gatewaySetup.ts

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
import { createInterface } from "node:readline/promises";
2+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { homedir } from "node:os";
5+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6+
7+
const PILOTDECK_YAML_PATH = join(homedir(), ".pilotdeck", "pilotdeck.yaml");
8+
const WEIXIN_CREDS_PATH = join(homedir(), ".pilotdeck", "weixin-credentials.json");
9+
10+
const FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
11+
const LARK_TOKEN_URL = "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal";
12+
13+
// ---------------------------------------------------------------------------
14+
// Entry
15+
// ---------------------------------------------------------------------------
16+
17+
export async function runGatewaySetup(argv: string[]): Promise<void> {
18+
const rl = createInterface({ input: process.stdin, output: process.stdout });
19+
20+
try {
21+
console.log("\n╔══════════════════════════════════════════════════╗");
22+
console.log("║ PilotDeck Gateway Setup ║");
23+
console.log("║ 配置 IM 平台连接 ║");
24+
console.log("╚══════════════════════════════════════════════════╝\n");
25+
26+
const platform = argv[0]?.toLowerCase();
27+
if (platform === "feishu" || platform === "lark") {
28+
await setupFeishu(rl);
29+
} else if (platform === "weixin" || platform === "wechat") {
30+
await setupWeixin(rl);
31+
} else {
32+
const choice = await selectPlatform(rl);
33+
if (choice === "feishu") await setupFeishu(rl);
34+
else if (choice === "weixin") await setupWeixin(rl);
35+
else if (choice === "all") {
36+
await setupFeishu(rl);
37+
console.log("");
38+
await setupWeixin(rl);
39+
} else {
40+
console.log("未选择任何平台,退出。");
41+
}
42+
}
43+
} finally {
44+
rl.close();
45+
}
46+
}
47+
48+
async function selectPlatform(
49+
rl: ReturnType<typeof createInterface>,
50+
): Promise<"feishu" | "weixin" | "all" | null> {
51+
const currentConfig = loadYamlConfig();
52+
const feishuStatus = currentConfig?.adapters?.feishu?.enabled ? "✅ 已启用" : "未配置";
53+
const weixinStatus = existsSync(WEIXIN_CREDS_PATH) ? "✅ 已有凭据" : "未配置";
54+
55+
console.log("可配置的平台:");
56+
console.log(` 1) 飞书 / Lark [${feishuStatus}]`);
57+
console.log(` 2) 微信 (iLink) [${weixinStatus}]`);
58+
console.log(` 3) 全部配置`);
59+
console.log(` q) 退出\n`);
60+
61+
const answer = (await rl.question("请选择 [1/2/3/q]: ")).trim().toLowerCase();
62+
if (answer === "1" || answer === "feishu") return "feishu";
63+
if (answer === "2" || answer === "weixin") return "weixin";
64+
if (answer === "3" || answer === "all") return "all";
65+
return null;
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// Feishu Setup
70+
// ---------------------------------------------------------------------------
71+
72+
async function setupFeishu(rl: ReturnType<typeof createInterface>): Promise<void> {
73+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
74+
console.log(" 飞书 / Lark 配置");
75+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
76+
77+
const currentConfig = loadYamlConfig();
78+
const currentAppId = currentConfig?.adapters?.feishu?.appId || "";
79+
const currentSecret = currentConfig?.adapters?.feishu?.appSecret || "";
80+
const currentDomain = currentConfig?.adapters?.feishu?.domainName || "feishu";
81+
82+
// Domain selection
83+
const domainAnswer = (
84+
await rl.question(
85+
`使用哪个平台? [feishu/lark] (当前: ${currentDomain}): `,
86+
)
87+
).trim().toLowerCase();
88+
const domain: "feishu" | "lark" =
89+
domainAnswer === "lark" ? "lark" : "feishu";
90+
91+
const consoleUrl =
92+
domain === "lark"
93+
? "https://open.larksuite.com"
94+
: "https://open.feishu.cn";
95+
96+
// Check if we can do QR-scan app creation via the Lark SDK
97+
const qrResult = await attemptFeishuQRCreation(rl, domain);
98+
let appId: string;
99+
let appSecret: string;
100+
101+
if (qrResult) {
102+
appId = qrResult.appId;
103+
appSecret = qrResult.appSecret;
104+
} else {
105+
// Manual credential entry
106+
console.log(`\n请在 ${consoleUrl} 创建企业自建应用:`);
107+
console.log(" 1. 创建应用 → 获取 App ID 和 App Secret");
108+
console.log(" 2. 权限管理 → 添加以下权限:");
109+
console.log(" - im:message (收发消息)");
110+
console.log(" - im:message:send_as_bot (以机器人身份发消息)");
111+
console.log(" - im:resource (访问图片/文件)");
112+
console.log(" - im:chat (群聊元信息)");
113+
console.log(" 3. 事件订阅 → 添加 im.message.receive_v1");
114+
console.log(" 4. 版本管理 → 发布版本\n");
115+
116+
const defaultAppIdHint = currentAppId ? ` (当前: ${maskSecret(currentAppId)})` : "";
117+
appId = (
118+
await rl.question(`App ID${defaultAppIdHint}: `)
119+
).trim() || currentAppId;
120+
121+
const defaultSecretHint = currentSecret ? " (回车保留当前值)" : "";
122+
const secretInput = (
123+
await rl.question(`App Secret${defaultSecretHint}: `)
124+
).trim();
125+
appSecret = secretInput || currentSecret;
126+
}
127+
128+
if (!appId || !appSecret) {
129+
console.log("\n⚠️ 未提供完整凭据,跳过飞书配置。");
130+
return;
131+
}
132+
133+
// Test connection
134+
console.log("\n🔍 验证凭据...");
135+
const tokenUrl = domain === "lark" ? LARK_TOKEN_URL : FEISHU_TOKEN_URL;
136+
const testResult = await testFeishuCredentials(appId, appSecret, tokenUrl);
137+
138+
if (!testResult.ok) {
139+
console.log(`\n❌ 凭据验证失败: ${testResult.error}`);
140+
const proceed = (await rl.question("是否仍然保存配置? [y/N]: ")).trim().toLowerCase();
141+
if (proceed !== "y" && proceed !== "yes") {
142+
console.log("已取消。");
143+
return;
144+
}
145+
} else {
146+
console.log("✅ 凭据验证通过!");
147+
}
148+
149+
// Write config
150+
writeFeishuConfig({ appId, appSecret, domain });
151+
console.log("\n✅ 飞书配置已写入 pilotdeck.yaml");
152+
console.log(" 连接模式: stream (WebSocket, 推荐 — 无需公网 IP)");
153+
console.log(" 重启 PilotDeck 服务后生效\n");
154+
}
155+
156+
async function attemptFeishuQRCreation(
157+
rl: ReturnType<typeof createInterface>,
158+
domain: "feishu" | "lark",
159+
): Promise<{ appId: string; appSecret: string } | null> {
160+
let Lark: any;
161+
try {
162+
const mod = await import("@larksuiteoapi/node-sdk");
163+
Lark = (mod as { default?: unknown }).default ?? mod;
164+
} catch {
165+
return null;
166+
}
167+
168+
if (!Lark?.AppTicketManager) return null;
169+
170+
const answer = (
171+
await rl.question(
172+
"是否尝试扫码自动创建飞书应用?(需要管理员权限) [y/N]: ",
173+
)
174+
).trim().toLowerCase();
175+
176+
if (answer !== "y" && answer !== "yes") return null;
177+
178+
try {
179+
console.log("\n正在生成二维码...");
180+
const larkDomain =
181+
domain === "lark"
182+
? Lark.Domain?.Lark ?? "https://open.larksuite.com"
183+
: Lark.Domain?.Feishu ?? "https://open.feishu.cn";
184+
185+
const createAppUrl = `${larkDomain}/open-apis/authen/v1/app_access_token`;
186+
console.log(`\n请在浏览器中访问以下链接,使用飞书扫码授权:`);
187+
console.log(` ${larkDomain}/app\n`);
188+
console.log("创建应用后,将 App ID 和 App Secret 粘贴到下方。\n");
189+
190+
return null;
191+
} catch (e) {
192+
console.log(`\n自动创建失败: ${e instanceof Error ? e.message : String(e)}`);
193+
console.log("将使用手动输入模式。\n");
194+
return null;
195+
}
196+
}
197+
198+
async function testFeishuCredentials(
199+
appId: string,
200+
appSecret: string,
201+
tokenUrl: string,
202+
): Promise<{ ok: boolean; error?: string }> {
203+
try {
204+
const res = await fetch(tokenUrl, {
205+
method: "POST",
206+
headers: { "Content-Type": "application/json; charset=utf-8" },
207+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
208+
});
209+
const json = (await res.json()) as {
210+
code?: number;
211+
msg?: string;
212+
tenant_access_token?: string;
213+
};
214+
215+
if (json.code === 0 && json.tenant_access_token) {
216+
return { ok: true };
217+
}
218+
return {
219+
ok: false,
220+
error: `code=${json.code} msg=${json.msg ?? "unknown"}`,
221+
};
222+
} catch (e) {
223+
return {
224+
ok: false,
225+
error: e instanceof Error ? e.message : String(e),
226+
};
227+
}
228+
}
229+
230+
function writeFeishuConfig(cfg: {
231+
appId: string;
232+
appSecret: string;
233+
domain: "feishu" | "lark";
234+
}): void {
235+
const yamlConfig = loadYamlConfig() ?? {};
236+
237+
if (!yamlConfig.adapters) yamlConfig.adapters = {};
238+
yamlConfig.adapters.feishu = {
239+
enabled: true,
240+
appId: cfg.appId,
241+
appSecret: cfg.appSecret,
242+
connectionMode: "stream",
243+
domainName: cfg.domain,
244+
};
245+
246+
saveYamlConfig(yamlConfig);
247+
}
248+
249+
// ---------------------------------------------------------------------------
250+
// Weixin (iLink) Setup
251+
// ---------------------------------------------------------------------------
252+
253+
async function setupWeixin(rl: ReturnType<typeof createInterface>): Promise<void> {
254+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
255+
console.log(" 微信 iLink 配置");
256+
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
257+
258+
const existingCreds = loadWeixinCredentials();
259+
if (existingCreds) {
260+
console.log(`已有凭据 (accountId: ${existingCreds.accountId})`);
261+
const answer = (await rl.question("重新登录? [y/N]: ")).trim().toLowerCase();
262+
if (answer !== "y" && answer !== "yes") {
263+
// Make sure weixin is enabled in config
264+
enableWeixinConfig();
265+
console.log("✅ 微信已启用(使用现有凭据)\n");
266+
return;
267+
}
268+
}
269+
270+
console.log("⚠️ 注意:");
271+
console.log(" - 使用 iLink Bot API,非企业微信");
272+
console.log(" - 建议使用小号登录,有封号风险");
273+
console.log(" - 群聊功能默认禁用\n");
274+
275+
const proceed = (await rl.question("继续扫码登录? [Y/n]: ")).trim().toLowerCase();
276+
if (proceed === "n" || proceed === "no") {
277+
console.log("已取消。");
278+
return;
279+
}
280+
281+
let loginWithQR: typeof import("weixin-ilink").loginWithQR;
282+
try {
283+
const mod = await import("weixin-ilink");
284+
loginWithQR = mod.loginWithQR;
285+
} catch (e) {
286+
console.log("\n❌ weixin-ilink 模块加载失败");
287+
console.log(" 请运行: npm install weixin-ilink\n");
288+
return;
289+
}
290+
291+
console.log("\n╔══════════════════════════════════════════════╗");
292+
console.log("║ 请用微信扫描二维码 ║");
293+
console.log("╚══════════════════════════════════════════════╝\n");
294+
295+
try {
296+
const result = await loginWithQR({
297+
onQRCode: (url: string) => {
298+
console.log("扫码登录链接:");
299+
console.log(` ${url}\n`);
300+
console.log("如果终端无法显示二维码,请在浏览器中打开上述链接。\n");
301+
},
302+
onStatusChange: (status: string) => {
303+
const labels: Record<string, string> = {
304+
waiting: "⏳ 等待扫码...",
305+
scanned: "📱 已扫码,等待确认...",
306+
expired: "⏰ 二维码已过期,正在刷新...",
307+
refreshing: "🔄 刷新中...",
308+
};
309+
console.log(labels[status] ?? `状态: ${status}`);
310+
},
311+
});
312+
313+
const creds = {
314+
baseUrl: result.baseUrl,
315+
botToken: result.botToken,
316+
accountId: result.accountId,
317+
};
318+
319+
saveWeixinCredentials(creds);
320+
enableWeixinConfig();
321+
322+
console.log(`\n✅ 微信登录成功!`);
323+
console.log(` 账号 ID: ${result.accountId}`);
324+
console.log(` 凭据已保存到: ${WEIXIN_CREDS_PATH}`);
325+
console.log(` 重启 PilotDeck 服务后生效\n`);
326+
} catch (e) {
327+
console.log(`\n❌ 微信登录失败: ${e instanceof Error ? e.message : String(e)}`);
328+
console.log(" 请检查网络连接后重试。\n");
329+
}
330+
}
331+
332+
function loadWeixinCredentials(): { accountId: string; baseUrl: string; botToken: string } | null {
333+
try {
334+
if (!existsSync(WEIXIN_CREDS_PATH)) return null;
335+
const raw = readFileSync(WEIXIN_CREDS_PATH, "utf-8");
336+
const data = JSON.parse(raw);
337+
if (!data.baseUrl || !data.botToken || !data.accountId) return null;
338+
return data;
339+
} catch {
340+
return null;
341+
}
342+
}
343+
344+
function saveWeixinCredentials(creds: {
345+
baseUrl: string;
346+
botToken: string;
347+
accountId: string;
348+
}): void {
349+
mkdirSync(join(homedir(), ".pilotdeck"), { recursive: true });
350+
writeFileSync(WEIXIN_CREDS_PATH, JSON.stringify(creds, null, 2), "utf-8");
351+
}
352+
353+
function enableWeixinConfig(): void {
354+
const yamlConfig = loadYamlConfig() ?? {};
355+
if (!yamlConfig.adapters) yamlConfig.adapters = {};
356+
yamlConfig.adapters.weixin = { enabled: true };
357+
saveYamlConfig(yamlConfig);
358+
}
359+
360+
// ---------------------------------------------------------------------------
361+
// YAML config read/write
362+
// ---------------------------------------------------------------------------
363+
364+
function loadYamlConfig(): Record<string, any> | null {
365+
try {
366+
if (!existsSync(PILOTDECK_YAML_PATH)) return null;
367+
const raw = readFileSync(PILOTDECK_YAML_PATH, "utf-8");
368+
return parseYaml(raw) as Record<string, any>;
369+
} catch {
370+
return null;
371+
}
372+
}
373+
374+
function saveYamlConfig(config: Record<string, any>): void {
375+
mkdirSync(join(homedir(), ".pilotdeck"), { recursive: true });
376+
const yamlStr = stringifyYaml(config, {
377+
lineWidth: 0,
378+
singleQuote: false,
379+
});
380+
writeFileSync(PILOTDECK_YAML_PATH, yamlStr, "utf-8");
381+
}
382+
383+
function maskSecret(value: string): string {
384+
if (value.length <= 8) return value;
385+
return `${value.slice(0, 4)}${value.slice(-4)}`;
386+
}

0 commit comments

Comments
 (0)