@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
package/lib/endpoint.d.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
- import type { EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
1
+ import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
3
2
  import type { CapabilityId } from 'zhin.js';
4
3
  import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
5
- import { type WeixinIlinkCredentials } from './credentials.js';
4
+ import { IlinkClientMetadata } from './ilink-meta.js';
5
+ import { type WeixinIlinkCredentials, WeixinIlinkStateStore } from './credentials.js';
6
+ import { WeixinContextTokenStore } from './context-store.js';
7
+ import { IlinkSessionGuard } from './ilink-session-guard.js';
6
8
  import { sendMessageWeixin } from './weixin-send.js';
7
9
  import { type ResolvedWeixinIlinkConfig, type WeixinMessageWithMedia } from './protocol.js';
8
10
  import { WeixinIlinkClient } from './client.js';
@@ -13,12 +15,15 @@ export type WeixinIlinkGetUpdates = typeof getUpdates;
13
15
  export interface WeixinIlinkEndpointOptions {
14
16
  readonly id: CapabilityId;
15
17
  readonly config: ResolvedWeixinIlinkConfig;
16
- readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
18
+ readonly resolveCredentials?: (config: ResolvedWeixinIlinkConfig, metadata: IlinkClientMetadata, state: WeixinIlinkStateStore, signal?: AbortSignal) => Promise<WeixinIlinkCredentials>;
17
19
  /** Test / internal: override network side effects. */
18
20
  readonly notifyStart?: WeixinIlinkNotifyStart;
19
21
  readonly notifyStop?: WeixinIlinkNotifyStop;
20
22
  readonly getUpdates?: WeixinIlinkGetUpdates;
21
23
  readonly sendText?: WeixinIlinkSendText;
24
+ readonly state?: WeixinIlinkStateStore;
25
+ readonly contextTokens?: WeixinContextTokenStore;
26
+ readonly sessionGuard?: IlinkSessionGuard;
22
27
  }
23
28
  export declare class WeixinIlinkEndpoint extends Endpoint<WeixinIlinkClient> {
24
29
  #private;
package/lib/endpoint.js CHANGED
@@ -1,18 +1,18 @@
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';
7
+ import { Endpoint, } from 'zhin.js/adapter';
8
8
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
9
  import { getUpdates, notifyStart, notifyStop } from './ilink-api.js';
10
- import { configureIlinkMeta } from './ilink-meta.js';
11
- import { loadSyncBuf, resolveStateDir, saveSyncBuf, } from './credentials.js';
10
+ import { IlinkClientMetadata } from './ilink-meta.js';
11
+ import { WeixinIlinkStateStore, } from './credentials.js';
12
12
  import { resolveCredentials } from './login.js';
13
- import { listContextTokenUserIds, restoreContextTokens, setContextToken, flushContextTokenPersist, } from './context-store.js';
13
+ import { WeixinContextTokenStore, } from './context-store.js';
14
14
  import { WeixinConfigManager } from './ilink-config-cache.js';
15
- import { getRemainingPauseMs, isSessionPaused, pauseSession, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
15
+ import { IlinkSessionGuard, SESSION_EXPIRED_ERRCODE, } from './ilink-session-guard.js';
16
16
  import { downloadMediaFromItem } from './media-download.js';
17
17
  import { sendMessageWeixin } from './weixin-send.js';
18
18
  import { materializeOutboundMedia } from './outbound-media.js';
@@ -36,6 +36,10 @@ export class WeixinIlinkEndpoint extends Endpoint {
36
36
  #notifyStop;
37
37
  #getUpdates;
38
38
  #sendText;
39
+ #state;
40
+ #contextTokens;
41
+ #sessionGuard;
42
+ #metadata;
39
43
  #creds = null;
40
44
  #pollAbort;
41
45
  #pollPromise;
@@ -59,7 +63,12 @@ export class WeixinIlinkEndpoint extends Endpoint {
59
63
  this.#notifyStop = options.notifyStop ?? notifyStop;
60
64
  this.#getUpdates = options.getUpdates ?? getUpdates;
61
65
  this.#sendText = options.sendText ?? sendMessageWeixin;
62
- this.client = new WeixinIlinkClient(options.config, () => this.#creds, () => this.#configManager, this.#sendText);
66
+ this.#state = options.state
67
+ ?? new WeixinIlinkStateStore(options.config.id, options.config.dataDir);
68
+ this.#contextTokens = options.contextTokens ?? new WeixinContextTokenStore(this.#state);
69
+ this.#sessionGuard = options.sessionGuard ?? new IlinkSessionGuard(options.config.id);
70
+ this.#metadata = new IlinkClientMetadata({ botAgent: options.config.botAgent });
71
+ this.client = new WeixinIlinkClient(options.config, this.#metadata, () => this.#creds, () => this.#configManager, this.#sendText, this.#contextTokens);
63
72
  }
64
73
  get hasCredentials() {
65
74
  return this.client.authenticated;
@@ -69,16 +78,20 @@ export class WeixinIlinkEndpoint extends Endpoint {
69
78
  return;
70
79
  this.#started = true;
71
80
  try {
72
- configureIlinkMeta({ botAgent: this.#options.config.botAgent });
73
81
  // QR 登录最长 8 分钟:stop() 必须能打断
74
82
  this.#loginAbort = new AbortController();
75
- this.#creds = await this.#resolveCredentials(this.#options.config, this.#loginAbort.signal);
76
- restoreContextTokens(this.#options.config.id);
83
+ this.#creds = await this.#resolveCredentials(this.#options.config, this.#metadata, this.#state, this.#loginAbort.signal);
84
+ this.#contextTokens.restore();
77
85
  await this.#notifyStart({
78
86
  baseUrl: this.client.apiBaseUrl,
87
+ metadata: this.#metadata,
79
88
  token: this.#creds.botToken,
80
89
  });
81
- this.#configManager = new WeixinConfigManager({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken }, (msg) => this.#logger.debug(msg));
90
+ this.#configManager = new WeixinConfigManager({
91
+ baseUrl: this.client.apiBaseUrl,
92
+ metadata: this.#metadata,
93
+ token: this.#creds.botToken,
94
+ }, (msg) => this.#logger.debug(msg));
82
95
  this.#pollAbort = new AbortController();
83
96
  this.#pollPromise = this.#pollLoop(this.#pollAbort.signal);
84
97
  this.#startMediaSweep();
@@ -114,10 +127,14 @@ export class WeixinIlinkEndpoint extends Endpoint {
114
127
  }
115
128
  this.#stopMediaSweep();
116
129
  // 防抖中的 context token 落盘,避免 stop 丢尾部写入
117
- flushContextTokenPersist(this.#options.config.id);
130
+ this.#contextTokens.flush();
118
131
  if (this.#creds?.botToken) {
119
132
  try {
120
- await this.#notifyStop({ baseUrl: this.client.apiBaseUrl, token: this.#creds.botToken });
133
+ await this.#notifyStop({
134
+ baseUrl: this.client.apiBaseUrl,
135
+ metadata: this.#metadata,
136
+ token: this.#creds.botToken,
137
+ });
121
138
  }
122
139
  catch (err) {
123
140
  this.#logger.warn(formatCompact({
@@ -151,7 +168,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
151
168
  }));
152
169
  throw new Error(`missing context_token for peer ${target}`);
153
170
  }
154
- const outboundDir = path.join(resolveStateDir(), 'media', 'outbound');
171
+ const outboundDir = this.#state.mediaDirectory('outbound');
155
172
  const wire = formatOutboundSegments(payload);
156
173
  const materialized = await materializeOutboundMedia(wire, outboundDir);
157
174
  const segments = Array.isArray(materialized) ? materialized : [materialized];
@@ -201,7 +218,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
201
218
  return;
202
219
  const userId = msg.from_user_id ?? '';
203
220
  if (msg.context_token && userId) {
204
- setContextToken(this.#options.config.id, userId, msg.context_token);
221
+ this.#contextTokens.set(userId, msg.context_token);
205
222
  }
206
223
  const conversation = weixinIlinkInboundConversation(String(this.#options.id), userId);
207
224
  const replyTo = weixinReplyTo(msg);
@@ -228,18 +245,18 @@ export class WeixinIlinkEndpoint extends Endpoint {
228
245
  async #pollLoop(abortSignal) {
229
246
  if (!this.#creds?.botToken)
230
247
  return;
231
- const endpointId = this.#options.config.id;
232
- let getUpdatesBuf = loadSyncBuf(endpointId);
248
+ let getUpdatesBuf = this.#state.loadSyncBuf();
233
249
  let nextTimeoutMs = this.#options.config.longPollTimeoutMs;
234
250
  let consecutiveFailures = 0;
235
251
  while (!abortSignal.aborted) {
236
- if (isSessionPaused(endpointId)) {
237
- await sleep(getRemainingPauseMs(endpointId), abortSignal);
252
+ if (this.#sessionGuard.paused) {
253
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
238
254
  continue;
239
255
  }
240
256
  try {
241
257
  const resp = await this.#getUpdates({
242
258
  baseUrl: this.client.apiBaseUrl,
259
+ metadata: this.#metadata,
243
260
  token: this.#creds.botToken,
244
261
  get_updates_buf: getUpdatesBuf,
245
262
  timeoutMs: nextTimeoutMs,
@@ -253,9 +270,9 @@ export class WeixinIlinkEndpoint extends Endpoint {
253
270
  if (isApiError) {
254
271
  const sessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
255
272
  if (sessionExpired) {
256
- pauseSession(endpointId);
273
+ this.#sessionGuard.pause();
257
274
  consecutiveFailures = 0;
258
- await sleep(getRemainingPauseMs(endpointId), abortSignal);
275
+ await sleep(this.#sessionGuard.remainingMs, abortSignal);
259
276
  continue;
260
277
  }
261
278
  consecutiveFailures += 1;
@@ -276,7 +293,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
276
293
  }
277
294
  if (nextBuf) {
278
295
  getUpdatesBuf = nextBuf;
279
- saveSyncBuf(endpointId, getUpdatesBuf);
296
+ this.#state.saveSyncBuf(getUpdatesBuf);
280
297
  }
281
298
  }
282
299
  catch (err) {
@@ -300,9 +317,6 @@ export class WeixinIlinkEndpoint extends Endpoint {
300
317
  async #handleInboundMessage(full) {
301
318
  void this.#emitPlatformEvent(`message.${full.message_type ?? 'unknown'}`, full);
302
319
  const fromUserId = full.from_user_id ?? '';
303
- if (full.context_token && fromUserId) {
304
- setContextToken(this.#options.config.id, fromUserId, full.context_token);
305
- }
306
320
  const mediaOpts = await this.#downloadInboundMedia(full);
307
321
  this.admit({ ...full, _media: mediaOpts });
308
322
  this.#logger.debug(formatCompact({
@@ -341,7 +355,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
341
355
  if (buffer.length > maxBytes) {
342
356
  throw new Error(`media exceeds max size ${maxBytes}`);
343
357
  }
344
- const dir = path.join(resolveStateDir(), 'media', subdir);
358
+ const dir = this.#state.mediaDirectory(subdir);
345
359
  fs.mkdirSync(dir, { recursive: true });
346
360
  const resolvedMime = contentType ?? sniffMimeFromBuffer(buffer);
347
361
  const ext = originalFilename
@@ -372,7 +386,7 @@ export class WeixinIlinkEndpoint extends Endpoint {
372
386
  }
373
387
  }
374
388
  #sweepInboundMedia() {
375
- const dir = path.join(resolveStateDir(), 'media', 'inbound');
389
+ const dir = this.#state.mediaDirectory('inbound');
376
390
  let entries;
377
391
  try {
378
392
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -408,7 +422,7 @@ function createWeixinIlinkEndpointManagement(requireClient) {
408
422
  // "好友"只能从会话推导:凡持有 context_token 的对端(来过消息,含重启后从磁盘恢复的)
409
423
  // 即为可达私聊对端;昵称不可得,用 user_id 占位并在 remark 注明来源。
410
424
  async listFriends() {
411
- return listContextTokenUserIds(requireClient().config.id).map((userId) => ({
425
+ return requireClient().reachableUserIds().map((userId) => ({
412
426
  user_id: userId,
413
427
  nickname: userId,
414
428
  remark: 'ilink: 从会话 context_token 推导,非通讯录',
@@ -1,7 +1,9 @@
1
+ import { type IlinkClientMetadata } from "./ilink-meta.js";
1
2
  import type { GetUploadUrlReq, GetUploadUrlResp, GetUpdatesReq, GetUpdatesResp, NotifyStopResp, NotifyStartResp, SendMessageReq, SendTypingReq, GetConfigResp } from "./ilink-types.js";
2
- export { buildBaseInfo, sanitizeBotAgent } from "./ilink-meta.js";
3
+ export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
3
4
  export type WeixinApiOptions = {
4
5
  baseUrl: string;
6
+ metadata: IlinkClientMetadata;
5
7
  token?: string;
6
8
  timeoutMs?: number;
7
9
  /** Long-poll timeout for getUpdates (server may hold the request up to this). */
@@ -15,6 +17,7 @@ export type WeixinApiOptions = {
15
17
  */
16
18
  export declare function apiGetFetch(params: {
17
19
  baseUrl: string;
20
+ metadata: IlinkClientMetadata;
18
21
  endpoint: string;
19
22
  timeoutMs?: number;
20
23
  label: string;
@@ -29,6 +32,7 @@ export declare function apiGetFetch(params: {
29
32
  */
30
33
  export declare function apiPostFetch(params: {
31
34
  baseUrl: string;
35
+ metadata: IlinkClientMetadata;
32
36
  endpoint: string;
33
37
  body: string;
34
38
  token?: string;
@@ -42,10 +46,7 @@ export declare function apiPostFetch(params: {
42
46
  * On client-side timeout (no server response within timeoutMs), returns an empty response
43
47
  * with ret=0 so the caller can simply retry. This is normal for long-poll.
44
48
  */
45
- export declare function getUpdates(params: GetUpdatesReq & {
46
- baseUrl: string;
47
- token?: string;
48
- timeoutMs?: number;
49
+ export declare function getUpdates(params: GetUpdatesReq & WeixinApiOptions & {
49
50
  /**
50
51
  * Optional external abort signal (e.g. from the gateway when stopping the
51
52
  * channel). When this aborts, the in-flight long-poll is terminated
package/lib/ilink-api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import crypto from "node:crypto";
2
2
  import { logger } from "./ilink-logger.js";
3
- import { buildBaseInfo, ILINK_APP_CLIENT_VERSION, ILINK_APP_ID, } from "./ilink-meta.js";
3
+ import { ILINK_APP_ID, } from "./ilink-meta.js";
4
4
  import { redactBody, redactUrl } from "./ilink-redact.js";
5
- export { buildBaseInfo, sanitizeBotAgent } from "./ilink-meta.js";
5
+ export { IlinkClientMetadata, sanitizeBotAgent } from "./ilink-meta.js";
6
6
  /** Default timeout for long-poll getUpdates requests. */
7
7
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
8
8
  /** Default timeout for regular API requests (sendMessage, getUploadUrl). */
@@ -18,10 +18,10 @@ function randomWechatUin() {
18
18
  return Buffer.from(String(uint32), "utf-8").toString("base64");
19
19
  }
20
20
  /** Build headers shared by both GET and POST requests. */
21
- function buildCommonHeaders() {
21
+ function buildCommonHeaders(metadata) {
22
22
  const headers = {
23
23
  "iLink-App-Id": ILINK_APP_ID,
24
- "iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION),
24
+ "iLink-App-ClientVersion": String(metadata.appClientVersion),
25
25
  };
26
26
  return headers;
27
27
  }
@@ -30,7 +30,7 @@ function buildHeaders(opts) {
30
30
  "Content-Type": "application/json",
31
31
  AuthorizationType: "ilink_bot_token",
32
32
  "X-WECHAT-UIN": randomWechatUin(),
33
- ...buildCommonHeaders(),
33
+ ...buildCommonHeaders(opts.metadata),
34
34
  };
35
35
  if (opts.token?.trim()) {
36
36
  headers.Authorization = `Bearer ${opts.token.trim()}`;
@@ -47,7 +47,7 @@ function buildHeaders(opts) {
47
47
  export async function apiGetFetch(params) {
48
48
  const base = ensureTrailingSlash(params.baseUrl);
49
49
  const url = new URL(params.endpoint, base);
50
- const hdrs = buildCommonHeaders();
50
+ const hdrs = buildCommonHeaders(params.metadata);
51
51
  logger.debug(`GET ${redactUrl(url.toString())}`);
52
52
  const timeoutMs = params.timeoutMs;
53
53
  const controller = timeoutMs != null && timeoutMs > 0 ? new AbortController() : undefined;
@@ -107,7 +107,7 @@ function combineAbortSignals(internal, external) {
107
107
  export async function apiPostFetch(params) {
108
108
  const base = ensureTrailingSlash(params.baseUrl);
109
109
  const url = new URL(params.endpoint, base);
110
- const hdrs = buildHeaders({ token: params.token });
110
+ const hdrs = buildHeaders({ metadata: params.metadata, token: params.token });
111
111
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
112
112
  const controller = params.timeoutMs !== undefined ? new AbortController() : undefined;
113
113
  const t = controller != null && params.timeoutMs !== undefined
@@ -148,10 +148,11 @@ export async function getUpdates(params) {
148
148
  try {
149
149
  const rawText = await apiPostFetch({
150
150
  baseUrl: params.baseUrl,
151
+ metadata: params.metadata,
151
152
  endpoint: "ilink/bot/getupdates",
152
153
  body: JSON.stringify({
153
154
  get_updates_buf: params.get_updates_buf ?? "",
154
- base_info: buildBaseInfo(),
155
+ base_info: params.metadata.buildBaseInfo(),
155
156
  }),
156
157
  token: params.token,
157
158
  timeoutMs: timeout,
@@ -183,6 +184,7 @@ export async function getUpdates(params) {
183
184
  export async function getUploadUrl(params) {
184
185
  const rawText = await apiPostFetch({
185
186
  baseUrl: params.baseUrl,
187
+ metadata: params.metadata,
186
188
  endpoint: "ilink/bot/getuploadurl",
187
189
  body: JSON.stringify({
188
190
  filekey: params.filekey,
@@ -196,7 +198,7 @@ export async function getUploadUrl(params) {
196
198
  thumb_filesize: params.thumb_filesize,
197
199
  no_need_thumb: params.no_need_thumb,
198
200
  aeskey: params.aeskey,
199
- base_info: buildBaseInfo(),
201
+ base_info: params.metadata.buildBaseInfo(),
200
202
  }),
201
203
  token: params.token,
202
204
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
@@ -209,8 +211,9 @@ export async function getUploadUrl(params) {
209
211
  export async function sendMessage(params) {
210
212
  await apiPostFetch({
211
213
  baseUrl: params.baseUrl,
214
+ metadata: params.metadata,
212
215
  endpoint: "ilink/bot/sendmessage",
213
- body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
216
+ body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
214
217
  token: params.token,
215
218
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
216
219
  label: "sendMessage",
@@ -220,11 +223,12 @@ export async function sendMessage(params) {
220
223
  export async function getConfig(params) {
221
224
  const rawText = await apiPostFetch({
222
225
  baseUrl: params.baseUrl,
226
+ metadata: params.metadata,
223
227
  endpoint: "ilink/bot/getconfig",
224
228
  body: JSON.stringify({
225
229
  ilink_user_id: params.ilinkUserId,
226
230
  context_token: params.contextToken,
227
- base_info: buildBaseInfo(),
231
+ base_info: params.metadata.buildBaseInfo(),
228
232
  }),
229
233
  token: params.token,
230
234
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
@@ -237,8 +241,9 @@ export async function getConfig(params) {
237
241
  export async function sendTyping(params) {
238
242
  await apiPostFetch({
239
243
  baseUrl: params.baseUrl,
244
+ metadata: params.metadata,
240
245
  endpoint: "ilink/bot/sendtyping",
241
- body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
246
+ body: JSON.stringify({ ...params.body, base_info: params.metadata.buildBaseInfo() }),
242
247
  token: params.token,
243
248
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
244
249
  label: "sendTyping",
@@ -252,8 +257,9 @@ export async function sendTyping(params) {
252
257
  export async function notifyStop(params) {
253
258
  const rawText = await apiPostFetch({
254
259
  baseUrl: params.baseUrl,
260
+ metadata: params.metadata,
255
261
  endpoint: "ilink/bot/msg/notifystop",
256
- body: JSON.stringify({ base_info: buildBaseInfo() }),
262
+ body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
257
263
  token: params.token,
258
264
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
259
265
  label: "notifyStop",
@@ -266,8 +272,9 @@ export async function notifyStop(params) {
266
272
  export async function notifyStart(params) {
267
273
  const rawText = await apiPostFetch({
268
274
  baseUrl: params.baseUrl,
275
+ metadata: params.metadata,
269
276
  endpoint: "ilink/bot/msg/notifystart",
270
- body: JSON.stringify({ base_info: buildBaseInfo() }),
277
+ body: JSON.stringify({ base_info: params.metadata.buildBaseInfo() }),
271
278
  token: params.token,
272
279
  timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
273
280
  label: "notifyStart",
@@ -1,3 +1,4 @@
1
+ import { type WeixinApiOptions } from "./ilink-api.js";
1
2
  /** Subset of getConfig fields that we actually need; add new fields here as needed. */
2
3
  export interface CachedConfig {
3
4
  typingTicket: string;
@@ -7,12 +8,7 @@ export interface CachedConfig {
7
8
  * exponential-backoff retry (up to 1h) on failure.
8
9
  */
9
10
  export declare class WeixinConfigManager {
10
- private apiOpts;
11
- private log;
12
- private cache;
13
- constructor(apiOpts: {
14
- baseUrl: string;
15
- token?: string;
16
- }, log: (msg: string) => void);
11
+ #private;
12
+ constructor(apiOptions: WeixinApiOptions, log: (msg: string) => void);
17
13
  getForUser(userId: string, contextToken?: string): Promise<CachedConfig>;
18
14
  }
@@ -7,39 +7,38 @@ const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
7
7
  * exponential-backoff retry (up to 1h) on failure.
8
8
  */
9
9
  export class WeixinConfigManager {
10
- apiOpts;
11
- log;
12
- cache = new Map();
13
- constructor(apiOpts, log) {
14
- this.apiOpts = apiOpts;
15
- this.log = log;
10
+ #cache = new Map();
11
+ #apiOptions;
12
+ #log;
13
+ constructor(apiOptions, log) {
14
+ this.#apiOptions = apiOptions;
15
+ this.#log = log;
16
16
  }
17
17
  async getForUser(userId, contextToken) {
18
18
  const now = Date.now();
19
- const entry = this.cache.get(userId);
19
+ const entry = this.#cache.get(userId);
20
20
  const shouldFetch = !entry || now >= entry.nextFetchAt;
21
21
  if (shouldFetch) {
22
22
  let fetchOk = false;
23
23
  try {
24
24
  const resp = await getConfig({
25
- baseUrl: this.apiOpts.baseUrl,
26
- token: this.apiOpts.token,
25
+ ...this.#apiOptions,
27
26
  ilinkUserId: userId,
28
27
  contextToken,
29
28
  });
30
29
  if (resp.ret === 0) {
31
- this.cache.set(userId, {
30
+ this.#cache.set(userId, {
32
31
  config: { typingTicket: resp.typing_ticket ?? "" },
33
32
  everSucceeded: true,
34
33
  nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
35
34
  retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
36
35
  });
37
- this.log(`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`);
36
+ this.#log(`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`);
38
37
  fetchOk = true;
39
38
  }
40
39
  }
41
40
  catch (err) {
42
- this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
41
+ this.#log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
43
42
  }
44
43
  if (!fetchOk) {
45
44
  const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
@@ -49,7 +48,7 @@ export class WeixinConfigManager {
49
48
  entry.retryDelayMs = nextDelay;
50
49
  }
51
50
  else {
52
- this.cache.set(userId, {
51
+ this.#cache.set(userId, {
53
52
  config: { typingTicket: "" },
54
53
  everSucceeded: false,
55
54
  nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
@@ -58,6 +57,6 @@ export class WeixinConfigManager {
58
57
  }
59
58
  }
60
59
  }
61
- return this.cache.get(userId)?.config ?? { typingTicket: "" };
60
+ return this.#cache.get(userId)?.config ?? { typingTicket: "" };
62
61
  }
63
62
  }
@@ -5,5 +5,4 @@ export type IlinkLogger = {
5
5
  error: (msg: string) => void;
6
6
  withAccount: (accountId: string) => IlinkLogger;
7
7
  };
8
- export declare function setIlinkLogger(next: IlinkLogger): void;
9
8
  export declare const logger: IlinkLogger;
@@ -1,26 +1,12 @@
1
- let sink = {
2
- debug: () => { },
3
- info: () => { },
4
- warn: (msg) => console.warn(`[weixin-ilink] ${msg}`),
5
- error: (msg) => console.error(`[weixin-ilink] ${msg}`),
6
- withAccount(accountId) {
7
- const prefix = `[weixin-ilink:${accountId}]`;
8
- return {
9
- debug: (msg) => sink.debug(`${prefix} ${msg}`),
10
- info: (msg) => sink.info(`${prefix} ${msg}`),
11
- warn: (msg) => sink.warn(`${prefix} ${msg}`),
12
- error: (msg) => sink.error(`${prefix} ${msg}`),
13
- withAccount: () => this,
14
- };
15
- },
16
- };
17
- export function setIlinkLogger(next) {
18
- sink = next;
1
+ function createIlinkLogger(prefix = '[weixin-ilink]') {
2
+ return Object.freeze({
3
+ debug: () => { },
4
+ info: () => { },
5
+ warn: (msg) => console.warn(`${prefix} ${msg}`),
6
+ error: (msg) => console.error(`${prefix} ${msg}`),
7
+ withAccount(accountId) {
8
+ return createIlinkLogger(`[weixin-ilink:${accountId}]`);
9
+ },
10
+ });
19
11
  }
20
- export const logger = {
21
- debug: (msg) => sink.debug(msg),
22
- info: (msg) => sink.info(msg),
23
- warn: (msg) => sink.warn(msg),
24
- error: (msg) => sink.error(msg),
25
- withAccount: (accountId) => sink.withAccount(accountId),
26
- };
12
+ export const logger = createIlinkLogger();
@@ -2,11 +2,17 @@ import type { BaseInfo } from "./ilink-types.js";
2
2
  export declare const ILINK_APP_ID = "bot";
3
3
  export declare const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
4
4
  export declare const DEFAULT_API_BASE_URL = "https://ilinkai.weixin.qq.com";
5
- export declare function configureIlinkMeta(opts: {
6
- botAgent?: string;
7
- version?: string;
8
- }): void;
9
5
  export declare function buildClientVersion(version: string): number;
10
- export declare const ILINK_APP_CLIENT_VERSION: number;
11
- export declare function sanitizeBotAgent(raw: string | undefined): string;
12
- export declare function buildBaseInfo(): BaseInfo;
6
+ export declare function sanitizeBotAgent(raw: string | undefined, fallback?: string): string;
7
+ export interface IlinkClientMetadataOptions {
8
+ readonly botAgent?: string;
9
+ readonly version?: string;
10
+ }
11
+ /** Immutable request identity owned by one endpoint. */
12
+ export declare class IlinkClientMetadata {
13
+ readonly channelVersion: string;
14
+ readonly botAgent: string;
15
+ readonly appClientVersion: number;
16
+ constructor(options?: IlinkClientMetadataOptions);
17
+ buildBaseInfo(): BaseInfo;
18
+ }
package/lib/ilink-meta.js CHANGED
@@ -6,8 +6,6 @@ export const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
6
6
  export const DEFAULT_API_BASE_URL = "https://ilinkai.weixin.qq.com";
7
7
  const DEFAULT_BOT_AGENT = "Zhin.js";
8
8
  const BOT_AGENT_MAX_LEN = 256;
9
- let channelVersion = "0.0.0";
10
- let configuredBotAgent;
11
9
  function readPackageVersion() {
12
10
  try {
13
11
  const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
@@ -18,13 +16,7 @@ function readPackageVersion() {
18
16
  return "0.0.0";
19
17
  }
20
18
  }
21
- channelVersion = readPackageVersion();
22
- export function configureIlinkMeta(opts) {
23
- if (opts.botAgent)
24
- configuredBotAgent = opts.botAgent;
25
- if (opts.version)
26
- channelVersion = opts.version;
27
- }
19
+ const PACKAGE_VERSION = readPackageVersion();
28
20
  export function buildClientVersion(version) {
29
21
  const parts = version.split(".").map((p) => parseInt(p, 10));
30
22
  const major = parts[0] ?? 0;
@@ -32,25 +24,37 @@ export function buildClientVersion(version) {
32
24
  const patch = parts[2] ?? 0;
33
25
  return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
34
26
  }
35
- export const ILINK_APP_CLIENT_VERSION = buildClientVersion(channelVersion);
36
- export function sanitizeBotAgent(raw) {
27
+ export function sanitizeBotAgent(raw, fallback = DEFAULT_BOT_AGENT) {
37
28
  if (!raw || typeof raw !== "string")
38
- return configuredBotAgent ?? DEFAULT_BOT_AGENT;
29
+ return fallback;
39
30
  const trimmed = raw.trim();
40
31
  if (!trimmed)
41
- return configuredBotAgent ?? DEFAULT_BOT_AGENT;
32
+ return fallback;
42
33
  const productRe = /^[A-Za-z0-9_.\-]{1,32}\/[A-Za-z0-9_.+\-]{1,32}$/;
43
34
  const tokens = trimmed.split(/\s+/).filter((tok) => productRe.test(tok));
44
35
  if (tokens.length === 0)
45
- return configuredBotAgent ?? DEFAULT_BOT_AGENT;
36
+ return fallback;
46
37
  const joined = tokens.join(" ");
47
38
  if (Buffer.byteLength(joined, "utf-8") <= BOT_AGENT_MAX_LEN)
48
39
  return joined;
49
- return (configuredBotAgent ?? DEFAULT_BOT_AGENT);
40
+ return fallback;
50
41
  }
51
- export function buildBaseInfo() {
52
- return {
53
- channel_version: channelVersion,
54
- bot_agent: sanitizeBotAgent(configuredBotAgent),
55
- };
42
+ /** Immutable request identity owned by one endpoint. */
43
+ export class IlinkClientMetadata {
44
+ channelVersion;
45
+ botAgent;
46
+ appClientVersion;
47
+ constructor(options = {}) {
48
+ this.channelVersion = options.version ?? PACKAGE_VERSION;
49
+ const defaultBotAgent = `Zhin.js/${this.channelVersion}`;
50
+ this.botAgent = sanitizeBotAgent(options.botAgent, defaultBotAgent);
51
+ this.appClientVersion = buildClientVersion(this.channelVersion);
52
+ Object.freeze(this);
53
+ }
54
+ buildBaseInfo() {
55
+ return {
56
+ channel_version: this.channelVersion,
57
+ bot_agent: this.botAgent,
58
+ };
59
+ }
56
60
  }
@@ -1,15 +1,14 @@
1
- /** Error code returned by the server when the bot session has expired. */
2
1
  export declare const SESSION_EXPIRED_ERRCODE = -14;
3
- /** Pause all inbound/outbound API calls for `accountId` for one hour. */
4
- export declare function pauseSession(accountId: string): void;
5
- /** Returns `true` when the bot is still within its one-hour cooldown window. */
6
- export declare function isSessionPaused(accountId: string): boolean;
7
- /** Milliseconds remaining until the pause expires (0 when not paused). */
8
- export declare function getRemainingPauseMs(accountId: string): number;
9
- /** Throw if the session is currently paused. Call before any API request. */
10
- export declare function assertSessionActive(accountId: string): void;
11
- /**
12
- * Reset internal state — only for tests.
13
- * @internal
14
- */
15
- export declare function _resetForTest(): void;
2
+ export declare const SESSION_PAUSE_DURATION_MS: number;
3
+ /** Endpoint-owned cooldown state for an authenticated iLink session. */
4
+ export declare class IlinkSessionGuard {
5
+ #private;
6
+ readonly accountId: string;
7
+ private readonly now;
8
+ private readonly pauseDurationMs;
9
+ constructor(accountId: string, now?: () => number, pauseDurationMs?: number);
10
+ pause(): void;
11
+ get paused(): boolean;
12
+ get remainingMs(): number;
13
+ assertActive(): void;
14
+ }