@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,287 +1,95 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- import { logger } from "./ilink-logger.js";
5
- import { generateId } from "./ilink-random.js";
1
+ import { generateId } from './ilink-random.js';
6
2
  import { MessageItemType, type WeixinMessage, type MessageItem } from './ilink-types.js';
7
3
 
8
- import { resolveStateDir } from "./credentials.js";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Context token store (in-process cache + disk persistence)
12
- // ---------------------------------------------------------------------------
13
-
14
- /**
15
- * contextToken is issued per-message by the Weixin getupdates API and must
16
- * be echoed verbatim in every outbound send. The in-memory map is the primary
17
- * lookup; a disk-backed file per account ensures tokens survive gateway restarts.
18
- */
19
- const contextTokenStore = new Map<string, string>();
20
-
21
- function contextTokenKey(accountId: string, userId: string): string {
22
- return `${accountId}:${userId}`;
23
- }
24
-
25
- // ---------------------------------------------------------------------------
26
- // Disk persistence helpers
27
- // ---------------------------------------------------------------------------
28
-
29
- function resolveContextTokenFilePath(accountId: string): string {
30
- return path.join(resolveStateDir(), "context-tokens", `${accountId}.context-tokens.json`);
31
- }
32
-
33
- /** Persist all context tokens for a given account to disk. */
34
- function persistContextTokens(accountId: string): void {
35
- const prefix = `${accountId}:`;
36
- const tokens: Record<string, string> = {};
37
- for (const [k, v] of contextTokenStore) {
38
- if (k.startsWith(prefix)) {
39
- tokens[k.slice(prefix.length)] = v;
40
- }
41
- }
42
- const filePath = resolveContextTokenFilePath(accountId);
43
- try {
44
- const dir = path.dirname(filePath);
45
- fs.mkdirSync(dir, { recursive: true });
46
- fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
47
- } catch (err) {
48
- logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`);
49
- }
50
- }
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
-
81
- /**
82
- * Restore persisted context tokens for an account into the in-memory map.
83
- * Called once during gateway startAccount to survive restarts.
84
- */
85
- export function restoreContextTokens(accountId: string): void {
86
- const filePath = resolveContextTokenFilePath(accountId);
87
- try {
88
- if (!fs.existsSync(filePath)) return;
89
- const raw = fs.readFileSync(filePath, "utf-8");
90
- const tokens = JSON.parse(raw) as Record<string, string>;
91
- let count = 0;
92
- for (const [userId, token] of Object.entries(tokens)) {
93
- if (typeof token === "string" && token) {
94
- contextTokenStore.set(contextTokenKey(accountId, userId), token);
95
- count++;
96
- }
97
- }
98
- logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`);
99
- } catch (err) {
100
- logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`);
101
- }
102
- }
103
-
104
- /** Remove all context tokens for a given account (memory + disk). */
105
- export function clearContextTokensForAccount(accountId: string): void {
106
- const prefix = `${accountId}:`;
107
- for (const k of [...contextTokenStore.keys()]) {
108
- if (k.startsWith(prefix)) {
109
- contextTokenStore.delete(k);
110
- }
111
- }
112
- const filePath = resolveContextTokenFilePath(accountId);
113
- try {
114
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
115
- } catch (err) {
116
- logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`);
117
- }
118
- logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`);
119
- }
120
-
121
- /** Store a context token for a given account+user pair (memory + debounced disk). */
122
- export function setContextToken(accountId: string, userId: string, token: string): void {
123
- const k = contextTokenKey(accountId, userId);
124
- logger.debug(`setContextToken: key=${k}`);
125
- contextTokenStore.set(k, token);
126
- schedulePersistContextTokens(accountId);
127
- }
128
-
129
- /** Retrieve the cached context token for a given account+user pair. */
130
- export function getContextToken(accountId: string, userId: string): string | undefined {
131
- const k = contextTokenKey(accountId, userId);
132
- const val = contextTokenStore.get(k);
133
- logger.debug(
134
- `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
135
- );
136
- return val;
137
- }
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
-
153
- /**
154
- * Find all accountIds that have an active contextToken for the given userId.
155
- * Used to infer the sending bot account from the recipient address when
156
- * accountId is not explicitly provided (e.g. cron delivery).
157
- *
158
- * Returns all matching accountIds (not just the first) so the caller can
159
- * detect ambiguity when multiple accounts have sessions with the same user.
160
- */
161
- export function findAccountIdsByContextToken(
162
- accountIds: string[],
163
- userId: string,
164
- ): string[] {
165
- return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId)));
166
- }
167
-
168
- // ---------------------------------------------------------------------------
169
- // Message ID generation
170
- // ---------------------------------------------------------------------------
171
-
172
4
  function generateMessageSid(): string {
173
- return generateId("openclaw-weixin");
5
+ return generateId('openclaw-weixin');
174
6
  }
175
7
 
176
- /** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
177
8
  export type WeixinMsgContext = {
178
9
  Body: string;
179
10
  From: string;
180
11
  To: string;
181
12
  AccountId: string;
182
- OriginatingChannel: "openclaw-weixin";
13
+ OriginatingChannel: 'openclaw-weixin';
183
14
  OriginatingTo: string;
184
15
  MessageSid: string;
185
16
  Timestamp?: number;
186
- Provider: "openclaw-weixin";
187
- ChatType: "direct";
188
- /** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
17
+ Provider: 'openclaw-weixin';
18
+ ChatType: 'direct';
189
19
  SessionKey?: string;
190
20
  context_token?: string;
191
21
  MediaUrl?: string;
192
22
  MediaPath?: string;
193
23
  MediaType?: string;
194
- /** Raw message body for framework command authorization. */
195
24
  CommandBody?: string;
196
- /** Whether the sender is authorized to execute slash commands. */
197
25
  CommandAuthorized?: boolean;
198
26
  };
199
27
 
200
- /** Returns true if the message item is a media type (image, video, file, or voice). */
201
28
  export function isMediaItem(item: MessageItem): boolean {
202
- return (
203
- item.type === MessageItemType.IMAGE ||
204
- item.type === MessageItemType.VIDEO ||
205
- item.type === MessageItemType.FILE ||
206
- item.type === MessageItemType.VOICE
207
- );
29
+ return item.type === MessageItemType.IMAGE
30
+ || item.type === MessageItemType.VIDEO
31
+ || item.type === MessageItemType.FILE
32
+ || item.type === MessageItemType.VOICE;
208
33
  }
209
34
 
210
35
  export function bodyFromItemList(itemList?: MessageItem[]): string {
211
- if (!itemList?.length) return "";
36
+ if (!itemList?.length) return '';
212
37
  for (const item of itemList) {
213
38
  if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
214
39
  return String(item.text_item.text);
215
40
  }
216
- // 语音转文字:如果语音消息有 text 字段,直接使用文字内容
217
41
  if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
218
42
  return item.voice_item.text;
219
43
  }
220
44
  }
221
- return "";
45
+ return '';
222
46
  }
223
47
 
224
48
  export type WeixinInboundMediaOpts = {
225
- /** Local path to decrypted image file. */
226
49
  decryptedPicPath?: string;
227
- /** Local path to transcoded/raw voice file (.wav or .silk). */
228
50
  decryptedVoicePath?: string;
229
- /** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
230
51
  voiceMediaType?: string;
231
- /** Local path to decrypted file attachment. */
232
52
  decryptedFilePath?: string;
233
- /** MIME type for the file attachment (guessed from file_name). */
234
53
  fileMediaType?: string;
235
- /** Local path to decrypted video file. */
236
54
  decryptedVideoPath?: string;
237
55
  };
238
56
 
239
- /**
240
- * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
241
- * Media: only pass MediaPath (local file, after CDN download + decrypt).
242
- * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
243
- * Priority when multiple media types present: image > video > file > voice.
244
- */
245
57
  export function weixinMessageToMsgContext(
246
58
  msg: WeixinMessage,
247
59
  accountId: string,
248
60
  opts?: WeixinInboundMediaOpts,
249
61
  ): WeixinMsgContext {
250
- const from_user_id = msg.from_user_id ?? "";
62
+ const fromUserId = msg.from_user_id ?? '';
251
63
  const ctx: WeixinMsgContext = {
252
64
  Body: bodyFromItemList(msg.item_list),
253
- From: from_user_id,
254
- To: from_user_id,
65
+ From: fromUserId,
66
+ To: fromUserId,
255
67
  AccountId: accountId,
256
- OriginatingChannel: "openclaw-weixin",
257
- OriginatingTo: from_user_id,
68
+ OriginatingChannel: 'openclaw-weixin',
69
+ OriginatingTo: fromUserId,
258
70
  MessageSid: generateMessageSid(),
259
71
  Timestamp: msg.create_time_ms,
260
- Provider: "openclaw-weixin",
261
- ChatType: "direct",
72
+ Provider: 'openclaw-weixin',
73
+ ChatType: 'direct',
262
74
  };
263
- if (msg.context_token) {
264
- ctx.context_token = msg.context_token;
265
- }
75
+ if (msg.context_token) ctx.context_token = msg.context_token;
266
76
 
267
77
  if (opts?.decryptedPicPath) {
268
78
  ctx.MediaPath = opts.decryptedPicPath;
269
- ctx.MediaType = "image/*";
79
+ ctx.MediaType = 'image/*';
270
80
  } else if (opts?.decryptedVideoPath) {
271
81
  ctx.MediaPath = opts.decryptedVideoPath;
272
- ctx.MediaType = "video/mp4";
82
+ ctx.MediaType = 'video/mp4';
273
83
  } else if (opts?.decryptedFilePath) {
274
84
  ctx.MediaPath = opts.decryptedFilePath;
275
- ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
85
+ ctx.MediaType = opts.fileMediaType ?? 'application/octet-stream';
276
86
  } else if (opts?.decryptedVoicePath) {
277
87
  ctx.MediaPath = opts.decryptedVoicePath;
278
- ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
88
+ ctx.MediaType = opts.voiceMediaType ?? 'audio/wav';
279
89
  }
280
-
281
90
  return ctx;
282
91
  }
283
92
 
284
- /** Extract the context_token from an inbound WeixinMsgContext. */
285
93
  export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
286
94
  return ctx.context_token;
287
95
  }
@@ -23,7 +23,11 @@ export async function sendWeixinMediaFile(params: {
23
23
  }): Promise<{ messageId: string }> {
24
24
  const { filePath, to, text, opts, cdnBaseUrl } = params;
25
25
  const mime = getMimeFromFilename(filePath);
26
- const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
26
+ const uploadOpts: WeixinApiOptions = {
27
+ baseUrl: opts.baseUrl,
28
+ metadata: opts.metadata,
29
+ token: opts.token,
30
+ };
27
31
 
28
32
  if (mime.startsWith("video/")) {
29
33
  logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
@@ -72,6 +72,7 @@ export async function sendMessageWeixin(params: {
72
72
  try {
73
73
  await sendMessageApi({
74
74
  baseUrl: opts.baseUrl,
75
+ metadata: opts.metadata,
75
76
  token: opts.token,
76
77
  timeoutMs: opts.timeoutMs,
77
78
  body: req,
@@ -119,6 +120,7 @@ async function sendMediaItems(params: {
119
120
  try {
120
121
  await sendMessageApi({
121
122
  baseUrl: opts.baseUrl,
123
+ metadata: opts.metadata,
122
124
  token: opts.token,
123
125
  timeoutMs: opts.timeoutMs,
124
126
  body: req,
@@ -1,3 +0,0 @@
1
- import { weixinIlinkEndpointCommands } from '../../../src/weixin-ilink-endpoint-commands.js';
2
-
3
- export default weixinIlinkEndpointCommands.add;
@@ -1,3 +0,0 @@
1
- import { weixinIlinkEndpointCommands } from '../../src/weixin-ilink-endpoint-commands.js';
2
-
3
- export default weixinIlinkEndpointCommands.list;
@@ -1,3 +0,0 @@
1
- import { weixinIlinkEndpointCommands } from '../../../src/weixin-ilink-endpoint-commands.js';
2
-
3
- export default weixinIlinkEndpointCommands.remove;
@@ -1 +0,0 @@
1
- export declare const weixinIlinkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;