@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,224 +1,61 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { logger } from "./ilink-logger.js";
4
- import { generateId } from "./ilink-random.js";
1
+ import { generateId } from './ilink-random.js';
5
2
  import { MessageItemType } from './ilink-types.js';
6
- import { resolveStateDir } from "./credentials.js";
7
- // ---------------------------------------------------------------------------
8
- // Context token store (in-process cache + disk persistence)
9
- // ---------------------------------------------------------------------------
10
- /**
11
- * contextToken is issued per-message by the Weixin getupdates API and must
12
- * be echoed verbatim in every outbound send. The in-memory map is the primary
13
- * lookup; a disk-backed file per account ensures tokens survive gateway restarts.
14
- */
15
- const contextTokenStore = new Map();
16
- function contextTokenKey(accountId, userId) {
17
- return `${accountId}:${userId}`;
18
- }
19
- // ---------------------------------------------------------------------------
20
- // Disk persistence helpers
21
- // ---------------------------------------------------------------------------
22
- function resolveContextTokenFilePath(accountId) {
23
- return path.join(resolveStateDir(), "context-tokens", `${accountId}.context-tokens.json`);
24
- }
25
- /** Persist all context tokens for a given account to disk. */
26
- function persistContextTokens(accountId) {
27
- const prefix = `${accountId}:`;
28
- const tokens = {};
29
- for (const [k, v] of contextTokenStore) {
30
- if (k.startsWith(prefix)) {
31
- tokens[k.slice(prefix.length)] = v;
32
- }
33
- }
34
- const filePath = resolveContextTokenFilePath(accountId);
35
- try {
36
- const dir = path.dirname(filePath);
37
- fs.mkdirSync(dir, { recursive: true });
38
- fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
39
- }
40
- catch (err) {
41
- logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
42
- }
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
- }
72
- /**
73
- * Restore persisted context tokens for an account into the in-memory map.
74
- * Called once during gateway startAccount to survive restarts.
75
- */
76
- export function restoreContextTokens(accountId) {
77
- const filePath = resolveContextTokenFilePath(accountId);
78
- try {
79
- if (!fs.existsSync(filePath))
80
- return;
81
- const raw = fs.readFileSync(filePath, "utf-8");
82
- const tokens = JSON.parse(raw);
83
- let count = 0;
84
- for (const [userId, token] of Object.entries(tokens)) {
85
- if (typeof token === "string" && token) {
86
- contextTokenStore.set(contextTokenKey(accountId, userId), token);
87
- count++;
88
- }
89
- }
90
- logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
91
- }
92
- catch (err) {
93
- logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
94
- }
95
- }
96
- /** Remove all context tokens for a given account (memory + disk). */
97
- export function clearContextTokensForAccount(accountId) {
98
- const prefix = `${accountId}:`;
99
- for (const k of [...contextTokenStore.keys()]) {
100
- if (k.startsWith(prefix)) {
101
- contextTokenStore.delete(k);
102
- }
103
- }
104
- const filePath = resolveContextTokenFilePath(accountId);
105
- try {
106
- if (fs.existsSync(filePath))
107
- fs.unlinkSync(filePath);
108
- }
109
- catch (err) {
110
- logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
111
- }
112
- logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
113
- }
114
- /** Store a context token for a given account+user pair (memory + debounced disk). */
115
- export function setContextToken(accountId, userId, token) {
116
- const k = contextTokenKey(accountId, userId);
117
- logger.debug(`setContextToken: key=${k}`);
118
- contextTokenStore.set(k, token);
119
- schedulePersistContextTokens(accountId);
120
- }
121
- /** Retrieve the cached context token for a given account+user pair. */
122
- export function getContextToken(accountId, userId) {
123
- const k = contextTokenKey(accountId, userId);
124
- const val = contextTokenStore.get(k);
125
- logger.debug(`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`);
126
- return val;
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
- }
142
- /**
143
- * Find all accountIds that have an active contextToken for the given userId.
144
- * Used to infer the sending bot account from the recipient address when
145
- * accountId is not explicitly provided (e.g. cron delivery).
146
- *
147
- * Returns all matching accountIds (not just the first) so the caller can
148
- * detect ambiguity when multiple accounts have sessions with the same user.
149
- */
150
- export function findAccountIdsByContextToken(accountIds, userId) {
151
- return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
152
- }
153
- // ---------------------------------------------------------------------------
154
- // Message ID generation
155
- // ---------------------------------------------------------------------------
156
3
  function generateMessageSid() {
157
- return generateId("openclaw-weixin");
4
+ return generateId('openclaw-weixin');
158
5
  }
159
- /** Returns true if the message item is a media type (image, video, file, or voice). */
160
6
  export function isMediaItem(item) {
161
- return (item.type === MessageItemType.IMAGE ||
162
- item.type === MessageItemType.VIDEO ||
163
- item.type === MessageItemType.FILE ||
164
- item.type === MessageItemType.VOICE);
7
+ return item.type === MessageItemType.IMAGE
8
+ || item.type === MessageItemType.VIDEO
9
+ || item.type === MessageItemType.FILE
10
+ || item.type === MessageItemType.VOICE;
165
11
  }
166
12
  export function bodyFromItemList(itemList) {
167
13
  if (!itemList?.length)
168
- return "";
14
+ return '';
169
15
  for (const item of itemList) {
170
16
  if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
171
17
  return String(item.text_item.text);
172
18
  }
173
- // 语音转文字:如果语音消息有 text 字段,直接使用文字内容
174
19
  if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
175
20
  return item.voice_item.text;
176
21
  }
177
22
  }
178
- return "";
23
+ return '';
179
24
  }
180
- /**
181
- * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
182
- * Media: only pass MediaPath (local file, after CDN download + decrypt).
183
- * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
184
- * Priority when multiple media types present: image > video > file > voice.
185
- */
186
25
  export function weixinMessageToMsgContext(msg, accountId, opts) {
187
- const from_user_id = msg.from_user_id ?? "";
26
+ const fromUserId = msg.from_user_id ?? '';
188
27
  const ctx = {
189
28
  Body: bodyFromItemList(msg.item_list),
190
- From: from_user_id,
191
- To: from_user_id,
29
+ From: fromUserId,
30
+ To: fromUserId,
192
31
  AccountId: accountId,
193
- OriginatingChannel: "openclaw-weixin",
194
- OriginatingTo: from_user_id,
32
+ OriginatingChannel: 'openclaw-weixin',
33
+ OriginatingTo: fromUserId,
195
34
  MessageSid: generateMessageSid(),
196
35
  Timestamp: msg.create_time_ms,
197
- Provider: "openclaw-weixin",
198
- ChatType: "direct",
36
+ Provider: 'openclaw-weixin',
37
+ ChatType: 'direct',
199
38
  };
200
- if (msg.context_token) {
39
+ if (msg.context_token)
201
40
  ctx.context_token = msg.context_token;
202
- }
203
41
  if (opts?.decryptedPicPath) {
204
42
  ctx.MediaPath = opts.decryptedPicPath;
205
- ctx.MediaType = "image/*";
43
+ ctx.MediaType = 'image/*';
206
44
  }
207
45
  else if (opts?.decryptedVideoPath) {
208
46
  ctx.MediaPath = opts.decryptedVideoPath;
209
- ctx.MediaType = "video/mp4";
47
+ ctx.MediaType = 'video/mp4';
210
48
  }
211
49
  else if (opts?.decryptedFilePath) {
212
50
  ctx.MediaPath = opts.decryptedFilePath;
213
- ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
51
+ ctx.MediaType = opts.fileMediaType ?? 'application/octet-stream';
214
52
  }
215
53
  else if (opts?.decryptedVoicePath) {
216
54
  ctx.MediaPath = opts.decryptedVoicePath;
217
- ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
55
+ ctx.MediaType = opts.voiceMediaType ?? 'audio/wav';
218
56
  }
219
57
  return ctx;
220
58
  }
221
- /** Extract the context_token from an inbound WeixinMsgContext. */
222
59
  export function getContextTokenFromMsgContext(ctx) {
223
60
  return ctx.context_token;
224
61
  }
@@ -15,7 +15,11 @@ import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin }
15
15
  export async function sendWeixinMediaFile(params) {
16
16
  const { filePath, to, text, opts, cdnBaseUrl } = params;
17
17
  const mime = getMimeFromFilename(filePath);
18
- const uploadOpts = { baseUrl: opts.baseUrl, token: opts.token };
18
+ const uploadOpts = {
19
+ baseUrl: opts.baseUrl,
20
+ metadata: opts.metadata,
21
+ token: opts.token,
22
+ };
19
23
  if (mime.startsWith("video/")) {
20
24
  logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
21
25
  const uploaded = await uploadVideoToWeixin({
@@ -51,6 +51,7 @@ export async function sendMessageWeixin(params) {
51
51
  try {
52
52
  await sendMessageApi({
53
53
  baseUrl: opts.baseUrl,
54
+ metadata: opts.metadata,
54
55
  token: opts.token,
55
56
  timeoutMs: opts.timeoutMs,
56
57
  body: req,
@@ -90,6 +91,7 @@ async function sendMediaItems(params) {
90
91
  try {
91
92
  await sendMessageApi({
92
93
  baseUrl: opts.baseUrl,
94
+ metadata: opts.metadata,
93
95
  token: opts.token,
94
96
  timeoutMs: opts.timeoutMs,
95
97
  body: req,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-weixin-ilink",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
6
6
  "main": "./lib/index.js",
@@ -48,17 +48,17 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@zhin.js/adapter": "1.1.12",
52
- "@zhin.js/core": "1.1.35",
53
- "@zhin.js/feature-kit": "1.1.0",
54
- "@zhin.js/im-contract": "1.1.0",
55
- "@zhin.js/logger": "1.1.0"
51
+ "@zhin.js/adapter": "1.1.14",
52
+ "@zhin.js/core": "1.1.37",
53
+ "@zhin.js/feature-kit": "1.1.1",
54
+ "@zhin.js/im-contract": "1.1.1",
55
+ "@zhin.js/logger": "1.1.1"
56
56
  },
57
57
  "peerDependencies": {
58
- "@zhin.js/adapter": "^1.1.12",
59
- "@zhin.js/command": "^1.1.0",
60
- "@zhin.js/core": "^1.1.35",
61
- "zhin.js": "^1.1.0"
58
+ "@zhin.js/adapter": "^1.1.14",
59
+ "@zhin.js/command": "^1.1.1",
60
+ "@zhin.js/core": "^1.1.37",
61
+ "zhin.js": "^1.1.2"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "@zhin.js/command": {
@@ -72,7 +72,7 @@
72
72
  "@types/node": "^26.1.2",
73
73
  "typescript": "^6.0.3",
74
74
  "vitest": "^4.1.10",
75
- "zhin.js": "1.1.0"
75
+ "zhin.js": "1.1.2"
76
76
  },
77
77
  "ilink_appid": "bot",
78
78
  "engines": {
package/plugin.js CHANGED
@@ -8,7 +8,7 @@ export default definePlugin({
8
8
  displayName: 'Weixin iLink Adapter',
9
9
  },
10
10
  setup(context) {
11
- // 运行中 endpoint 注册表(weixin-ilink.endpoint list 的"运行中"数据源)
11
+ // 运行中 endpoint 注册表(weixin-ilink endpoint list 的"运行中"数据源)
12
12
  context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
13
13
  },
14
14
  });
package/schema.json CHANGED
@@ -18,6 +18,12 @@
18
18
  "type": "number",
19
19
  "default": 35000
20
20
  },
21
+ "dataDir": {
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "default": "data/weixin-ilink",
25
+ "description": "Endpoint state root for credentials, cursors, context tokens, and media"
26
+ },
21
27
  "master": {
22
28
  "type": [
23
29
  "string",
@@ -38,10 +44,11 @@
38
44
  },
39
45
  "endpoints": {
40
46
  "type": "array",
47
+ "minItems": 1,
41
48
  "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
42
49
  "items": {
43
50
  "type": "object",
44
- "additionalProperties": true,
51
+ "additionalProperties": false,
45
52
  "properties": {
46
53
  "master": {
47
54
  "type": [
@@ -63,16 +70,37 @@
63
70
  },
64
71
  "botToken": {
65
72
  "type": "string",
66
- "description": "iLink bot token (prefer env WEIXIN_ILINK_TOKEN or sidecar credential file)"
73
+ "description": "iLink bot token; omit to load endpoint state or start QR login"
67
74
  },
68
75
  "id": {
69
76
  "type": "string",
77
+ "pattern": "^[A-Za-z0-9_.-]+$",
70
78
  "description": "Weixin iLink bot name"
79
+ },
80
+ "botAgent": {
81
+ "type": "string"
82
+ },
83
+ "baseUrl": {
84
+ "type": "string"
85
+ },
86
+ "cdnBaseUrl": {
87
+ "type": "string"
88
+ },
89
+ "longPollTimeoutMs": {
90
+ "type": "number"
91
+ },
92
+ "dataDir": {
93
+ "type": "string",
94
+ "minLength": 1,
95
+ "description": "Endpoint state root"
96
+ },
97
+ "commandPrefix": {
98
+ "type": "string",
99
+ "description": "本 endpoint 的命令前缀"
71
100
  }
72
101
  },
73
102
  "required": [
74
- "id",
75
- "botToken"
103
+ "id"
76
104
  ]
77
105
  }
78
106
  },
package/src/client.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { getContextToken } from './context-store.js';
1
+ import type { WeixinContextTokenStore } from './context-store.js';
2
2
  import type { WeixinIlinkCredentials } from './credentials.js';
3
3
  import { sendTyping } from './ilink-api.js';
4
4
  import type { WeixinConfigManager } from './ilink-config-cache.js';
5
+ import type { IlinkClientMetadata } from './ilink-meta.js';
5
6
  import type { ResolvedWeixinIlinkConfig } from './protocol.js';
6
7
  import { sendMessageWeixin } from './weixin-send.js';
7
8
  import { sendWeixinMediaFile } from './weixin-send-media.js';
@@ -9,25 +10,41 @@ import { defineEndpointClient } from 'zhin.js/adapter';
9
10
 
10
11
  /** Direct iLink API client backed by the Endpoint's live authenticated session. */
11
12
  export class WeixinIlinkClient {
13
+ readonly config: ResolvedWeixinIlinkConfig;
14
+ readonly #metadata: IlinkClientMetadata;
15
+ readonly #resolveCredentials: () => WeixinIlinkCredentials | null;
16
+ readonly #resolveConfigManager: () => WeixinConfigManager | undefined;
17
+ readonly #sendTextImpl: typeof sendMessageWeixin;
18
+ readonly #contextTokens: WeixinContextTokenStore;
19
+
12
20
  constructor(
13
- readonly config: ResolvedWeixinIlinkConfig,
14
- private readonly resolveCredentials: () => WeixinIlinkCredentials | null,
15
- private readonly resolveConfigManager: () => WeixinConfigManager | undefined,
16
- private readonly sendTextImpl: typeof sendMessageWeixin,
17
- ) {}
21
+ config: ResolvedWeixinIlinkConfig,
22
+ metadata: IlinkClientMetadata,
23
+ resolveCredentials: () => WeixinIlinkCredentials | null,
24
+ resolveConfigManager: () => WeixinConfigManager | undefined,
25
+ sendTextImpl: typeof sendMessageWeixin,
26
+ contextTokens: WeixinContextTokenStore,
27
+ ) {
28
+ this.config = config;
29
+ this.#metadata = metadata;
30
+ this.#resolveCredentials = resolveCredentials;
31
+ this.#resolveConfigManager = resolveConfigManager;
32
+ this.#sendTextImpl = sendTextImpl;
33
+ this.#contextTokens = contextTokens;
34
+ }
18
35
 
19
36
  get credentials(): WeixinIlinkCredentials {
20
- const credentials = this.resolveCredentials();
37
+ const credentials = this.#resolveCredentials();
21
38
  if (!credentials?.botToken) throw new Error('weixin-ilink client not authenticated');
22
39
  return credentials;
23
40
  }
24
41
 
25
42
  get authenticated(): boolean {
26
- return Boolean(this.resolveCredentials()?.botToken);
43
+ return Boolean(this.#resolveCredentials()?.botToken);
27
44
  }
28
45
 
29
46
  get apiBaseUrl(): string {
30
- return this.resolveCredentials()?.baseUrl ?? this.config.baseUrl;
47
+ return this.#resolveCredentials()?.baseUrl ?? this.config.baseUrl;
31
48
  }
32
49
 
33
50
  get cdnBaseUrl(): string {
@@ -35,16 +52,25 @@ export class WeixinIlinkClient {
35
52
  }
36
53
 
37
54
  contextToken(userId: string): string | undefined {
38
- return getContextToken(this.config.id, userId);
55
+ return this.#contextTokens.get(userId);
56
+ }
57
+
58
+ reachableUserIds(): string[] {
59
+ return this.#contextTokens.userIds();
39
60
  }
40
61
 
41
62
  async sendText(to: string, text: string): Promise<{ messageId: string }> {
42
63
  const contextToken = this.contextToken(to);
43
64
  if (!contextToken) throw new Error(`missing context_token for peer ${to}`);
44
- return this.sendTextImpl({
65
+ return this.#sendTextImpl({
45
66
  to,
46
67
  text,
47
- opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
68
+ opts: {
69
+ baseUrl: this.apiBaseUrl,
70
+ metadata: this.#metadata,
71
+ token: this.credentials.botToken,
72
+ contextToken,
73
+ },
48
74
  });
49
75
  }
50
76
 
@@ -55,18 +81,24 @@ export class WeixinIlinkClient {
55
81
  filePath,
56
82
  to,
57
83
  text,
58
- opts: { baseUrl: this.apiBaseUrl, token: this.credentials.botToken, contextToken },
84
+ opts: {
85
+ baseUrl: this.apiBaseUrl,
86
+ metadata: this.#metadata,
87
+ token: this.credentials.botToken,
88
+ contextToken,
89
+ },
59
90
  cdnBaseUrl: this.cdnBaseUrl,
60
91
  });
61
92
  }
62
93
 
63
94
  async sendTyping(userId: string, status: number): Promise<boolean> {
64
- const manager = this.resolveConfigManager();
95
+ const manager = this.#resolveConfigManager();
65
96
  if (!this.authenticated || !manager) return false;
66
97
  const cfg = await manager.getForUser(userId, this.contextToken(userId));
67
98
  if (!cfg.typingTicket) return false;
68
99
  await sendTyping({
69
100
  baseUrl: this.apiBaseUrl,
101
+ metadata: this.#metadata,
70
102
  token: this.credentials.botToken,
71
103
  body: { ilink_user_id: userId, typing_ticket: cfg.typingTicket, status },
72
104
  });
@@ -1,10 +1,101 @@
1
- export {
2
- setContextToken,
3
- getContextToken,
4
- listContextTokenUserIds,
5
- restoreContextTokens,
6
- clearContextTokensForAccount,
7
- flushContextTokenPersist,
8
- bodyFromItemList,
9
- isMediaItem,
10
- } from "./weixin-inbound.js";
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { WeixinIlinkStateStore } from './credentials.js';
4
+ import { logger } from './ilink-logger.js';
5
+
6
+ const PERSIST_DEBOUNCE_MS = 500;
7
+
8
+ /** Endpoint-owned context-token repository with account-scoped persistence. */
9
+ export class WeixinContextTokenStore {
10
+ readonly state: WeixinIlinkStateStore;
11
+ readonly #tokens = new Map<string, string>();
12
+ #persistTimer?: ReturnType<typeof setTimeout>;
13
+
14
+ constructor(state: WeixinIlinkStateStore) {
15
+ this.state = state;
16
+ }
17
+
18
+ get accountId(): string {
19
+ return this.state.endpointId;
20
+ }
21
+
22
+ get(userId: string): string | undefined {
23
+ const value = this.#tokens.get(userId);
24
+ logger.debug(`contextTokenStore.get: account=${this.accountId} user=${userId} found=${value !== undefined} storeSize=${this.#tokens.size}`);
25
+ return value;
26
+ }
27
+
28
+ set(userId: string, token: string): void {
29
+ logger.debug(`contextTokenStore.set: account=${this.accountId} user=${userId}`);
30
+ this.#tokens.set(userId, token);
31
+ this.#schedulePersist();
32
+ }
33
+
34
+ userIds(): string[] {
35
+ return [...this.#tokens.keys()];
36
+ }
37
+
38
+ restore(): void {
39
+ const filePath = this.#filePath();
40
+ try {
41
+ if (!fs.existsSync(filePath)) return;
42
+ const tokens = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
43
+ let count = 0;
44
+ for (const [userId, token] of Object.entries(tokens)) {
45
+ if (typeof token !== 'string' || !token) continue;
46
+ this.#tokens.set(userId, token);
47
+ count += 1;
48
+ }
49
+ logger.info(`contextTokenStore.restore: restored ${count} tokens for account=${this.accountId}`);
50
+ } catch (error) {
51
+ logger.warn(`contextTokenStore.restore: failed to read ${filePath}: ${String(error)}`);
52
+ }
53
+ }
54
+
55
+ flush(): void {
56
+ if (this.#persistTimer) {
57
+ clearTimeout(this.#persistTimer);
58
+ this.#persistTimer = undefined;
59
+ }
60
+ this.#persist();
61
+ }
62
+
63
+ clear(): void {
64
+ if (this.#persistTimer) {
65
+ clearTimeout(this.#persistTimer);
66
+ this.#persistTimer = undefined;
67
+ }
68
+ this.#tokens.clear();
69
+ const filePath = this.#filePath();
70
+ try {
71
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
72
+ } catch (error) {
73
+ logger.warn(`contextTokenStore.clear: failed to remove ${filePath}: ${String(error)}`);
74
+ }
75
+ }
76
+
77
+ #filePath(): string {
78
+ return this.state.contextTokensPath();
79
+ }
80
+
81
+ #schedulePersist(): void {
82
+ if (this.#persistTimer) clearTimeout(this.#persistTimer);
83
+ this.#persistTimer = setTimeout(() => {
84
+ this.#persistTimer = undefined;
85
+ this.#persist();
86
+ }, PERSIST_DEBOUNCE_MS);
87
+ this.#persistTimer.unref?.();
88
+ }
89
+
90
+ #persist(): void {
91
+ const filePath = this.#filePath();
92
+ try {
93
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
94
+ fs.writeFileSync(filePath, JSON.stringify(Object.fromEntries(this.#tokens)), 'utf-8');
95
+ } catch (error) {
96
+ logger.warn(`contextTokenStore.persist: failed to write ${filePath}: ${String(error)}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ export { bodyFromItemList, isMediaItem } from './weixin-inbound.js';