@zhin.js/adapter-weixin-ilink 4.0.0 → 4.0.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @zhin.js/adapter-weixin-ilink
2
2
 
3
+ ## 4.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
17
+ - 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
18
+ - Updated dependencies [cdf64e7]
19
+ - Updated dependencies [5691aba]
20
+ - Updated dependencies [078e3f7]
21
+ - Updated dependencies [50497a5]
22
+ - Updated dependencies [9c997b2]
23
+ - Updated dependencies [09d4f25]
24
+ - Updated dependencies [43485a9]
25
+ - Updated dependencies [f0ec5ab]
26
+ - Updated dependencies [3e925d0]
27
+ - Updated dependencies [fa66c4c]
28
+ - Updated dependencies [fa66c4c]
29
+ - Updated dependencies [6cb6152]
30
+ - @zhin.js/command@1.0.3
31
+ - @zhin.js/plugin-runtime@1.1.1
32
+ - zhin.js@5.0.1
33
+ - @zhin.js/adapter@1.1.1
34
+ - @zhin.js/core@1.4.1
35
+
3
36
  ## 4.0.0
4
37
 
5
38
  ### Patch Changes
@@ -8,6 +8,7 @@ import {
8
8
  resolveWeixinIlinkConfig,
9
9
  type WeixinIlinkAdapterConfig,
10
10
  } from '../src/protocol.js';
11
+ import { weixinIlinkRuntimeStateToken } from '../src/weixin-ilink-runtime-state.js';
11
12
 
12
13
  export { WeixinIlinkEndpoint } from '../src/endpoint.js';
13
14
  export type {
@@ -20,11 +21,22 @@ export type {
20
21
 
21
22
  export default defineAdapter<WeixinIlinkAdapterConfig>({
22
23
  capabilities: ['inbound', 'outbound'],
24
+ // base64 / 远程 URL 落盘后走 CDN 上传(sendWeixinMediaFile);微信无卡片交互面,交互段降级纯文本。
25
+ segments: {
26
+ outboundMedia: ['base64', 'url', 'upload'],
27
+ interactive: 'text',
28
+ },
23
29
  create(context) {
30
+ const config = resolveWeixinIlinkConfig(context.config);
31
+ // 注册到插件运行时状态(weixin-ilink endpoint list 的"运行中"数据源)
32
+ context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
33
+ name: config.name,
34
+ mode: config.connection,
35
+ });
24
36
  return new WeixinIlinkEndpoint({
25
37
  id: context.id,
26
38
  gateway: context.use(messageGatewayToken),
27
- config: resolveWeixinIlinkConfig(context.config),
39
+ config,
28
40
  });
29
41
  },
30
42
  });
@@ -0,0 +1,3 @@
1
+ import { weixinIlinkEndpointCommands } from '../../../src/weixin-ilink-endpoint-commands.js';
2
+
3
+ export default weixinIlinkEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { weixinIlinkEndpointCommands } from '../../src/weixin-ilink-endpoint-commands.js';
2
+
3
+ export default weixinIlinkEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { weixinIlinkEndpointCommands } from '../../../src/weixin-ilink-endpoint-commands.js';
2
+
3
+ export default weixinIlinkEndpointCommands.remove;
@@ -1 +1 @@
1
- export { setContextToken, getContextToken, restoreContextTokens, clearContextTokensForAccount, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
1
+ export { setContextToken, getContextToken, listContextTokenUserIds, restoreContextTokens, clearContextTokensForAccount, flushContextTokenPersist, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
@@ -1 +1 @@
1
- export { setContextToken, getContextToken, restoreContextTokens, clearContextTokensForAccount, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
1
+ export { setContextToken, getContextToken, listContextTokenUserIds, restoreContextTokens, clearContextTokensForAccount, flushContextTokenPersist, bodyFromItemList, isMediaItem, } from "./weixin-inbound.js";
package/lib/endpoint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
4
4
  import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
@@ -13,7 +13,7 @@ export interface WeixinIlinkEndpointOptions {
13
13
  readonly id: CapabilityId;
14
14
  readonly gateway: MessageGateway;
15
15
  readonly config: ResolvedWeixinIlinkConfig;
16
- readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig) => Promise<WeixinIlinkCredentials>;
16
+ readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
17
17
  /** Test / internal: override network side effects. */
18
18
  readonly notifyStart?: WeixinIlinkNotifyStart;
19
19
  readonly notifyStop?: WeixinIlinkNotifyStop;
@@ -22,8 +22,11 @@ export interface WeixinIlinkEndpointOptions {
22
22
  }
23
23
  export declare class WeixinIlinkEndpoint implements EndpointInstance {
24
24
  #private;
25
+ readonly management: EndpointManagement;
25
26
  constructor(options: WeixinIlinkEndpointOptions);
26
27
  get hasCredentials(): boolean;
28
+ /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
29
+ get configName(): string;
27
30
  apiBaseUrl(): string;
28
31
  cdnBaseUrl(): string;
29
32
  start(): Promise<void>;
package/lib/endpoint.js CHANGED
@@ -9,7 +9,7 @@ import { getUpdates, notifyStart, notifyStop, sendTyping } from './ilink-api.js'
9
9
  import { configureIlinkMeta } from './ilink-meta.js';
10
10
  import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
11
11
  import { resolveCredentials } from './login.js';
12
- import { getContextToken, restoreContextTokens, setContextToken, } from './context-store.js';
12
+ import { getContextToken, listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
13
13
  import { WeixinConfigManager } from './ilink-config-cache.js';
14
14
  import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
15
15
  import { downloadMediaFromItem } from './media-download.js';
@@ -24,6 +24,9 @@ const MAX_CONSECUTIVE_FAILURES = 3;
24
24
  const BACKOFF_DELAY_MS = 30_000;
25
25
  const RETRY_DELAY_MS = 2_000;
26
26
  const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
27
+ /** 入站媒体保留 24h,每小时清扫一次。 */
28
+ const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
29
+ const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
27
30
  export class WeixinIlinkEndpoint {
28
31
  #options;
29
32
  #resolveCredentials;
@@ -34,9 +37,12 @@ export class WeixinIlinkEndpoint {
34
37
  #creds = null;
35
38
  #pollAbort;
36
39
  #pollPromise;
40
+ #loginAbort;
37
41
  #configManager;
42
+ #mediaSweepTimer;
38
43
  #open = false;
39
44
  #started = false;
45
+ management = createWeixinIlinkEndpointManagement(this);
40
46
  constructor(options) {
41
47
  this.#options = options;
42
48
  this.#resolveCredentials = options.resolveCredentials ?? resolveCredentials;
@@ -48,6 +54,10 @@ export class WeixinIlinkEndpoint {
48
54
  get hasCredentials() {
49
55
  return Boolean(this.#creds?.botToken);
50
56
  }
57
+ /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
58
+ get configName() {
59
+ return this.#options.config.name;
60
+ }
51
61
  apiBaseUrl() {
52
62
  return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
53
63
  }
@@ -60,7 +70,9 @@ export class WeixinIlinkEndpoint {
60
70
  this.#started = true;
61
71
  try {
62
72
  configureIlinkMeta({ botAgent: this.#options.config.botAgent });
63
- this.#creds = await this.#resolveCredentials(this.#options.config);
73
+ // QR 登录最长 8 分钟:stop() 必须能打断
74
+ this.#loginAbort = new AbortController();
75
+ this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
64
76
  restoreContextTokens(this.#options.config.name);
65
77
  await this.#notifyStart({
66
78
  baseUrl: this.apiBaseUrl(),
@@ -69,6 +81,7 @@ export class WeixinIlinkEndpoint {
69
81
  this.#configManager = new WeixinConfigManager({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken }, (msg) => logger.debug(msg));
70
82
  this.#pollAbort = new AbortController();
71
83
  this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
84
+ this.#startMediaSweep();
72
85
  logger.info(formatCompact({
73
86
  op: 'connect',
74
87
  endpoint: this.#options.config.name,
@@ -88,6 +101,7 @@ export class WeixinIlinkEndpoint {
88
101
  }
89
102
  async stop() {
90
103
  this.#open = false;
104
+ this.#loginAbort?.abort();
91
105
  this.#pollAbort?.abort();
92
106
  try {
93
107
  await this.#pollPromise;
@@ -95,6 +109,9 @@ export class WeixinIlinkEndpoint {
95
109
  catch {
96
110
  /* poll loop exit */
97
111
  }
112
+ this.#stopMediaSweep();
113
+ // 防抖中的 context token 落盘,避免 stop 丢尾部写入
114
+ flushContextTokenPersist(this.#options.config.name);
98
115
  if (this.#creds?.botToken) {
99
116
  try {
100
117
  await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
@@ -274,8 +291,8 @@ export class WeixinIlinkEndpoint {
274
291
  continue;
275
292
  }
276
293
  consecutiveFailures += 1;
294
+ // 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
277
295
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
278
- consecutiveFailures = 0;
279
296
  await sleep(BACKOFF_DELAY_MS, abortSignal);
280
297
  }
281
298
  else {
@@ -284,13 +301,15 @@ export class WeixinIlinkEndpoint {
284
301
  continue;
285
302
  }
286
303
  consecutiveFailures = 0;
287
- if (resp.get_updates_buf) {
288
- getUpdatesBuf = resp.get_updates_buf;
289
- saveSyncBuf(endpointName, getUpdatesBuf);
290
- }
304
+ // 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
305
+ const nextBuf = resp.get_updates_buf;
291
306
  for (const inbound of resp.msgs ?? []) {
292
307
  await this.#handleInboundMessage(inbound);
293
308
  }
309
+ if (nextBuf) {
310
+ getUpdatesBuf = nextBuf;
311
+ saveSyncBuf(endpointName, getUpdatesBuf);
312
+ }
294
313
  }
295
314
  catch (err) {
296
315
  if (abortSignal.aborted)
@@ -302,7 +321,6 @@ export class WeixinIlinkEndpoint {
302
321
  error: err instanceof Error ? err.message : String(err),
303
322
  }));
304
323
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
305
- consecutiveFailures = 0;
306
324
  await sleep(BACKOFF_DELAY_MS, abortSignal);
307
325
  }
308
326
  else {
@@ -361,4 +379,62 @@ export class WeixinIlinkEndpoint {
361
379
  fs.writeFileSync(filePath, buffer);
362
380
  return { path: filePath };
363
381
  }
382
+ /** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
383
+ #startMediaSweep() {
384
+ this.#stopMediaSweep();
385
+ const sweep = () => this.#sweepInboundMedia();
386
+ sweep();
387
+ this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
388
+ this.#mediaSweepTimer.unref?.();
389
+ }
390
+ #stopMediaSweep() {
391
+ if (this.#mediaSweepTimer) {
392
+ clearInterval(this.#mediaSweepTimer);
393
+ this.#mediaSweepTimer = undefined;
394
+ }
395
+ }
396
+ #sweepInboundMedia() {
397
+ const dir = path.join(resolveStateDir(), 'media', 'inbound');
398
+ let entries;
399
+ try {
400
+ entries = fs.readdirSync(dir, { withFileTypes: true });
401
+ }
402
+ catch {
403
+ return;
404
+ }
405
+ const now = Date.now();
406
+ for (const entry of entries) {
407
+ if (!entry.isFile())
408
+ continue;
409
+ const filePath = path.join(dir, entry.name);
410
+ try {
411
+ const stat = fs.statSync(filePath);
412
+ if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS)
413
+ continue;
414
+ fs.unlinkSync(filePath);
415
+ logger.debug(formatCompact({
416
+ op: 'media_sweep',
417
+ endpoint: this.#options.config.name,
418
+ file: entry.name,
419
+ }));
420
+ }
421
+ catch {
422
+ /* 单个文件失败不阻塞整体清扫 */
423
+ }
424
+ }
425
+ }
426
+ }
427
+ function createWeixinIlinkEndpointManagement(endpoint) {
428
+ return Object.freeze({
429
+ // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
430
+ // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
431
+ // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
432
+ async listFriends() {
433
+ return listContextTokenUserIds(endpoint.configName).map((userId) => ({
434
+ user_id: userId,
435
+ nickname: userId,
436
+ remark: 'ilink: 从会话 context_token 推导,非通讯录',
437
+ }));
438
+ },
439
+ });
364
440
  }
@@ -18,6 +18,7 @@ export declare function apiGetFetch(params: {
18
18
  endpoint: string;
19
19
  timeoutMs?: number;
20
20
  label: string;
21
+ abortSignal?: AbortSignal;
21
22
  }): Promise<string>;
22
23
  /**
23
24
  * Common fetch wrapper: POST JSON to a Weixin API endpoint.
package/lib/ilink-api.js CHANGED
@@ -54,11 +54,12 @@ export async function apiGetFetch(params) {
54
54
  const t = controller != null && timeoutMs != null
55
55
  ? setTimeout(() => controller.abort(), timeoutMs)
56
56
  : undefined;
57
+ const { signal, cleanup } = combineAbortSignals(controller, params.abortSignal);
57
58
  try {
58
59
  const res = await fetch(url.toString(), {
59
60
  method: "GET",
60
61
  headers: hdrs,
61
- ...(controller ? { signal: controller.signal } : {}),
62
+ ...(signal ? { signal } : {}),
62
63
  });
63
64
  if (t !== undefined)
64
65
  clearTimeout(t);
@@ -74,6 +75,9 @@ export async function apiGetFetch(params) {
74
75
  clearTimeout(t);
75
76
  throw err;
76
77
  }
78
+ finally {
79
+ cleanup();
80
+ }
77
81
  }
78
82
  function combineAbortSignals(internal, external) {
79
83
  if (!internal && !external)
package/lib/login.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { type WeixinIlinkCredentials } from './credentials.js';
2
2
  import type { ResolvedWeixinIlinkConfig } from './protocol.js';
3
- export declare function resolveCredentials(config: ResolvedWeixinIlinkConfig): Promise<WeixinIlinkCredentials>;
4
- export declare function loginWithQr(config: ResolvedWeixinIlinkConfig): Promise<WeixinIlinkCredentials>;
3
+ export declare function resolveCredentials(config: ResolvedWeixinIlinkConfig, signal?: AbortSignal): Promise<WeixinIlinkCredentials>;
4
+ export declare function loginWithQr(config: ResolvedWeixinIlinkConfig, signal?: AbortSignal): Promise<WeixinIlinkCredentials>;
package/lib/login.js CHANGED
@@ -21,7 +21,7 @@ async function fetchQRCode(apiBaseUrl, botType) {
21
21
  });
22
22
  return JSON.parse(rawText);
23
23
  }
24
- async function pollQRStatus(apiBaseUrl, qrcode) {
24
+ async function pollQRStatus(apiBaseUrl, qrcode, signal) {
25
25
  try {
26
26
  const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
27
27
  const rawText = await apiGetFetch({
@@ -29,18 +29,42 @@ async function pollQRStatus(apiBaseUrl, qrcode) {
29
29
  endpoint,
30
30
  timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
31
31
  label: 'pollQRStatus',
32
+ abortSignal: signal,
32
33
  });
33
34
  return JSON.parse(rawText);
34
35
  }
35
36
  catch (err) {
36
37
  if (err instanceof Error && err.name === 'AbortError') {
38
+ // 外部 stop 的 abort 不能吞掉当普通长轮询超时
39
+ if (signal?.aborted)
40
+ throw err;
37
41
  return { status: 'wait' };
38
42
  }
39
43
  logger.warn(`pollQRStatus error: ${String(err)}`);
40
44
  return { status: 'wait' };
41
45
  }
42
46
  }
43
- export async function resolveCredentials(config) {
47
+ function abortError() {
48
+ return Object.assign(new Error('扫码登录已取消'), { name: 'AbortError' });
49
+ }
50
+ function sleepWithAbort(ms, signal) {
51
+ return new Promise((resolve, reject) => {
52
+ if (signal?.aborted) {
53
+ reject(abortError());
54
+ return;
55
+ }
56
+ const timer = setTimeout(() => {
57
+ signal?.removeEventListener('abort', onAbort);
58
+ resolve();
59
+ }, ms);
60
+ const onAbort = () => {
61
+ clearTimeout(timer);
62
+ reject(abortError());
63
+ };
64
+ signal?.addEventListener('abort', onAbort, { once: true });
65
+ });
66
+ }
67
+ export async function resolveCredentials(config, signal) {
44
68
  const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
45
69
  if (envToken) {
46
70
  const stored = loadCredentials(config.name);
@@ -58,9 +82,9 @@ export async function resolveCredentials(config) {
58
82
  baseUrl: config.baseUrl ?? stored.baseUrl ?? DEFAULT_API_BASE_URL,
59
83
  };
60
84
  }
61
- return loginWithQr(config);
85
+ return loginWithQr(config, signal);
62
86
  }
63
- export async function loginWithQr(config) {
87
+ export async function loginWithQr(config, signal) {
64
88
  const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
65
89
  const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
66
90
  const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
@@ -68,7 +92,10 @@ export async function loginWithQr(config) {
68
92
  const deadline = Date.now() + LOGIN_TIMEOUT_MS;
69
93
  let currentBaseUrl = apiBaseUrl;
70
94
  while (Date.now() < deadline) {
71
- const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
95
+ // stop() 的 AbortSignal:不打满 8 分钟,立即退出登录循环
96
+ if (signal?.aborted)
97
+ throw abortError();
98
+ const status = await pollQRStatus(currentBaseUrl, qr.qrcode, signal);
72
99
  if (status.status === 'scaned_but_redirect' && status.redirect_host) {
73
100
  currentBaseUrl = status.redirect_host.startsWith('http')
74
101
  ? status.redirect_host
@@ -93,7 +120,7 @@ export async function loginWithQr(config) {
93
120
  if (status.status === 'expired') {
94
121
  throw new Error('二维码已过期,请重启 bot 重新登录');
95
122
  }
96
- await new Promise((r) => setTimeout(r, 500));
123
+ await sleepWithAbort(500, signal);
97
124
  }
98
125
  throw new Error('微信扫码登录超时');
99
126
  }
@@ -0,0 +1 @@
1
+ export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { weixinIlinkRuntimeStateToken } from './weixin-ilink-runtime-state.js';
8
+ export const weixinIlinkEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'weixin-ilink',
10
+ adapterDisplayName: 'Weixin iLink',
11
+ fields: [
12
+ { key: 'botToken', required: true, env: true, description: 'iLink bot token' },
13
+ ],
14
+ running: (use) => use(weixinIlinkRuntimeStateToken).endpoints.values(),
15
+ describeEntry: (entry) => `botToken: ${String(entry.botToken)}`,
16
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const weixinIlinkRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const weixinIlinkRuntimeStateToken = defineEndpointRuntimeStateToken('weixin-ilink');
@@ -1,4 +1,6 @@
1
1
  import { type WeixinMessage, type MessageItem } from './ilink-types.js';
2
+ /** 立即落盘所有待写(endpoint stop / 测试用)。 */
3
+ export declare function flushContextTokenPersist(accountId?: string): void;
2
4
  /**
3
5
  * Restore persisted context tokens for an account into the in-memory map.
4
6
  * Called once during gateway startAccount to survive restarts.
@@ -6,10 +8,16 @@ import { type WeixinMessage, type MessageItem } from './ilink-types.js';
6
8
  export declare function restoreContextTokens(accountId: string): void;
7
9
  /** Remove all context tokens for a given account (memory + disk). */
8
10
  export declare function clearContextTokensForAccount(accountId: string): void;
9
- /** Store a context token for a given account+user pair (memory + disk). */
11
+ /** Store a context token for a given account+user pair (memory + debounced disk). */
10
12
  export declare function setContextToken(accountId: string, userId: string, token: string): void;
11
13
  /** Retrieve the cached context token for a given account+user pair. */
12
14
  export declare function getContextToken(accountId: string, userId: string): string | undefined;
15
+ /**
16
+ * List all peer userIds that hold a context token for the given account
17
+ * (memory map, already merged with the disk-backed file on restore).
18
+ * Personal WeChat has no contact/profile API; this is the only peer catalog.
19
+ */
20
+ export declare function listContextTokenUserIds(accountId: string): string[];
13
21
  /**
14
22
  * Find all accountIds that have an active contextToken for the given userId.
15
23
  * Used to infer the sending bot account from the recipient address when
@@ -41,6 +41,34 @@ function persistContextTokens(accountId) {
41
41
  logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
42
42
  }
43
43
  }
44
+ /**
45
+ * 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
46
+ * 按 account 防抖合并,500ms 内多次写入只落盘一次。
47
+ */
48
+ const PERSIST_DEBOUNCE_MS = 500;
49
+ const persistTimers = new Map();
50
+ function schedulePersistContextTokens(accountId) {
51
+ const existing = persistTimers.get(accountId);
52
+ if (existing)
53
+ clearTimeout(existing);
54
+ const timer = setTimeout(() => {
55
+ persistTimers.delete(accountId);
56
+ persistContextTokens(accountId);
57
+ }, PERSIST_DEBOUNCE_MS);
58
+ // 不让防抖定时器拖住进程退出
59
+ timer.unref?.();
60
+ persistTimers.set(accountId, timer);
61
+ }
62
+ /** 立即落盘所有待写(endpoint stop / 测试用)。 */
63
+ export function flushContextTokenPersist(accountId) {
64
+ for (const [id, timer] of [...persistTimers]) {
65
+ if (accountId !== undefined && id !== accountId)
66
+ continue;
67
+ clearTimeout(timer);
68
+ persistTimers.delete(id);
69
+ persistContextTokens(id);
70
+ }
71
+ }
44
72
  /**
45
73
  * Restore persisted context tokens for an account into the in-memory map.
46
74
  * Called once during gateway startAccount to survive restarts.
@@ -83,12 +111,12 @@ export function clearContextTokensForAccount(accountId) {
83
111
  }
84
112
  logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
85
113
  }
86
- /** Store a context token for a given account+user pair (memory + disk). */
114
+ /** Store a context token for a given account+user pair (memory + debounced disk). */
87
115
  export function setContextToken(accountId, userId, token) {
88
116
  const k = contextTokenKey(accountId, userId);
89
117
  logger.debug(`setContextToken: key=${k}`);
90
118
  contextTokenStore.set(k, token);
91
- persistContextTokens(accountId);
119
+ schedulePersistContextTokens(accountId);
92
120
  }
93
121
  /** Retrieve the cached context token for a given account+user pair. */
94
122
  export function getContextToken(accountId, userId) {
@@ -97,6 +125,20 @@ export function getContextToken(accountId, userId) {
97
125
  logger.debug(`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`);
98
126
  return val;
99
127
  }
128
+ /**
129
+ * List all peer userIds that hold a context token for the given account
130
+ * (memory map, already merged with the disk-backed file on restore).
131
+ * Personal WeChat has no contact/profile API; this is the only peer catalog.
132
+ */
133
+ export function listContextTokenUserIds(accountId) {
134
+ const prefix = `${accountId}:`;
135
+ const ids = [];
136
+ for (const k of contextTokenStore.keys()) {
137
+ if (k.startsWith(prefix))
138
+ ids.push(k.slice(prefix.length));
139
+ }
140
+ return ids;
141
+ }
100
142
  /**
101
143
  * Find all accountIds that have an active contextToken for the given userId.
102
144
  * Used to infer the sending bot account from the recipient address when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-weixin-ilink",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
6
6
  "main": "./lib/index.js",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "files": [
30
30
  "adapters",
31
+ "commands",
31
32
  "plugin.ts",
32
33
  "schema.json",
33
34
  "src",
@@ -47,16 +48,17 @@
47
48
  }
48
49
  },
49
50
  "dependencies": {
50
- "@zhin.js/adapter": "1.1.0",
51
- "@zhin.js/core": "1.4.0",
51
+ "@zhin.js/adapter": "1.1.1",
52
+ "@zhin.js/command": "1.0.3",
53
+ "@zhin.js/core": "1.4.1",
52
54
  "@zhin.js/logger": "1.0.75",
53
- "@zhin.js/plugin-runtime": "1.1.0"
55
+ "@zhin.js/plugin-runtime": "1.1.1"
54
56
  },
55
57
  "peerDependencies": {
56
- "@zhin.js/adapter": "1.1.0",
57
- "@zhin.js/core": "1.4.0",
58
- "@zhin.js/plugin-runtime": "1.1.0",
59
- "zhin.js": "5.0.0"
58
+ "@zhin.js/adapter": "1.1.1",
59
+ "@zhin.js/core": "1.4.1",
60
+ "@zhin.js/plugin-runtime": "1.1.1",
61
+ "zhin.js": "5.0.1"
60
62
  },
61
63
  "peerDependenciesMeta": {
62
64
  "zhin.js": {
@@ -82,6 +84,10 @@
82
84
  {
83
85
  "package": "@zhin.js/adapter",
84
86
  "api": "^1.0.0"
87
+ },
88
+ {
89
+ "package": "@zhin.js/command",
90
+ "api": "^1.0.0"
85
91
  }
86
92
  ],
87
93
  "plugins": []
package/plugin.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
3
+ import { weixinIlinkRuntimeStateToken } from './src/weixin-ilink-runtime-state.js';
2
4
 
3
5
  export default definePlugin({
4
6
  name: 'weixin-ilink',
5
7
  metadata: {
6
8
  displayName: 'Weixin iLink Adapter',
7
9
  },
10
+ setup(context) {
11
+ // 运行中 endpoint 注册表(weixin-ilink endpoint list 的"运行中"数据源)
12
+ context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
13
+ },
8
14
  });
@@ -1,8 +1,10 @@
1
1
  export {
2
2
  setContextToken,
3
3
  getContextToken,
4
+ listContextTokenUserIds,
4
5
  restoreContextTokens,
5
6
  clearContextTokensForAccount,
7
+ flushContextTokenPersist,
6
8
  bodyFromItemList,
7
9
  isMediaItem,
8
10
  } from "./weixin-inbound.js";
package/src/endpoint.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import crypto from 'node:crypto';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import type { EndpointInstance } from '@zhin.js/adapter';
7
+ import type { EndpointFriend, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
8
8
  import type { MessageGateway } from '@zhin.js/core/runtime';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
10
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -19,8 +19,10 @@ import {
19
19
  import { resolveCredentials } from './login.js';
20
20
  import {
21
21
  getContextToken,
22
+ listContextTokenUserIds,
22
23
  restoreContextTokens,
23
24
  setContextToken,
25
+ flushContextTokenPersist,
24
26
  } from './context-store.js';
25
27
  import { WeixinConfigManager } from './ilink-config-cache.js';
26
28
  import {
@@ -53,6 +55,9 @@ const MAX_CONSECUTIVE_FAILURES = 3;
53
55
  const BACKOFF_DELAY_MS = 30_000;
54
56
  const RETRY_DELAY_MS = 2_000;
55
57
  const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
58
+ /** 入站媒体保留 24h,每小时清扫一次。 */
59
+ const INBOUND_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
60
+ const MEDIA_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
56
61
 
57
62
  export type WeixinIlinkSendText = typeof sendMessageWeixin;
58
63
  export type WeixinIlinkNotifyStart = typeof notifyStart;
@@ -65,6 +70,7 @@ export interface WeixinIlinkEndpointOptions {
65
70
  readonly config: ResolvedWeixinIlinkConfig;
66
71
  readonly resolveCredentials?: (
67
72
  config: ResolvedWeixinIlinkConfig,
73
+ signal?: AbortSignal,
68
74
  ) => Promise<WeixinIlinkCredentials>;
69
75
  /** Test / internal: override network side effects. */
70
76
  readonly notifyStart?: WeixinIlinkNotifyStart;
@@ -77,6 +83,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
77
83
  readonly #options: WeixinIlinkEndpointOptions;
78
84
  readonly #resolveCredentials: (
79
85
  config: ResolvedWeixinIlinkConfig,
86
+ signal?: AbortSignal,
80
87
  ) => Promise<WeixinIlinkCredentials>;
81
88
  readonly #notifyStart: WeixinIlinkNotifyStart;
82
89
  readonly #notifyStop: WeixinIlinkNotifyStop;
@@ -85,9 +92,12 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
85
92
  #creds: WeixinIlinkCredentials | null = null;
86
93
  #pollAbort?: AbortController;
87
94
  #pollPromise?: Promise<void>;
95
+ #loginAbort?: AbortController;
88
96
  #configManager?: WeixinConfigManager;
97
+ #mediaSweepTimer?: ReturnType<typeof setInterval>;
89
98
  #open = false;
90
99
  #started = false;
100
+ readonly management: EndpointManagement = createWeixinIlinkEndpointManagement(this);
91
101
 
92
102
  constructor(options: WeixinIlinkEndpointOptions) {
93
103
  this.#options = options;
@@ -102,6 +112,11 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
102
112
  return Boolean(this.#creds?.botToken);
103
113
  }
104
114
 
115
+ /** Live endpoint 名(context token 按 account 名分桶,management 推导对端用)。 */
116
+ get configName(): string {
117
+ return this.#options.config.name;
118
+ }
119
+
105
120
  apiBaseUrl(): string {
106
121
  return this.#creds?.baseUrl ?? this.#options.config.baseUrl;
107
122
  }
@@ -115,7 +130,9 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
115
130
  this.#started = true;
116
131
  try {
117
132
  configureIlinkMeta({ botAgent: this.#options.config.botAgent });
118
- this.#creds = await this.#resolveCredentials(this.#options.config);
133
+ // QR 登录最长 8 分钟:stop() 必须能打断
134
+ this.#loginAbort = new AbortController();
135
+ this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
119
136
  restoreContextTokens(this.#options.config.name);
120
137
 
121
138
  await this.#notifyStart({
@@ -130,6 +147,7 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
130
147
 
131
148
  this.#pollAbort = new AbortController();
132
149
  this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
150
+ this.#startMediaSweep();
133
151
  logger.info(formatCompact({
134
152
  op: 'connect',
135
153
  endpoint: this.#options.config.name,
@@ -151,12 +169,16 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
151
169
 
152
170
  async stop(): Promise<void> {
153
171
  this.#open = false;
172
+ this.#loginAbort?.abort();
154
173
  this.#pollAbort?.abort();
155
174
  try {
156
175
  await this.#pollPromise;
157
176
  } catch {
158
177
  /* poll loop exit */
159
178
  }
179
+ this.#stopMediaSweep();
180
+ // 防抖中的 context token 落盘,避免 stop 丢尾部写入
181
+ flushContextTokenPersist(this.#options.config.name);
160
182
  if (this.#creds?.botToken) {
161
183
  try {
162
184
  await this.#notifyStop({ baseUrl: this.apiBaseUrl(), token: this.#creds.botToken });
@@ -350,8 +372,8 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
350
372
  continue;
351
373
  }
352
374
  consecutiveFailures += 1;
375
+ // 退避后不清零:对端持续挂时清零会让重试固定打满 RETRY_DELAY_MS。
353
376
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
354
- consecutiveFailures = 0;
355
377
  await sleep(BACKOFF_DELAY_MS, abortSignal);
356
378
  } else {
357
379
  await sleep(RETRY_DELAY_MS, abortSignal);
@@ -360,14 +382,15 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
360
382
  }
361
383
 
362
384
  consecutiveFailures = 0;
363
- if (resp.get_updates_buf) {
364
- getUpdatesBuf = resp.get_updates_buf;
365
- saveSyncBuf(endpointName, getUpdatesBuf);
366
- }
367
-
385
+ // 先分发再推进 buf:分发途中崩溃时 buf 未推进,下一轮重拉不丢消息。
386
+ const nextBuf = resp.get_updates_buf;
368
387
  for (const inbound of resp.msgs ?? []) {
369
388
  await this.#handleInboundMessage(inbound);
370
389
  }
390
+ if (nextBuf) {
391
+ getUpdatesBuf = nextBuf;
392
+ saveSyncBuf(endpointName, getUpdatesBuf);
393
+ }
371
394
  } catch (err) {
372
395
  if (abortSignal.aborted) return;
373
396
  consecutiveFailures += 1;
@@ -377,7 +400,6 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
377
400
  error: err instanceof Error ? err.message : String(err),
378
401
  }));
379
402
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
380
- consecutiveFailures = 0;
381
403
  await sleep(BACKOFF_DELAY_MS, abortSignal);
382
404
  } else {
383
405
  await sleep(RETRY_DELAY_MS, abortSignal);
@@ -458,4 +480,62 @@ export class WeixinIlinkEndpoint implements EndpointInstance {
458
480
  fs.writeFileSync(filePath, buffer);
459
481
  return { path: filePath };
460
482
  }
483
+
484
+ /** 入站媒体 TTL 清扫:start 时立即扫一次,之后每小时扫一次。 */
485
+ #startMediaSweep(): void {
486
+ this.#stopMediaSweep();
487
+ const sweep = () => this.#sweepInboundMedia();
488
+ sweep();
489
+ this.#mediaSweepTimer = setInterval(sweep, MEDIA_SWEEP_INTERVAL_MS);
490
+ (this.#mediaSweepTimer as { unref?: () => void }).unref?.();
491
+ }
492
+
493
+ #stopMediaSweep(): void {
494
+ if (this.#mediaSweepTimer) {
495
+ clearInterval(this.#mediaSweepTimer);
496
+ this.#mediaSweepTimer = undefined;
497
+ }
498
+ }
499
+
500
+ #sweepInboundMedia(): void {
501
+ const dir = path.join(resolveStateDir(), 'media', 'inbound');
502
+ let entries: fs.Dirent[];
503
+ try {
504
+ entries = fs.readdirSync(dir, { withFileTypes: true });
505
+ } catch {
506
+ return;
507
+ }
508
+ const now = Date.now();
509
+ for (const entry of entries) {
510
+ if (!entry.isFile()) continue;
511
+ const filePath = path.join(dir, entry.name);
512
+ try {
513
+ const stat = fs.statSync(filePath);
514
+ if (now - stat.mtimeMs <= INBOUND_MEDIA_TTL_MS) continue;
515
+ fs.unlinkSync(filePath);
516
+ logger.debug(formatCompact({
517
+ op: 'media_sweep',
518
+ endpoint: this.#options.config.name,
519
+ file: entry.name,
520
+ }));
521
+ } catch {
522
+ /* 单个文件失败不阻塞整体清扫 */
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ function createWeixinIlinkEndpointManagement(endpoint: WeixinIlinkEndpoint): EndpointManagement {
529
+ return Object.freeze<EndpointManagement>({
530
+ // 个人微信无群概念(listGroups/listGroupMembers 不接),也没有通讯录/资料接口。
531
+ // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
532
+ // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
533
+ async listFriends(): Promise<readonly EndpointFriend[]> {
534
+ return listContextTokenUserIds(endpoint.configName).map((userId) => ({
535
+ user_id: userId,
536
+ nickname: userId,
537
+ remark: 'ilink: 从会话 context_token 推导,非通讯录',
538
+ }));
539
+ },
540
+ });
461
541
  }
package/src/ilink-api.ts CHANGED
@@ -83,6 +83,7 @@ export async function apiGetFetch(params: {
83
83
  endpoint: string;
84
84
  timeoutMs?: number;
85
85
  label: string;
86
+ abortSignal?: AbortSignal;
86
87
  }): Promise<string> {
87
88
  const base = ensureTrailingSlash(params.baseUrl);
88
89
  const url = new URL(params.endpoint, base);
@@ -96,11 +97,12 @@ export async function apiGetFetch(params: {
96
97
  controller != null && timeoutMs != null
97
98
  ? setTimeout(() => controller.abort(), timeoutMs)
98
99
  : undefined;
100
+ const { signal, cleanup } = combineAbortSignals(controller, params.abortSignal);
99
101
  try {
100
102
  const res = await fetch(url.toString(), {
101
103
  method: "GET",
102
104
  headers: hdrs,
103
- ...(controller ? { signal: controller.signal } : {}),
105
+ ...(signal ? { signal } : {}),
104
106
  });
105
107
  if (t !== undefined) clearTimeout(t);
106
108
  const rawText = await res.text();
@@ -112,6 +114,8 @@ export async function apiGetFetch(params: {
112
114
  } catch (err) {
113
115
  if (t !== undefined) clearTimeout(t);
114
116
  throw err;
117
+ } finally {
118
+ cleanup();
115
119
  }
116
120
  }
117
121
 
package/src/login.ts CHANGED
@@ -55,6 +55,7 @@ async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeR
55
55
  async function pollQRStatus(
56
56
  apiBaseUrl: string,
57
57
  qrcode: string,
58
+ signal?: AbortSignal,
58
59
  ): Promise<StatusResponse> {
59
60
  try {
60
61
  const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
@@ -63,10 +64,13 @@ async function pollQRStatus(
63
64
  endpoint,
64
65
  timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
65
66
  label: 'pollQRStatus',
67
+ abortSignal: signal,
66
68
  });
67
69
  return JSON.parse(rawText) as StatusResponse;
68
70
  } catch (err) {
69
71
  if (err instanceof Error && err.name === 'AbortError') {
72
+ // 外部 stop 的 abort 不能吞掉当普通长轮询超时
73
+ if (signal?.aborted) throw err;
70
74
  return { status: 'wait' };
71
75
  }
72
76
  logger.warn(`pollQRStatus error: ${String(err)}`);
@@ -74,8 +78,31 @@ async function pollQRStatus(
74
78
  }
75
79
  }
76
80
 
81
+ function abortError(): Error {
82
+ return Object.assign(new Error('扫码登录已取消'), { name: 'AbortError' });
83
+ }
84
+
85
+ function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
86
+ return new Promise((resolve, reject) => {
87
+ if (signal?.aborted) {
88
+ reject(abortError());
89
+ return;
90
+ }
91
+ const timer = setTimeout(() => {
92
+ signal?.removeEventListener('abort', onAbort);
93
+ resolve();
94
+ }, ms);
95
+ const onAbort = () => {
96
+ clearTimeout(timer);
97
+ reject(abortError());
98
+ };
99
+ signal?.addEventListener('abort', onAbort, { once: true });
100
+ });
101
+ }
102
+
77
103
  export async function resolveCredentials(
78
104
  config: ResolvedWeixinIlinkConfig,
105
+ signal?: AbortSignal,
79
106
  ): Promise<WeixinIlinkCredentials> {
80
107
  const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
81
108
  if (envToken) {
@@ -96,11 +123,12 @@ export async function resolveCredentials(
96
123
  };
97
124
  }
98
125
 
99
- return loginWithQr(config);
126
+ return loginWithQr(config, signal);
100
127
  }
101
128
 
102
129
  export async function loginWithQr(
103
130
  config: ResolvedWeixinIlinkConfig,
131
+ signal?: AbortSignal,
104
132
  ): Promise<WeixinIlinkCredentials> {
105
133
  const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
106
134
  const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
@@ -114,7 +142,9 @@ export async function loginWithQr(
114
142
  let currentBaseUrl = apiBaseUrl;
115
143
 
116
144
  while (Date.now() < deadline) {
117
- const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
145
+ // stop() 的 AbortSignal:不打满 8 分钟,立即退出登录循环
146
+ if (signal?.aborted) throw abortError();
147
+ const status = await pollQRStatus(currentBaseUrl, qr.qrcode, signal);
118
148
 
119
149
  if (status.status === 'scaned_but_redirect' && status.redirect_host) {
120
150
  currentBaseUrl = status.redirect_host.startsWith('http')
@@ -143,7 +173,7 @@ export async function loginWithQr(
143
173
  throw new Error('二维码已过期,请重启 bot 重新登录');
144
174
  }
145
175
 
146
- await new Promise((r) => setTimeout(r, 500));
176
+ await sleepWithAbort(500, signal);
147
177
  }
148
178
 
149
179
  throw new Error('微信扫码登录超时');
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { weixinIlinkRuntimeStateToken } from './weixin-ilink-runtime-state.js';
8
+
9
+ export const weixinIlinkEndpointCommands = createEndpointCommands({
10
+ adapterKey: 'weixin-ilink',
11
+ adapterDisplayName: 'Weixin iLink',
12
+ fields: [
13
+ { key: 'botToken', required: true, env: true, description: 'iLink bot token' },
14
+ ],
15
+ running: (use) => use(weixinIlinkRuntimeStateToken).endpoints.values(),
16
+ describeEntry: (entry) => `botToken: ${String(entry.botToken)}`,
17
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const weixinIlinkRuntimeStateToken = defineEndpointRuntimeStateToken('weixin-ilink');
@@ -49,6 +49,35 @@ function persistContextTokens(accountId: string): void {
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * 每条入站消息都会 setContextToken;全量同步写盘在消息高峰时是纯浪费。
54
+ * 按 account 防抖合并,500ms 内多次写入只落盘一次。
55
+ */
56
+ const PERSIST_DEBOUNCE_MS = 500;
57
+ const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
58
+
59
+ function schedulePersistContextTokens(accountId: string): void {
60
+ const existing = persistTimers.get(accountId);
61
+ if (existing) clearTimeout(existing);
62
+ const timer = setTimeout(() => {
63
+ persistTimers.delete(accountId);
64
+ persistContextTokens(accountId);
65
+ }, PERSIST_DEBOUNCE_MS);
66
+ // 不让防抖定时器拖住进程退出
67
+ (timer as { unref?: () => void }).unref?.();
68
+ persistTimers.set(accountId, timer);
69
+ }
70
+
71
+ /** 立即落盘所有待写(endpoint stop / 测试用)。 */
72
+ export function flushContextTokenPersist(accountId?: string): void {
73
+ for (const [id, timer] of [...persistTimers]) {
74
+ if (accountId !== undefined && id !== accountId) continue;
75
+ clearTimeout(timer);
76
+ persistTimers.delete(id);
77
+ persistContextTokens(id);
78
+ }
79
+ }
80
+
52
81
  /**
53
82
  * Restore persisted context tokens for an account into the in-memory map.
54
83
  * Called once during gateway startAccount to survive restarts.
@@ -89,12 +118,12 @@ export function clearContextTokensForAccount(accountId: string): void {
89
118
  logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
90
119
  }
91
120
 
92
- /** Store a context token for a given account+user pair (memory + disk). */
121
+ /** Store a context token for a given account+user pair (memory + debounced disk). */
93
122
  export function setContextToken(accountId: string, userId: string, token: string): void {
94
123
  const k = contextTokenKey(accountId, userId);
95
124
  logger.debug(`setContextToken: key=${k}`);
96
125
  contextTokenStore.set(k, token);
97
- persistContextTokens(accountId);
126
+ schedulePersistContextTokens(accountId);
98
127
  }
99
128
 
100
129
  /** Retrieve the cached context token for a given account+user pair. */
@@ -107,6 +136,20 @@ export function getContextToken(accountId: string, userId: string): string | und
107
136
  return val;
108
137
  }
109
138
 
139
+ /**
140
+ * List all peer userIds that hold a context token for the given account
141
+ * (memory map, already merged with the disk-backed file on restore).
142
+ * Personal WeChat has no contact/profile API; this is the only peer catalog.
143
+ */
144
+ export function listContextTokenUserIds(accountId: string): string[] {
145
+ const prefix = `${accountId}:`;
146
+ const ids: string[] = [];
147
+ for (const k of contextTokenStore.keys()) {
148
+ if (k.startsWith(prefix)) ids.push(k.slice(prefix.length));
149
+ }
150
+ return ids;
151
+ }
152
+
110
153
  /**
111
154
  * Find all accountIds that have an active contextToken for the given userId.
112
155
  * Used to infer the sending bot account from the recipient address when