@zhin.js/scaffold-wizard 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapter.ts CHANGED
@@ -5,10 +5,15 @@ import {
5
5
  adapterDocsUrl,
6
6
  configureDiscordEndpoint,
7
7
  configureGitHubEndpoint,
8
+ configureMilkyBot,
9
+ configureNapcatBot,
8
10
  configureOneBot11Bot,
11
+ configureOneBot12Bot,
9
12
  configureQQBot,
13
+ configureSatoriBot,
10
14
  configureSlackEndpoint,
11
15
  configureTelegramEndpoint,
16
+ configureWeixinIlinkBot,
12
17
  } from './adapter-configurers.js';
13
18
  import { ZHIN_STACK_VERSIONS } from './zhin-stack-deps.js';
14
19
 
@@ -32,7 +37,7 @@ export interface AdapterSetupResult {
32
37
  }
33
38
 
34
39
  // 适配器定义
35
- interface AdapterDefinition {
40
+ export interface AdapterDefinition {
36
41
  name: string;
37
42
  value: string;
38
43
  package: string;
@@ -48,7 +53,7 @@ interface AdapterDefinition {
48
53
  fields: AdapterField[];
49
54
  }
50
55
 
51
- interface AdapterField {
56
+ export interface AdapterField {
52
57
  key: string;
53
58
  message: string;
54
59
  type?: 'input' | 'password' | 'select';
@@ -56,6 +61,8 @@ interface AdapterField {
56
61
  required?: boolean;
57
62
  envKey?: string; // 如果是敏感信息,对应的环境变量名
58
63
  choices?: { name: string; value: string }[];
64
+ /** 字段归属:endpoint 级(默认,进 endpoints[i])或 shared(顶层共享字段) */
65
+ scope?: 'endpoint' | 'shared';
59
66
  }
60
67
 
61
68
  const ADAPTERS: AdapterDefinition[] = [
@@ -80,6 +87,8 @@ const ADAPTERS: AdapterDefinition[] = [
80
87
  docUrl: adapterDocsUrl('icqq'),
81
88
  fields: [
82
89
  { key: 'name', message: 'QQ 号(与 icqq login 时一致):', required: true, envKey: 'ICQQ_ACCOUNT' },
90
+ // schema 顶层 required: master(所有 endpoint 共享)
91
+ { key: 'master', message: '主人 QQ 号(master,/approve 等管理命令):', required: true, scope: 'shared' },
83
92
  ],
84
93
  },
85
94
  {
@@ -153,6 +162,8 @@ const ADAPTERS: AdapterDefinition[] = [
153
162
  fields: [
154
163
  { key: 'appKey', message: 'App Key:', required: true, envKey: 'DINGTALK_APP_KEY' },
155
164
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'DINGTALK_APP_SECRET' },
165
+ // schema endpoints[i] required 含 robotCode(主动发送 /robot/send 需要)
166
+ { key: 'robotCode', message: 'Robot Code:', required: true, envKey: 'DINGTALK_ROBOT_CODE' },
156
167
  { key: 'webhookPath', message: 'Webhook 路径:', default: '/dingtalk/webhook' },
157
168
  ],
158
169
  },
@@ -167,7 +178,24 @@ const ADAPTERS: AdapterDefinition[] = [
167
178
  fields: [
168
179
  { key: 'appId', message: 'App ID:', required: true, envKey: 'LARK_APP_ID' },
169
180
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'LARK_APP_SECRET' },
170
- { key: 'webhookPath', message: 'Webhook 路径:', default: '/lark/webhook' },
181
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
182
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/lark/webhook', scope: 'shared' },
183
+ ],
184
+ },
185
+ {
186
+ name: 'LINE',
187
+ value: 'line',
188
+ package: '@zhin.js/adapter-line',
189
+ plugin: '@zhin.js/adapter-line',
190
+ needsHttp: true,
191
+ description: 'LINE Messaging API',
192
+ setupHint: '在 LINE Developers Console 创建 Messaging API Channel;Webhook URL 需 HTTPS 公网可达,并关闭 Auto-reply。',
193
+ docUrl: adapterDocsUrl('line'),
194
+ fields: [
195
+ { key: 'channelSecret', message: 'Channel Secret:', required: true, type: 'password', envKey: 'LINE_CHANNEL_SECRET' },
196
+ { key: 'channelAccessToken', message: 'Channel Access Token:', required: true, type: 'password', envKey: 'LINE_CHANNEL_ACCESS_TOKEN' },
197
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
198
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/line/webhook', scope: 'shared' },
171
199
  ],
172
200
  },
173
201
  {
@@ -182,6 +210,54 @@ const ADAPTERS: AdapterDefinition[] = [
182
210
  configure: configureOneBot11Bot,
183
211
  fields: [],
184
212
  },
213
+ {
214
+ name: 'OneBot v12',
215
+ value: 'onebot12',
216
+ package: '@zhin.js/adapter-onebot12',
217
+ plugin: '@zhin.js/adapter-onebot12',
218
+ needsHttp: false,
219
+ description: 'OneBot v12(ws / webhook / wss)',
220
+ setupHint: 'connection: ws 连 OneBot 实现;webhook / wss 由内置 HTTP Host 承接。',
221
+ docUrl: adapterDocsUrl('onebot12'),
222
+ configure: configureOneBot12Bot,
223
+ fields: [],
224
+ },
225
+ {
226
+ name: 'NapCat',
227
+ value: 'napcat',
228
+ package: '@zhin.js/adapter-napcat',
229
+ plugin: '@zhin.js/adapter-napcat',
230
+ needsHttp: false,
231
+ description: 'NapCat(OneBot 11 + NapCat 扩展)',
232
+ setupHint: 'connection: ws 连 NapCat(默认 ws://127.0.0.1:3001);wss / http 由内置 HTTP Host 承接。',
233
+ docUrl: adapterDocsUrl('napcat'),
234
+ configure: configureNapcatBot,
235
+ fields: [],
236
+ },
237
+ {
238
+ name: 'Milky',
239
+ value: 'milky',
240
+ package: '@zhin.js/adapter-milky',
241
+ plugin: '@zhin.js/adapter-milky',
242
+ needsHttp: false,
243
+ description: 'Milky 协议(ws / sse / webhook / wss)',
244
+ setupHint: 'connection: ws 连协议端 ws(s)://baseUrl/event;webhook / wss 由内置 HTTP Host 承接。',
245
+ docUrl: adapterDocsUrl('milky'),
246
+ configure: configureMilkyBot,
247
+ fields: [],
248
+ },
249
+ {
250
+ name: 'Satori',
251
+ value: 'satori',
252
+ package: '@zhin.js/adapter-satori',
253
+ plugin: '@zhin.js/adapter-satori',
254
+ needsHttp: false,
255
+ description: 'Satori 聊天协议(ws / webhook)',
256
+ setupHint: 'connection: ws 连 Satori SDK(默认 http://127.0.0.1:5140);webhook 由内置 HTTP Host 承接。',
257
+ docUrl: adapterDocsUrl('satori'),
258
+ configure: configureSatoriBot,
259
+ fields: [],
260
+ },
185
261
  {
186
262
  name: '微信公众号',
187
263
  value: 'wechat-mp',
@@ -194,7 +270,26 @@ const ADAPTERS: AdapterDefinition[] = [
194
270
  { key: 'appId', message: 'App ID:', required: true, envKey: 'WECHAT_APP_ID' },
195
271
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'WECHAT_APP_SECRET' },
196
272
  { key: 'token', message: '验证 Token:', required: true, envKey: 'WECHAT_TOKEN' },
197
- { key: 'webhookPath', message: 'Webhook 路径:', default: '/wechat/webhook' },
273
+ // schema 顶层共享字段名为 path(endpoints[i] 不含此项)
274
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/wechat/webhook', scope: 'shared' },
275
+ ],
276
+ },
277
+ {
278
+ name: '企业微信',
279
+ value: 'wecom',
280
+ package: '@zhin.js/adapter-wecom',
281
+ plugin: '@zhin.js/adapter-wecom',
282
+ needsHttp: true,
283
+ description: '企业微信自建应用',
284
+ setupHint: '管理后台创建自建应用并配置「接收消息」URL(需公网可达);Token / EncodingAESKey 与后台一致。',
285
+ docUrl: adapterDocsUrl('wecom'),
286
+ fields: [
287
+ { key: 'corpId', message: 'Corp ID:', required: true, envKey: 'WECOM_CORP_ID' },
288
+ { key: 'agentSecret', message: '应用 Secret:', required: true, type: 'password', envKey: 'WECOM_AGENT_SECRET' },
289
+ { key: 'token', message: '回调 Token:', required: true, type: 'password', envKey: 'WECOM_TOKEN' },
290
+ { key: 'encodingAESKey', message: 'EncodingAESKey(43 字符):', required: true, type: 'password', envKey: 'WECOM_AES_KEY' },
291
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
292
+ { key: 'webhookPath', message: '回调路径:', default: '/wecom/callback', scope: 'shared' },
198
293
  ],
199
294
  },
200
295
  {
@@ -227,6 +322,18 @@ const ADAPTERS: AdapterDefinition[] = [
227
322
  configure: configureGitHubEndpoint,
228
323
  fields: [],
229
324
  },
325
+ {
326
+ name: '微信 iLink(个人微信)',
327
+ value: 'weixin-ilink',
328
+ package: '@zhin.js/adapter-weixin-ilink',
329
+ plugin: '@zhin.js/adapter-weixin-ilink',
330
+ needsHttp: false,
331
+ description: '个人微信(iLink / ClawBot 灰度入口,长轮询,仅私聊)',
332
+ setupHint: '需最新版微信 + ClawBot 灰度资格;默认扫码绑定(bot_token 写入 .env),也可手动输入或放 data/weixin-ilink/<name>.json 侧车凭证文件。',
333
+ docUrl: adapterDocsUrl('weixin-ilink'),
334
+ configure: configureWeixinIlinkBot,
335
+ fields: [],
336
+ },
230
337
  ];
231
338
 
232
339
  /**
@@ -253,7 +360,7 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
253
360
  if (input.length === 0) return '至少选择一个适配器';
254
361
  return true;
255
362
  },
256
- pageSize: 14,
363
+ pageSize: 22,
257
364
  }
258
365
  ]);
259
366
 
@@ -374,7 +481,7 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
374
481
  result.instances.push({
375
482
  package: adapterDef.package,
376
483
  instanceKey: adapterDef.value,
377
- config: buildFieldBasedInstanceConfig(adapterDef.value, fieldValues),
484
+ config: buildFieldBasedInstanceConfig(adapterDef, fieldValues),
378
485
  });
379
486
  }
380
487
 
@@ -382,36 +489,56 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
382
489
  }
383
490
 
384
491
  /**
385
- * 字段式适配器(无自定义 configure)的实例配置整形,对齐各插件 schema.json:
386
- * - wechat-mp:webhookPath → path
387
- * - email:smtp/imap 平铺字段 → smtp/imap 嵌套对象
388
- * - 其余:字段直传
492
+ * 字段式适配器(无自定义 configure)的实例配置整形,对齐各插件 schema.json 新格式:
493
+ * - endpoint 级字段(name + 凭据等,字段默认 scope: 'endpoint')进 `endpoints: [{ name, ... }]`
494
+ * - scope: 'shared' 的字段(如 lark/wechat-mp 的 webhookPath)留顶层
495
+ * - wechat-mp:顶层字段名为 path(向导沿用 webhookPath 提问)
496
+ * - email:smtp/imap 平铺字段 → endpoint 内 smtp/imap 嵌套对象
497
+ * - 未单独询问 name 的适配器用 `<adapter>-bot` 兜底
389
498
  */
390
- function buildFieldBasedInstanceConfig(adapterValue: string, values: Record<string, any>): Record<string, unknown> {
391
- if (adapterValue === 'wechat-mp') {
392
- const { webhookPath, ...rest } = values;
393
- return { ...rest, path: webhookPath ?? '/wechat/webhook' };
394
- }
395
- if (adapterValue === 'email') {
499
+ export function buildFieldBasedInstanceConfig(adapter: AdapterDefinition, values: Record<string, any>): Record<string, unknown> {
500
+ if (adapter.value === 'email') {
396
501
  const user = values.user ?? '';
397
502
  const password = values.password ?? '';
398
503
  return {
399
- smtp: {
400
- host: values.smtpHost,
401
- port: Number(values.smtpPort) || 465,
402
- secure: true,
403
- auth: { user, pass: password },
404
- },
405
- imap: {
406
- host: values.imapHost,
407
- port: Number(values.imapPort) || 993,
408
- tls: true,
409
- user,
410
- password,
411
- },
504
+ endpoints: [{
505
+ name: 'email-bot',
506
+ smtp: {
507
+ host: values.smtpHost,
508
+ port: Number(values.smtpPort) || 465,
509
+ secure: true,
510
+ auth: { user, pass: password },
511
+ },
512
+ imap: {
513
+ host: values.imapHost,
514
+ port: Number(values.imapPort) || 993,
515
+ tls: true,
516
+ user,
517
+ password,
518
+ },
519
+ }],
412
520
  };
413
521
  }
414
- return values;
522
+
523
+ const endpoint: Record<string, unknown> = {};
524
+ const shared: Record<string, unknown> = {};
525
+ for (const field of adapter.fields) {
526
+ const value = values[field.key];
527
+ if (value === undefined) continue;
528
+ if (field.scope === 'shared') {
529
+ shared[field.key] = value;
530
+ } else {
531
+ endpoint[field.key] = value;
532
+ }
533
+ }
534
+ if (adapter.value === 'wechat-mp' && shared.webhookPath !== undefined) {
535
+ shared.path = shared.webhookPath;
536
+ delete shared.webhookPath;
537
+ }
538
+ if (!endpoint.name) {
539
+ endpoint.name = `${adapter.value}-bot`;
540
+ }
541
+ return { ...shared, endpoints: [endpoint] };
415
542
  }
416
543
 
417
544
  /**
@@ -464,12 +591,14 @@ export function getAdapterSetupNotes(result: AdapterSetupResult): string[] {
464
591
  notes.push('Telegram polling: 本地开发可直接 pnpm dev,无需公网');
465
592
  }
466
593
  if (context === 'github') {
467
- if (entry.webhook_secret) {
594
+ const endpoints = Array.isArray(entry.endpoints) ? entry.endpoints as Array<Record<string, unknown>> : [];
595
+ const firstEndpoint = endpoints[0] ?? {};
596
+ if (firstEndpoint.webhook_secret) {
468
597
  notes.push('GitHub Webhook: App 设置 URL 为 https://<域名>/github/webhook');
469
598
  } else {
470
599
  notes.push('GitHub polling: 无 Webhook 时将轮询 Events API(默认 60s)');
471
600
  }
472
- if (entry.app_id) {
601
+ if (firstEndpoint.app_id) {
473
602
  notes.push('GitHub App: 私钥路径需存在,且 App 已安装到目标仓库');
474
603
  } else {
475
604
  notes.push('GitHub gh CLI: 运行 gh auth login 完成认证');
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export { DATABASE_PACKAGES } from './types.js';
4
4
  export type { AdapterSetupResult, AdapterPluginInstance } from './adapter.js';
5
5
  export {
6
6
  configureAdapters,
7
+ buildFieldBasedInstanceConfig,
7
8
  generateAdapterEnvVars,
8
9
  collectAdapterPluginConfigs,
9
10
  collectAdapterPluginManifest,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 微信 iLink(ClawBot)扫码登录流程 — 向导内联实现。
3
+ * 协议与 plugins/adapters/weixin-ilink/src/login.ts 一致(SSOT),
4
+ * 此处复制最小 HTTP 面,避免向导在适配器包安装前产生依赖。
5
+ */
6
+
7
+ export interface IlinkQrLoginResult {
8
+ readonly botToken: string;
9
+ readonly ilinkUserId?: string;
10
+ readonly ilinkBotId?: string;
11
+ readonly baseUrl: string;
12
+ }
13
+
14
+ interface QrCodeResponse {
15
+ qrcode: string;
16
+ qrcode_img_content: string;
17
+ }
18
+
19
+ type QrStatus =
20
+ | 'wait'
21
+ | 'scaned'
22
+ | 'confirmed'
23
+ | 'expired'
24
+ | 'scaned_but_redirect'
25
+ | 'need_verifycode'
26
+ | 'verify_code_blocked'
27
+ | 'binded_redirect';
28
+
29
+ interface StatusResponse {
30
+ status: QrStatus;
31
+ bot_token?: string;
32
+ ilink_endpoint_id?: string;
33
+ baseurl?: string;
34
+ ilink_user_id?: string;
35
+ redirect_host?: string;
36
+ }
37
+
38
+ const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
39
+ const DEFAULT_BOT_TYPE = '3';
40
+ const POLL_INTERVAL_MS = 500;
41
+ const QR_LONG_POLL_TIMEOUT_MS = 35_000;
42
+
43
+ export interface IlinkQrLoginOptions {
44
+ readonly baseUrl?: string;
45
+ readonly timeoutMs?: number;
46
+ /** 拿到二维码内容(URL)后回调,由调用方负责展示(终端 QR / 链接)。 */
47
+ readonly onQrCode: (url: string) => void | Promise<void>;
48
+ /** 扫码状态变化提示(scaned → 已扫码待确认)。 */
49
+ readonly onStatus?: (status: QrStatus) => void;
50
+ readonly fetchImpl?: typeof fetch;
51
+ readonly sleep?: (ms: number) => Promise<void>;
52
+ }
53
+
54
+ export class IlinkQrLoginError extends Error {
55
+ constructor(
56
+ message: string,
57
+ readonly reason: 'expired' | 'timeout' | 'network' | 'protocol',
58
+ ) {
59
+ super(message);
60
+ this.name = 'IlinkQrLoginError';
61
+ }
62
+ }
63
+
64
+ /** 与 adapter login.ts 的 buildBaseInfo 对齐的最小结构。 */
65
+ function buildBaseInfo(): Record<string, string> {
66
+ return { channel_version: '0.0.0', bot_agent: 'Zhin.js' };
67
+ }
68
+
69
+ /**
70
+ * 微信 iLink 扫码登录:获取二维码 → 展示 → 长轮询直至 confirmed。
71
+ * 成功返回 bot_token 等凭证;过期/超时/网络失败抛 IlinkQrLoginError。
72
+ */
73
+ export async function loginWithIlinkQr(options: IlinkQrLoginOptions): Promise<IlinkQrLoginResult> {
74
+ const fetchImpl = options.fetchImpl ?? fetch;
75
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
76
+ const apiBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
77
+ const timeoutMs = options.timeoutMs ?? 480_000;
78
+
79
+ let qr: QrCodeResponse;
80
+ try {
81
+ const res = await fetchImpl(
82
+ `${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(DEFAULT_BOT_TYPE)}`,
83
+ {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({ local_token_list: [], base_info: buildBaseInfo() }),
87
+ },
88
+ );
89
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
90
+ qr = await res.json() as QrCodeResponse;
91
+ } catch (error) {
92
+ throw new IlinkQrLoginError(
93
+ `获取登录二维码失败:${error instanceof Error ? error.message : String(error)}`,
94
+ 'network',
95
+ );
96
+ }
97
+
98
+ const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
99
+ await options.onQrCode(qrcodeUrl);
100
+
101
+ const deadline = Date.now() + timeoutMs;
102
+ let currentBaseUrl = apiBaseUrl;
103
+ let lastStatus: QrStatus | undefined;
104
+
105
+ while (Date.now() < deadline) {
106
+ let status: StatusResponse;
107
+ try {
108
+ const res = await fetchImpl(
109
+ `${currentBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr.qrcode)}`,
110
+ { signal: AbortSignal.timeout(QR_LONG_POLL_TIMEOUT_MS) },
111
+ );
112
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
113
+ status = await res.json() as StatusResponse;
114
+ } catch {
115
+ // 长轮询超时/瞬断视为继续等待
116
+ await sleep(POLL_INTERVAL_MS);
117
+ continue;
118
+ }
119
+
120
+ if (status.status !== lastStatus) {
121
+ lastStatus = status.status;
122
+ options.onStatus?.(status.status);
123
+ }
124
+
125
+ if (status.status === 'scaned_but_redirect' && status.redirect_host) {
126
+ currentBaseUrl = status.redirect_host.startsWith('http')
127
+ ? status.redirect_host
128
+ : `https://${status.redirect_host}`;
129
+ }
130
+
131
+ if (status.status === 'confirmed' || status.status === 'binded_redirect') {
132
+ if (!status.bot_token) {
133
+ throw new IlinkQrLoginError('扫码成功但未返回 bot_token,请重试或改用手动输入', 'protocol');
134
+ }
135
+ return {
136
+ botToken: status.bot_token,
137
+ ...(status.ilink_user_id ? { ilinkUserId: status.ilink_user_id } : {}),
138
+ ...(status.ilink_endpoint_id ? { ilinkBotId: status.ilink_endpoint_id } : {}),
139
+ baseUrl: status.baseurl ?? currentBaseUrl,
140
+ };
141
+ }
142
+
143
+ if (status.status === 'expired') {
144
+ throw new IlinkQrLoginError('二维码已过期', 'expired');
145
+ }
146
+
147
+ await sleep(POLL_INTERVAL_MS);
148
+ }
149
+
150
+ throw new IlinkQrLoginError('微信扫码登录超时', 'timeout');
151
+ }