@zhin.js/adapter-weixin-ilink 1.1.0 → 1.1.2

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +11 -6
  3. package/adapters/{weixin-ilink.js → weixin-ilink/index.js} +6 -6
  4. package/adapters/{weixin-ilink.ts → weixin-ilink/index.ts} +9 -9
  5. package/commands/{endpoint/add/[id].js → weixin-ilink/endpoint/add/[id]/index.js} +1 -1
  6. package/commands/weixin-ilink/endpoint/add/[id]/index.ts +3 -0
  7. package/{lib/weixin-ilink-endpoint-commands.js → commands/weixin-ilink/endpoint/definition.js} +4 -3
  8. package/{src/weixin-ilink-endpoint-commands.ts → commands/weixin-ilink/endpoint/definition.ts} +3 -3
  9. package/commands/{endpoint/list.js → weixin-ilink/endpoint/list/index.js} +1 -1
  10. package/commands/weixin-ilink/endpoint/list/index.ts +3 -0
  11. package/commands/{endpoint/remove/[id].js → weixin-ilink/endpoint/remove/[id]/index.js} +1 -1
  12. package/commands/weixin-ilink/endpoint/remove/[id]/index.ts +3 -0
  13. package/lib/client.d.ts +5 -4
  14. package/lib/client.js +33 -16
  15. package/lib/context-store.d.ts +15 -1
  16. package/lib/context-store.js +93 -1
  17. package/lib/credentials.d.ts +15 -7
  18. package/lib/credentials.js +59 -42
  19. package/lib/endpoint.d.ts +9 -4
  20. package/lib/endpoint.js +41 -27
  21. package/lib/ilink-api.d.ts +6 -5
  22. package/lib/ilink-api.js +21 -14
  23. package/lib/ilink-config-cache.d.ts +3 -7
  24. package/lib/ilink-config-cache.js +13 -14
  25. package/lib/ilink-logger.d.ts +0 -1
  26. package/lib/ilink-logger.js +11 -25
  27. package/lib/ilink-meta.d.ts +13 -7
  28. package/lib/ilink-meta.js +24 -20
  29. package/lib/ilink-session-guard.d.ts +13 -14
  30. package/lib/ilink-session-guard.js +29 -42
  31. package/lib/index.d.ts +5 -2
  32. package/lib/index.js +4 -0
  33. package/lib/login.d.ts +4 -3
  34. package/lib/login.js +24 -21
  35. package/lib/outbound-media.js +1 -1
  36. package/lib/protocol.d.ts +7 -10
  37. package/lib/protocol.js +15 -15
  38. package/lib/types.d.ts +1 -1
  39. package/lib/weixin-ilink-runtime-state.js +1 -1
  40. package/lib/weixin-inbound.d.ts +3 -49
  41. package/lib/weixin-inbound.js +20 -183
  42. package/lib/weixin-send-media.js +5 -1
  43. package/lib/weixin-send.js +2 -0
  44. package/package.json +11 -11
  45. package/plugin.js +1 -1
  46. package/schema.json +32 -4
  47. package/src/client.ts +46 -14
  48. package/src/context-store.ts +101 -10
  49. package/src/credentials.ts +68 -45
  50. package/src/endpoint.ts +59 -38
  51. package/src/ilink-api.ts +26 -20
  52. package/src/ilink-config-cache.ts +17 -13
  53. package/src/ilink-logger.ts +11 -26
  54. package/src/ilink-meta.ts +31 -21
  55. package/src/ilink-session-guard.ts +29 -46
  56. package/src/index.ts +15 -2
  57. package/src/login.ts +32 -19
  58. package/src/outbound-media.ts +1 -1
  59. package/src/protocol.ts +20 -29
  60. package/src/types.ts +0 -1
  61. package/src/upload.test.ts +11 -8
  62. package/src/weixin-ilink-runtime-state.ts +1 -1
  63. package/src/weixin-inbound.ts +23 -215
  64. package/src/weixin-send-media.ts +5 -1
  65. package/src/weixin-send.ts +2 -0
  66. package/commands/endpoint/add/[id].ts +0 -3
  67. package/commands/endpoint/list.ts +0 -3
  68. package/commands/endpoint/remove/[id].ts +0 -3
  69. package/lib/weixin-ilink-endpoint-commands.d.ts +0 -1
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
 
4
4
  export interface WeixinIlinkCredentials {
5
5
  botToken: string;
@@ -9,56 +9,79 @@ export interface WeixinIlinkCredentials {
9
9
  updatedAt?: string;
10
10
  }
11
11
 
12
- const DEFAULT_DATA_DIR = "./data/weixin-ilink";
12
+ /** Endpoint-owned persistence for credentials, cursors, context tokens, and media. */
13
+ export class WeixinIlinkStateStore {
14
+ readonly endpointId: string;
15
+ readonly rootDir: string;
13
16
 
14
- export function resolveStateDir(): string {
15
- return process.env.ZHIN_DATA_DIR
16
- ? path.join(process.env.ZHIN_DATA_DIR, "weixin-ilink")
17
- : DEFAULT_DATA_DIR;
18
- }
17
+ constructor(
18
+ endpointId: string,
19
+ rootDir: string,
20
+ ) {
21
+ if (!/^[A-Za-z0-9_.-]+$/u.test(endpointId)) {
22
+ throw new TypeError('Weixin iLink state endpoint id is invalid');
23
+ }
24
+ if (!rootDir.trim()) throw new TypeError('Weixin iLink state root is required');
25
+ this.endpointId = endpointId;
26
+ this.rootDir = rootDir;
27
+ }
19
28
 
20
- export function credentialsPath(endpointId: string): string {
21
- return path.join(resolveStateDir(), `${endpointId}.json`);
22
- }
29
+ endpointDirectory(): string {
30
+ return path.join(this.rootDir, this.endpointId);
31
+ }
23
32
 
24
- export function loadCredentials(endpointId: string): WeixinIlinkCredentials | null {
25
- const file = credentialsPath(endpointId);
26
- try {
27
- if (!fs.existsSync(file)) return null;
28
- const raw = fs.readFileSync(file, "utf-8");
29
- const data = JSON.parse(raw) as WeixinIlinkCredentials;
30
- if (!data.botToken?.trim()) return null;
31
- return data;
32
- } catch {
33
- return null;
33
+ credentialsPath(): string {
34
+ return path.join(this.endpointDirectory(), 'credentials.json');
34
35
  }
35
- }
36
36
 
37
- export function saveCredentials(endpointId: string, creds: WeixinIlinkCredentials): void {
38
- const file = credentialsPath(endpointId);
39
- fs.mkdirSync(path.dirname(file), { recursive: true });
40
- fs.writeFileSync(
41
- file,
42
- JSON.stringify({ ...creds, updatedAt: new Date().toISOString() }, null, 2),
43
- "utf-8",
44
- );
45
- }
37
+ loadCredentials(): WeixinIlinkCredentials | null {
38
+ const file = this.credentialsPath();
39
+ try {
40
+ if (!fs.existsSync(file)) return null;
41
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as WeixinIlinkCredentials;
42
+ return data.botToken?.trim() ? data : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
46
47
 
47
- export function syncBufPath(endpointId: string): string {
48
- return path.join(resolveStateDir(), `${endpointId}.sync-buf`);
49
- }
48
+ saveCredentials(credentials: WeixinIlinkCredentials): void {
49
+ const file = this.credentialsPath();
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ fs.writeFileSync(
52
+ file,
53
+ JSON.stringify({ ...credentials, updatedAt: new Date().toISOString() }, null, 2),
54
+ 'utf-8',
55
+ );
56
+ }
50
57
 
51
- export function loadSyncBuf(endpointId: string): string {
52
- const file = syncBufPath(endpointId);
53
- try {
54
- return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "";
55
- } catch {
56
- return "";
58
+ syncBufPath(): string {
59
+ return path.join(this.endpointDirectory(), 'sync-buf');
60
+ }
61
+
62
+ loadSyncBuf(): string {
63
+ const file = this.syncBufPath();
64
+ try {
65
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ saveSyncBuf(value: string): void {
72
+ const file = this.syncBufPath();
73
+ fs.mkdirSync(path.dirname(file), { recursive: true });
74
+ fs.writeFileSync(file, value, 'utf-8');
75
+ }
76
+
77
+ contextTokensPath(): string {
78
+ return path.join(this.endpointDirectory(), 'context-tokens.json');
57
79
  }
58
- }
59
80
 
60
- export function saveSyncBuf(endpointId: string, buf: string): void {
61
- const file = syncBufPath(endpointId);
62
- fs.mkdirSync(path.dirname(file), { recursive: true });
63
- fs.writeFileSync(file, buf, "utf-8");
81
+ mediaDirectory(subdir: string): string {
82
+ if (subdir !== 'inbound' && subdir !== 'outbound') {
83
+ throw new TypeError(`Unsupported Weixin iLink media directory: ${subdir}`);
84
+ }
85
+ return path.join(this.endpointDirectory(), 'media', subdir);
86
+ }
64
87
  }
package/src/endpoint.ts CHANGED
@@ -1,38 +1,31 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
1
  /**
3
2
  * WeixinIlinkEndpoint — lifecycle, long-poll inbound, outbound send.
4
3
  */
5
4
  import crypto from 'node:crypto';
6
5
  import fs from 'node:fs';
7
6
  import path from 'node:path';
8
- import type {
9
- EndpointFriend,
10
- EndpointControl,
11
- EndpointManagement,
12
- EndpointSendRequest,
7
+ import {
8
+ Endpoint,
9
+ type EndpointFriend,
10
+ type EndpointControl,
11
+ type EndpointManagement,
12
+ type EndpointSendRequest,
13
13
  } from 'zhin.js/adapter';
14
14
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
15
15
  import type { CapabilityId } from 'zhin.js';
16
16
  import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
17
- import { configureIlinkMeta } from './ilink-meta.js';
17
+ import { IlinkClientMetadata } from './ilink-meta.js';
18
18
  import {
19
- loadSyncBuf,
20
- resolveStateDir,
21
- saveSyncBuf,
22
19
  type WeixinIlinkCredentials,
20
+ WeixinIlinkStateStore,
23
21
  } from './credentials.js';
24
22
  import { resolveCredentials } from './login.js';
25
23
  import {
26
- listContextTokenUserIds,
27
- restoreContextTokens,
28
- setContextToken,
29
- flushContextTokenPersist,
24
+ WeixinContextTokenStore,
30
25
  } from './context-store.js';
31
26
  import { WeixinConfigManager } from './ilink-config-cache.js';
32
27
  import {
33
- getRemainingPauseMs,
34
- isSessionPaused,
35
- pauseSession,
28
+ IlinkSessionGuard,
36
29
  SESSION_EXPIRED_ERRCODE,
37
30
  } from './ilink-session-guard.js';
38
31
  import { downloadMediaFromItem } from './media-download.js';
@@ -74,6 +67,8 @@ export interface WeixinIlinkEndpointOptions {
74
67
  readonly config: ResolvedWeixinIlinkConfig;
75
68
  readonly resolveCredentials?: (
76
69
  config: ResolvedWeixinIlinkConfig,
70
+ metadata: IlinkClientMetadata,
71
+ state: WeixinIlinkStateStore,
77
72
  signal?: AbortSignal,
78
73
  ) => Promise<WeixinIlinkCredentials>;
79
74
  /** Test / internal: override network side effects. */
@@ -81,6 +76,9 @@ export interface WeixinIlinkEndpointOptions {
81
76
  readonly notifyStop?: WeixinIlinkNotifyStop;
82
77
  readonly getUpdates?: WeixinIlinkGetUpdates;
83
78
  readonly sendText?: WeixinIlinkSendText;
79
+ readonly state?: WeixinIlinkStateStore;
80
+ readonly contextTokens?: WeixinContextTokenStore;
81
+ readonly sessionGuard?: IlinkSessionGuard;
84
82
  }
85
83
 
86
84
  export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
@@ -90,12 +88,18 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
90
88
  readonly #options: WeixinIlinkEndpointOptions;
91
89
  readonly #resolveCredentials: (
92
90
  config: ResolvedWeixinIlinkConfig,
91
+ metadata: IlinkClientMetadata,
92
+ state: WeixinIlinkStateStore,
93
93
  signal?: AbortSignal,
94
94
  ) => Promise<WeixinIlinkCredentials>;
95
95
  readonly #notifyStart: WeixinIlinkNotifyStart;
96
96
  readonly #notifyStop: WeixinIlinkNotifyStop;
97
97
  readonly #getUpdates: WeixinIlinkGetUpdates;
98
98
  readonly #sendText: WeixinIlinkSendText;
99
+ readonly #state: WeixinIlinkStateStore;
100
+ readonly #contextTokens: WeixinContextTokenStore;
101
+ readonly #sessionGuard: IlinkSessionGuard;
102
+ readonly #metadata: IlinkClientMetadata;
99
103
  #creds: WeixinIlinkCredentials | null = null;
100
104
  #pollAbort?: AbortController;
101
105
  #pollPromise?: Promise<void>;
@@ -123,11 +127,18 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
123
127
  this.#notifyStop = options.notifyStop ?? notifyStop;
124
128
  this.#getUpdates = options.getUpdates ?? getUpdates;
125
129
  this.#sendText = options.sendText ?? sendMessageWeixin;
130
+ this.#state = options.state
131
+ ?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
132
+ this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
133
+ this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
134
+ this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
126
135
  this.client = new WeixinIlinkClient(
127
136
  options.config,
137
+ this.#metadata,
128
138
  () => this.#creds,
129
139
  () => this.#configManager,
130
140
  this.#sendText,
141
+ this.#contextTokens,
131
142
  );
132
143
  }
133
144
 
@@ -139,19 +150,28 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
139
150
  if (this.#started) return;
140
151
  this.#started = true;
141
152
  try {
142
- configureIlinkMeta({ botAgent: this.#options.config.botAgent });
143
153
  // QR 登录最长 8 分钟:stop() 必须能打断
144
154
  this.#loginAbort = new AbortController();
145
- this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
146
- restoreContextTokens(this.#options.config.id);
155
+ this.#creds = await this.#resolveCredentials(
156
+ this.#options.config,
157
+ this.#metadata,
158
+ this.#state,
159
+ this.#loginAbort.signal,
160
+ );
161
+ this.#contextTokens.restore();
147
162
 
148
163
  await this.#notifyStart({
149
164
  baseUrl: this.client.apiBaseUrl,
165
+ metadata: this.#metadata,
150
166
  token: this.#creds.botToken,
151
167
  });
152
168
 
153
169
  this.#configManager = new WeixinConfigManager(
154
- { baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken },
170
+ {
171
+ baseUrl: this.client.apiBaseUrl,
172
+ metadata: this.#metadata,
173
+ token: this.#creds.botToken,
174
+ },
155
175
  (msg) => this.#logger.debug(msg),
156
176
  );
157
177
 
@@ -191,10 +211,14 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
191
211
  }
192
212
  this.#stopMediaSweep();
193
213
  // 防抖中的 context token 落盘,避免 stop 丢尾部写入
194
- flushContextTokenPersist(this.#options.config.id);
214
+ this.#contextTokens.flush();
195
215
  if (this.#creds?.botToken) {
196
216
  try {
197
- await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
217
+ await this.#notifyStop({
218
+ baseUrl: this.client.apiBaseUrl,
219
+ metadata: this.#metadata,
220
+ token: this.#creds.botToken,
221
+ });
198
222
  } catch (err) {
199
223
  this.#logger.warn(formatCompact({
200
224
  op: 'notify_stop',
@@ -229,7 +253,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
229
253
  throw new Error(`missing context_token for peer ${target}`);
230
254
  }
231
255
 
232
- const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
256
+ const outboundDir = this.#state.mediaDirectory('outbound');
233
257
  const wire = formatOutboundSegments(payload);
234
258
  const materialized = await materializeOutboundMedia(wire, outboundDir);
235
259
  const segments = Array.isArray(materialized) ? materialized : [materialized];
@@ -283,7 +307,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
283
307
  if (!this.#open) return;
284
308
  const userId = msg.from_user_id ?? '';
285
309
  if (msg.context_token && userId) {
286
- setContextToken(this.#options.config.id, userId, msg.context_token);
310
+ this.#contextTokens.set(userId, msg.context_token);
287
311
  }
288
312
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
289
313
  const replyTo = weixinReplyTo(msg);
@@ -311,20 +335,20 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
311
335
  async #pollLoop(abortSignal: AbortSignal): Promise<void> {
312
336
  if (!this.#creds?.botToken) return;
313
337
 
314
- const endpointId = this.#options.config.id;
315
- let getUpdatesBuf = loadSyncBuf(endpointId);
338
+ let getUpdatesBuf = this.#state.loadSyncBuf();
316
339
  let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
317
340
  let consecutiveFailures = 0;
318
341
 
319
342
  while (!abortSignal.aborted) {
320
- if (isSessionPaused(endpointId)) {
321
- await sleep(getRemainingPauseMs(endpointId), abortSignal);
343
+ if (this.#sessionGuard.paused) {
344
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
322
345
  continue;
323
346
  }
324
347
 
325
348
  try {
326
349
  const resp = await this.#getUpdates({
327
350
  baseUrl: this.client.apiBaseUrl,
351
+ metadata: this.#metadata,
328
352
  token: this.#creds.botToken,
329
353
  get_updates_buf: getUpdatesBuf,
330
354
  timeoutMs: nextTimeoutMs,
@@ -343,9 +367,9 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
343
367
  const sessionExpired =
344
368
  resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
345
369
  if (sessionExpired) {
346
- pauseSession(endpointId);
370
+ this.#sessionGuard.pause();
347
371
  consecutiveFailures = 0;
348
- await sleep(getRemainingPauseMs(endpointId), abortSignal);
372
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
349
373
  continue;
350
374
  }
351
375
  consecutiveFailures += 1;
@@ -366,7 +390,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
366
390
  }
367
391
  if (nextBuf) {
368
392
  getUpdatesBuf = nextBuf;
369
- saveSyncBuf(endpointId, getUpdatesBuf);
393
+ this.#state.saveSyncBuf(getUpdatesBuf);
370
394
  }
371
395
  } catch (err) {
372
396
  if (abortSignal.aborted) return;
@@ -388,9 +412,6 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
388
412
  async #handleInboundMessage(full: WeixinMessage): Promise<void> {
389
413
  void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
390
414
  const fromUserId = full.from_user_id ?? '';
391
- if (full.context_token && fromUserId) {
392
- setContextToken(this.#options.config.id, fromUserId, full.context_token);
393
- }
394
415
 
395
416
  const mediaOpts = await this.#downloadInboundMedia(full);
396
417
  this.admit({ ...full, _media: mediaOpts });
@@ -452,7 +473,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
452
473
  if (buffer.length > maxBytes) {
453
474
  throw new Error(`media exceeds max size ${maxBytes}`);
454
475
  }
455
- const dir = path.join(resolveStateDir(), 'media', subdir);
476
+ const dir = this.#state.mediaDirectory(subdir);
456
477
  fs.mkdirSync(dir, { recursive: true });
457
478
  const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
458
479
  const ext = originalFilename
@@ -486,7 +507,7 @@ export class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
486
507
  }
487
508
 
488
509
  #sweepInboundMedia(): void {
489
- const dir = path.join(resolveStateDir(), 'media', 'inbound');
510
+ const dir = this.#state.mediaDirectory('inbound');
490
511
  let entries: fs.Dirent[];
491
512
  try {
492
513
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -521,7 +542,7 @@ function createWeixinIlinkEndpointManagement(
521
542
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
522
543
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
523
544
  async listFriends(): Promise<readonly EndpointFriend[]> {
524
- return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
545
+ return requireClient().reachableUserIds().map((userId) => ({
525
546
  user_id: userId,
526
547
  nickname: userId,
527
548
  remark: 'ilink: 从会话 context_token 推导,非通讯录',
package/src/ilink-api.ts CHANGED
@@ -2,8 +2,7 @@ import crypto from "node:crypto";
2
2
 
3
3
  import { logger } from "./ilink-logger.js";
4
4
  import {
5
- buildBaseInfo,
6
- ILINK_APP_CLIENT_VERSION,
5
+ type IlinkClientMetadata,
7
6
  ILINK_APP_ID,
8
7
  } from "./ilink-meta.js";
9
8
  import { redactBody, redactUrl } from "./ilink-redact.js";
@@ -20,10 +19,11 @@ import type {
20
19
  GetConfigResp,
21
20
  } from "./ilink-types.js";
22
21
 
23
- export { buildBaseInfo, sanitizeBotAgent } from "./ilink-meta.js";
22
+ export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
24
23
 
25
24
  export type WeixinApiOptions = {
26
25
  baseUrl: string;
26
+ metadata: IlinkClientMetadata;
27
27
  token?: string;
28
28
  timeoutMs?: number;
29
29
  /** Long-poll timeout for getUpdates (server may hold the request up to this). */
@@ -48,20 +48,20 @@ function randomWechatUin(): string {
48
48
  }
49
49
 
50
50
  /** Build headers shared by both GET and POST requests. */
51
- function buildCommonHeaders(): Record<string, string> {
51
+ function buildCommonHeaders(metadata: IlinkClientMetadata): Record<string, string> {
52
52
  const headers: Record<string, string> = {
53
53
  "iLink-App-Id": ILINK_APP_ID,
54
- "iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION),
54
+ "iLink-App-ClientVersion": String(metadata.appClientVersion),
55
55
  };
56
56
  return headers;
57
57
  }
58
58
 
59
- function buildHeaders(opts: { token?: string }): Record<string, string> {
59
+ function buildHeaders(opts: { metadata: IlinkClientMetadata; token?: string }): Record<string, string> {
60
60
  const headers: Record<string, string> = {
61
61
  "Content-Type": "application/json",
62
62
  AuthorizationType: "ilink_bot_token",
63
63
  "X-WECHAT-UIN": randomWechatUin(),
64
- ...buildCommonHeaders(),
64
+ ...buildCommonHeaders(opts.metadata),
65
65
  };
66
66
  if (opts.token?.trim()) {
67
67
  headers.Authorization = `Bearer ${opts.token.trim()}`;
@@ -80,6 +80,7 @@ function buildHeaders(opts: { token?: string }): Record<string, string> {
80
80
  */
81
81
  export async function apiGetFetch(params: {
82
82
  baseUrl: string;
83
+ metadata: IlinkClientMetadata;
83
84
  endpoint: string;
84
85
  timeoutMs?: number;
85
86
  label: string;
@@ -87,7 +88,7 @@ export async function apiGetFetch(params: {
87
88
  }): Promise<string> {
88
89
  const base = ensureTrailingSlash(params.baseUrl);
89
90
  const url = new URL(params.endpoint, base);
90
- const hdrs = buildCommonHeaders();
91
+ const hdrs = buildCommonHeaders(params.metadata);
91
92
  logger.debug(`GET ${redactUrl(url.toString())}`);
92
93
 
93
94
  const timeoutMs = params.timeoutMs;
@@ -149,6 +150,7 @@ function combineAbortSignals(
149
150
  */
150
151
  export async function apiPostFetch(params: {
151
152
  baseUrl: string;
153
+ metadata: IlinkClientMetadata;
152
154
  endpoint: string;
153
155
  body: string;
154
156
  token?: string;
@@ -158,7 +160,7 @@ export async function apiPostFetch(params: {
158
160
  }): Promise<string> {
159
161
  const base = ensureTrailingSlash(params.baseUrl);
160
162
  const url = new URL(params.endpoint, base);
161
- const hdrs = buildHeaders({ token: params.token });
163
+ const hdrs = buildHeaders({ metadata: params.metadata, token: params.token });
162
164
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
163
165
 
164
166
  const controller =
@@ -196,10 +198,7 @@ export async function apiPostFetch(params: {
196
198
  * with ret=0 so the caller can simply retry. This is normal for long-poll.
197
199
  */
198
200
  export async function getUpdates(
199
- params: GetUpdatesReq & {
200
- baseUrl: string;
201
- token?: string;
202
- timeoutMs?: number;
201
+ params: GetUpdatesReq & WeixinApiOptions & {
203
202
  /**
204
203
  * Optional external abort signal (e.g. from the gateway when stopping the
205
204
  * channel). When this aborts, the in-flight long-poll is terminated
@@ -213,10 +212,11 @@ export async function getUpdates(
213
212
  try {
214
213
  const rawText = await apiPostFetch({
215
214
  baseUrl: params.baseUrl,
215
+ metadata: params.metadata,
216
216
  endpoint: "ilink/bot/getupdates",
217
217
  body: JSON.stringify({
218
218
  get_updates_buf: params.get_updates_buf ?? "",
219
- base_info: buildBaseInfo(),
219
+ base_info: params.metadata.buildBaseInfo(),
220
220
  }),
221
221
  token: params.token,
222
222
  timeoutMs: timeout,
@@ -249,6 +249,7 @@ export async function getUploadUrl(
249
249
  ): Promise<GetUploadUrlResp> {
250
250
  const rawText = await apiPostFetch({
251
251
  baseUrl: params.baseUrl,
252
+ metadata: params.metadata,
252
253
  endpoint: "ilink/bot/getuploadurl",
253
254
  body: JSON.stringify({
254
255
  filekey: params.filekey,
@@ -262,7 +263,7 @@ export async function getUploadUrl(
262
263
  thumb_filesize: params.thumb_filesize,
263
264
  no_need_thumb: params.no_need_thumb,
264
265
  aeskey: params.aeskey,
265
- base_info: buildBaseInfo(),
266
+ base_info: params.metadata.buildBaseInfo(),
266
267
  }),
267
268
  token: params.token,
268
269
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
@@ -278,8 +279,9 @@ export async function sendMessage(
278
279
  ): Promise<void> {
279
280
  await apiPostFetch({
280
281
  baseUrl: params.baseUrl,
282
+ metadata: params.metadata,
281
283
  endpoint: "ilink/bot/sendmessage",
282
- body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
284
+ body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
283
285
  token: params.token,
284
286
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
285
287
  label: "sendMessage",
@@ -292,11 +294,12 @@ export async function getConfig(
292
294
  ): Promise<GetConfigResp> {
293
295
  const rawText = await apiPostFetch({
294
296
  baseUrl: params.baseUrl,
297
+ metadata: params.metadata,
295
298
  endpoint: "ilink/bot/getconfig",
296
299
  body: JSON.stringify({
297
300
  ilink_user_id: params.ilinkUserId,
298
301
  context_token: params.contextToken,
299
- base_info: buildBaseInfo(),
302
+ base_info: params.metadata.buildBaseInfo(),
300
303
  }),
301
304
  token: params.token,
302
305
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
@@ -312,8 +315,9 @@ export async function sendTyping(
312
315
  ): Promise<void> {
313
316
  await apiPostFetch({
314
317
  baseUrl: params.baseUrl,
318
+ metadata: params.metadata,
315
319
  endpoint: "ilink/bot/sendtyping",
316
- body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
320
+ body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
317
321
  token: params.token,
318
322
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
319
323
  label: "sendTyping",
@@ -328,8 +332,9 @@ export async function sendTyping(
328
332
  export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
329
333
  const rawText = await apiPostFetch({
330
334
  baseUrl: params.baseUrl,
335
+ metadata: params.metadata,
331
336
  endpoint: "ilink/bot/msg/notifystop",
332
- body: JSON.stringify({ base_info: buildBaseInfo() }),
337
+ body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
333
338
  token: params.token,
334
339
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
335
340
  label: "notifyStop",
@@ -343,8 +348,9 @@ export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopRe
343
348
  export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
344
349
  const rawText = await apiPostFetch({
345
350
  baseUrl: params.baseUrl,
351
+ metadata: params.metadata,
346
352
  endpoint: "ilink/bot/msg/notifystart",
347
- body: JSON.stringify({ base_info: buildBaseInfo() }),
353
+ body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
348
354
  token: params.token,
349
355
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
350
356
  label: "notifyStart",
@@ -1,4 +1,4 @@
1
- import { getConfig } from "./ilink-api.js";
1
+ import { getConfig, type WeixinApiOptions } from "./ilink-api.js";
2
2
 
3
3
  /** Subset of getConfig fields that we actually need; add new fields here as needed. */
4
4
  export interface CachedConfig {
@@ -21,41 +21,45 @@ interface ConfigCacheEntry {
21
21
  * exponential-backoff retry (up to 1h) on failure.
22
22
  */
23
23
  export class WeixinConfigManager {
24
- private cache = new Map<string, ConfigCacheEntry>();
24
+ readonly #cache = new Map<string, ConfigCacheEntry>();
25
+ readonly #apiOptions: WeixinApiOptions;
26
+ readonly #log: (msg: string) => void;
25
27
 
26
28
  constructor(
27
- private apiOpts: { baseUrl: string; token?: string },
28
- private log: (msg: string) => void,
29
- ) {}
29
+ apiOptions: WeixinApiOptions,
30
+ log: (msg: string) => void,
31
+ ) {
32
+ this.#apiOptions = apiOptions;
33
+ this.#log = log;
34
+ }
30
35
 
31
36
  async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
32
37
  const now = Date.now();
33
- const entry = this.cache.get(userId);
38
+ const entry = this.#cache.get(userId);
34
39
  const shouldFetch = !entry || now >= entry.nextFetchAt;
35
40
 
36
41
  if (shouldFetch) {
37
42
  let fetchOk = false;
38
43
  try {
39
44
  const resp = await getConfig({
40
- baseUrl: this.apiOpts.baseUrl,
41
- token: this.apiOpts.token,
45
+ ...this.#apiOptions,
42
46
  ilinkUserId: userId,
43
47
  contextToken,
44
48
  });
45
49
  if (resp.ret === 0) {
46
- this.cache.set(userId, {
50
+ this.#cache.set(userId, {
47
51
  config: { typingTicket: resp.typing_ticket ?? "" },
48
52
  everSucceeded: true,
49
53
  nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
50
54
  retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
51
55
  });
52
- this.log(
56
+ this.#log(
53
57
  `[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
54
58
  );
55
59
  fetchOk = true;
56
60
  }
57
61
  } catch (err) {
58
- this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
62
+ this.#log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
59
63
  }
60
64
  if (!fetchOk) {
61
65
  const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
@@ -64,7 +68,7 @@ export class WeixinConfigManager {
64
68
  entry.nextFetchAt = now + nextDelay;
65
69
  entry.retryDelayMs = nextDelay;
66
70
  } else {
67
- this.cache.set(userId, {
71
+ this.#cache.set(userId, {
68
72
  config: { typingTicket: "" },
69
73
  everSucceeded: false,
70
74
  nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
@@ -74,6 +78,6 @@ export class WeixinConfigManager {
74
78
  }
75
79
  }
76
80
 
77
- return this.cache.get(userId)?.config ?? { typingTicket: "" };
81
+ return this.#cache.get(userId)?.config ?? { typingTicket: "" };
78
82
  }
79
83
  }