@netcat-ai/openclaw-weixin 2.4.7

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 (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CHANGELOG.zh_CN.md +156 -0
  3. package/LICENSE +27 -0
  4. package/README.md +355 -0
  5. package/README.zh_CN.md +351 -0
  6. package/dist/index.js +16 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/src/api/api.js +484 -0
  9. package/dist/src/api/api.js.map +1 -0
  10. package/dist/src/api/config-cache.js +64 -0
  11. package/dist/src/api/config-cache.js.map +1 -0
  12. package/dist/src/api/session-guard.js +49 -0
  13. package/dist/src/api/session-guard.js.map +1 -0
  14. package/dist/src/api/types.js +37 -0
  15. package/dist/src/api/types.js.map +1 -0
  16. package/dist/src/auth/accounts.js +328 -0
  17. package/dist/src/auth/accounts.js.map +1 -0
  18. package/dist/src/auth/login-qr.js +331 -0
  19. package/dist/src/auth/login-qr.js.map +1 -0
  20. package/dist/src/auth/pairing.js +104 -0
  21. package/dist/src/auth/pairing.js.map +1 -0
  22. package/dist/src/cdn/aes-ecb.js +19 -0
  23. package/dist/src/cdn/aes-ecb.js.map +1 -0
  24. package/dist/src/cdn/cdn-upload.js +74 -0
  25. package/dist/src/cdn/cdn-upload.js.map +1 -0
  26. package/dist/src/cdn/cdn-url.js +14 -0
  27. package/dist/src/cdn/cdn-url.js.map +1 -0
  28. package/dist/src/cdn/pic-decrypt.js +89 -0
  29. package/dist/src/cdn/pic-decrypt.js.map +1 -0
  30. package/dist/src/cdn/upload.js +115 -0
  31. package/dist/src/cdn/upload.js.map +1 -0
  32. package/dist/src/channel.js +478 -0
  33. package/dist/src/channel.js.map +1 -0
  34. package/dist/src/compat.js +67 -0
  35. package/dist/src/compat.js.map +1 -0
  36. package/dist/src/config/config-schema.js +20 -0
  37. package/dist/src/config/config-schema.js.map +1 -0
  38. package/dist/src/config/reply-progress.js +5 -0
  39. package/dist/src/config/reply-progress.js.map +1 -0
  40. package/dist/src/media/media-download.js +95 -0
  41. package/dist/src/media/media-download.js.map +1 -0
  42. package/dist/src/media/mime.js +73 -0
  43. package/dist/src/media/mime.js.map +1 -0
  44. package/dist/src/media/silk-transcode.js +64 -0
  45. package/dist/src/media/silk-transcode.js.map +1 -0
  46. package/dist/src/messaging/debug-mode.js +63 -0
  47. package/dist/src/messaging/debug-mode.js.map +1 -0
  48. package/dist/src/messaging/error-notice.js +25 -0
  49. package/dist/src/messaging/error-notice.js.map +1 -0
  50. package/dist/src/messaging/inbound.js +201 -0
  51. package/dist/src/messaging/inbound.js.map +1 -0
  52. package/dist/src/messaging/markdown-filter.js +368 -0
  53. package/dist/src/messaging/markdown-filter.js.map +1 -0
  54. package/dist/src/messaging/outbound-hooks.js +58 -0
  55. package/dist/src/messaging/outbound-hooks.js.map +1 -0
  56. package/dist/src/messaging/process-message.js +406 -0
  57. package/dist/src/messaging/process-message.js.map +1 -0
  58. package/dist/src/messaging/reply-progress-sender.js +93 -0
  59. package/dist/src/messaging/reply-progress-sender.js.map +1 -0
  60. package/dist/src/messaging/send-media.js +54 -0
  61. package/dist/src/messaging/send-media.js.map +1 -0
  62. package/dist/src/messaging/send.js +220 -0
  63. package/dist/src/messaging/send.js.map +1 -0
  64. package/dist/src/messaging/slash-commands.js +70 -0
  65. package/dist/src/messaging/slash-commands.js.map +1 -0
  66. package/dist/src/monitor/monitor.js +146 -0
  67. package/dist/src/monitor/monitor.js.map +1 -0
  68. package/dist/src/storage/state-dir.js +9 -0
  69. package/dist/src/storage/state-dir.js.map +1 -0
  70. package/dist/src/storage/sync-buf.js +64 -0
  71. package/dist/src/storage/sync-buf.js.map +1 -0
  72. package/dist/src/util/logger.js +120 -0
  73. package/dist/src/util/logger.js.map +1 -0
  74. package/dist/src/util/random.js +16 -0
  75. package/dist/src/util/random.js.map +1 -0
  76. package/dist/src/util/redact.js +54 -0
  77. package/dist/src/util/redact.js.map +1 -0
  78. package/index.ts +19 -0
  79. package/openclaw.plugin.json +22 -0
  80. package/package.json +75 -0
  81. package/src/api/api.ts +586 -0
  82. package/src/api/config-cache.ts +79 -0
  83. package/src/api/session-guard.ts +58 -0
  84. package/src/api/types.ts +278 -0
  85. package/src/auth/accounts.ts +394 -0
  86. package/src/auth/login-qr.ts +458 -0
  87. package/src/auth/pairing.ts +120 -0
  88. package/src/cdn/aes-ecb.ts +21 -0
  89. package/src/cdn/cdn-upload.ts +93 -0
  90. package/src/cdn/cdn-url.ts +20 -0
  91. package/src/cdn/pic-decrypt.ts +101 -0
  92. package/src/cdn/upload.ts +168 -0
  93. package/src/channel.ts +548 -0
  94. package/src/compat.ts +77 -0
  95. package/src/config/config-schema.ts +23 -0
  96. package/src/config/reply-progress.ts +10 -0
  97. package/src/media/media-download.ts +149 -0
  98. package/src/media/mime.ts +76 -0
  99. package/src/media/silk-transcode.ts +74 -0
  100. package/src/messaging/debug-mode.ts +69 -0
  101. package/src/messaging/error-notice.ts +32 -0
  102. package/src/messaging/inbound.ts +262 -0
  103. package/src/messaging/markdown-filter.ts +361 -0
  104. package/src/messaging/outbound-hooks.ts +88 -0
  105. package/src/messaging/process-message.ts +525 -0
  106. package/src/messaging/reply-progress-sender.ts +122 -0
  107. package/src/messaging/send-media.ts +72 -0
  108. package/src/messaging/send.ts +294 -0
  109. package/src/messaging/slash-commands.ts +110 -0
  110. package/src/monitor/monitor.ts +224 -0
  111. package/src/storage/state-dir.ts +11 -0
  112. package/src/storage/sync-buf.ts +81 -0
  113. package/src/util/logger.ts +145 -0
  114. package/src/util/random.ts +17 -0
  115. package/src/util/redact.ts +54 -0
  116. package/src/vendor.d.ts +25 -0
@@ -0,0 +1,394 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
6
+
7
+ import { resolveStateDir } from "../storage/state-dir.js";
8
+ import { resolveFrameworkAllowFromPath } from "./pairing.js";
9
+ import { logger } from "../util/logger.js";
10
+
11
+ export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
12
+ export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
13
+
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Account ID compatibility (legacy raw ID → normalized ID)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.
21
+ * Used only as a compatibility fallback when loading accounts / sync bufs stored
22
+ * under the old raw ID.
23
+ * e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot"
24
+ */
25
+ export function deriveRawAccountId(normalizedId: string): string | undefined {
26
+ if (normalizedId.endsWith("-im-bot")) {
27
+ return `${normalizedId.slice(0, -7)}@im.bot`;
28
+ }
29
+ if (normalizedId.endsWith("-im-wechat")) {
30
+ return `${normalizedId.slice(0, -10)}@im.wechat`;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Account index (persistent list of registered account IDs)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function resolveWeixinStateDir(): string {
40
+ return path.join(resolveStateDir(), "openclaw-weixin");
41
+ }
42
+
43
+ function resolveAccountIndexPath(): string {
44
+ return path.join(resolveWeixinStateDir(), "accounts.json");
45
+ }
46
+
47
+ /** Returns all accountIds registered via QR login. */
48
+ export function listIndexedWeixinAccountIds(): string[] {
49
+ const filePath = resolveAccountIndexPath();
50
+ try {
51
+ if (!fs.existsSync(filePath)) return [];
52
+ const raw = fs.readFileSync(filePath, "utf-8");
53
+ const parsed = JSON.parse(raw);
54
+ if (!Array.isArray(parsed)) return [];
55
+ return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /** Add accountId to the persistent index (no-op if already present). */
62
+ export function registerWeixinAccountId(accountId: string): void {
63
+ const dir = resolveWeixinStateDir();
64
+ fs.mkdirSync(dir, { recursive: true });
65
+
66
+ const existing = listIndexedWeixinAccountIds();
67
+ if (existing.includes(accountId)) return;
68
+
69
+ const updated = [...existing, accountId];
70
+ fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
71
+ }
72
+
73
+ /** Remove accountId from the persistent index. */
74
+ export function unregisterWeixinAccountId(accountId: string): void {
75
+ const existing = listIndexedWeixinAccountIds();
76
+ const updated = existing.filter((id) => id !== accountId);
77
+ if (updated.length !== existing.length) {
78
+ fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Remove stale accounts that share the same userId as the newly-bound account.
84
+ * Called after a successful QR login to ensure only the latest account remains
85
+ * for a given WeChat user, preventing ambiguous contextToken matches.
86
+ *
87
+ * @param onClearContextTokens callback to clear context tokens for the removed account
88
+ */
89
+ export function clearStaleAccountsForUserId(
90
+ currentAccountId: string,
91
+ userId: string,
92
+ onClearContextTokens?: (accountId: string) => void,
93
+ ): void {
94
+ if (!userId) return;
95
+ const allIds = listIndexedWeixinAccountIds();
96
+ for (const id of allIds) {
97
+ if (id === currentAccountId) continue;
98
+ const data = loadWeixinAccount(id);
99
+ if (data?.userId?.trim() === userId) {
100
+ logger.info(`clearStaleAccountsForUserId: removing stale account=${id} (same userId=${userId})`);
101
+ onClearContextTokens?.(id);
102
+ clearWeixinAccount(id);
103
+ unregisterWeixinAccountId(id);
104
+ }
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Account store (per-account credential files)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Unified per-account data: token + baseUrl in one file. */
113
+ export type WeixinAccountData = {
114
+ token?: string;
115
+ savedAt?: string;
116
+ baseUrl?: string;
117
+ /** Last linked Weixin user id from QR login (optional). */
118
+ userId?: string;
119
+ };
120
+
121
+ function resolveAccountsDir(): string {
122
+ return path.join(resolveWeixinStateDir(), "accounts");
123
+ }
124
+
125
+ function resolveAccountPath(accountId: string): string {
126
+ return path.join(resolveAccountsDir(), `${accountId}.json`);
127
+ }
128
+
129
+ /**
130
+ * Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).
131
+ */
132
+ function loadLegacyToken(): string | undefined {
133
+ const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json");
134
+ try {
135
+ if (!fs.existsSync(legacyPath)) return undefined;
136
+ const raw = fs.readFileSync(legacyPath, "utf-8");
137
+ const parsed = JSON.parse(raw) as { token?: string };
138
+ return typeof parsed.token === "string" ? parsed.token : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ function readAccountFile(filePath: string): WeixinAccountData | null {
145
+ try {
146
+ if (fs.existsSync(filePath)) {
147
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WeixinAccountData;
148
+ }
149
+ } catch {
150
+ // ignore
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /** Load account data by ID, with compatibility fallbacks. */
156
+ export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
157
+ // Primary: try given accountId (normalized IDs written after this change).
158
+ const primary = readAccountFile(resolveAccountPath(accountId));
159
+ if (primary) return primary;
160
+
161
+ // Compatibility: if the given ID is normalized, derive the old raw filename
162
+ // (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
163
+ const rawId = deriveRawAccountId(accountId);
164
+ if (rawId) {
165
+ const compat = readAccountFile(resolveAccountPath(rawId));
166
+ if (compat) return compat;
167
+ }
168
+
169
+ // Legacy fallback: read token from old single-account credentials file.
170
+ const token = loadLegacyToken();
171
+ if (token) return { token };
172
+
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Persist account data after QR login (merges into existing file).
178
+ * - token: overwritten when provided.
179
+ * - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.
180
+ * - userId: set when `update.userId` is provided; omitted from file when cleared to empty.
181
+ */
182
+ export function saveWeixinAccount(
183
+ accountId: string,
184
+ update: { token?: string; baseUrl?: string; userId?: string },
185
+ ): void {
186
+ const dir = resolveAccountsDir();
187
+ fs.mkdirSync(dir, { recursive: true });
188
+
189
+ const existing = loadWeixinAccount(accountId) ?? {};
190
+
191
+ const token = update.token?.trim() || existing.token;
192
+ const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
193
+ const userId =
194
+ update.userId !== undefined
195
+ ? update.userId.trim() || undefined
196
+ : existing.userId?.trim() || undefined;
197
+
198
+ const data: WeixinAccountData = {
199
+ ...(token ? { token, savedAt: new Date().toISOString() } : {}),
200
+ ...(baseUrl ? { baseUrl } : {}),
201
+ ...(userId ? { userId } : {}),
202
+ };
203
+
204
+ const filePath = resolveAccountPath(accountId);
205
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
206
+ try {
207
+ fs.chmodSync(filePath, 0o600);
208
+ } catch {
209
+ // best-effort
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Remove all files associated with an account:
215
+ * - accounts/{accountId}.json (credentials)
216
+ * - accounts/{accountId}.sync.json (getUpdates sync buf)
217
+ * - accounts/{accountId}.context-tokens.json (context tokens on disk)
218
+ * - credentials/openclaw-weixin-{accountId}-allowFrom.json (authorized users)
219
+ */
220
+ export function clearWeixinAccount(accountId: string): void {
221
+ const dir = resolveAccountsDir();
222
+ const accountFiles = [
223
+ `${accountId}.json`,
224
+ `${accountId}.sync.json`,
225
+ `${accountId}.context-tokens.json`,
226
+ ];
227
+ for (const file of accountFiles) {
228
+ try {
229
+ fs.unlinkSync(path.join(dir, file));
230
+ } catch {
231
+ // ignore if not found
232
+ }
233
+ }
234
+ try {
235
+ fs.unlinkSync(resolveFrameworkAllowFromPath(accountId));
236
+ } catch {
237
+ // ignore if not found
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Resolve the openclaw.json config file path.
243
+ * Checks OPENCLAW_CONFIG env var, then state dir.
244
+ */
245
+ function resolveConfigPath(): string {
246
+ const envPath = process.env.OPENCLAW_CONFIG?.trim();
247
+ if (envPath) return envPath;
248
+ return path.join(resolveStateDir(), "openclaw.json");
249
+ }
250
+
251
+ /**
252
+ * Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
253
+ * Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
254
+ * `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
255
+ *
256
+ * The config is cached after the first read since routeTag does not change at runtime.
257
+ */
258
+ let cachedRouteTagSection: Record<string, unknown> | null | undefined;
259
+
260
+ function loadRouteTagSection(): Record<string, unknown> | null {
261
+ if (cachedRouteTagSection !== undefined) return cachedRouteTagSection;
262
+ try {
263
+ const configPath = resolveConfigPath();
264
+ if (!fs.existsSync(configPath)) { cachedRouteTagSection = null; return null; }
265
+ const raw = fs.readFileSync(configPath, "utf-8");
266
+ const cfg = JSON.parse(raw) as Record<string, unknown>;
267
+ const channels = cfg.channels as Record<string, unknown> | undefined;
268
+ const section = (channels?.["openclaw-weixin"] as Record<string, unknown>) ?? null;
269
+ cachedRouteTagSection = section;
270
+ return section;
271
+ } catch {
272
+ cachedRouteTagSection = null;
273
+ return null;
274
+ }
275
+ }
276
+
277
+ export function loadConfigRouteTag(accountId?: string): string | undefined {
278
+ const section = loadRouteTagSection();
279
+ if (!section) return undefined;
280
+ if (accountId) {
281
+ const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
282
+ const tag = accounts?.[accountId]?.routeTag;
283
+ if (typeof tag === "number") return String(tag);
284
+ if (typeof tag === "string" && tag.trim()) return tag.trim();
285
+ }
286
+ if (typeof section.routeTag === "number") return String(section.routeTag);
287
+ return typeof section.routeTag === "string" && section.routeTag.trim()
288
+ ? section.routeTag.trim()
289
+ : undefined;
290
+ }
291
+
292
+ /**
293
+ * Read `botAgent` from `channels.openclaw-weixin.botAgent` in openclaw.json.
294
+ * Returns the raw configured string (caller is responsible for sanitization)
295
+ * or undefined when not set. Reuses the cached channel section.
296
+ */
297
+ export function loadConfigBotAgent(): string | undefined {
298
+ const section = loadRouteTagSection();
299
+ if (!section) return undefined;
300
+ const value = section.botAgent;
301
+ return typeof value === "string" && value.trim() ? value : undefined;
302
+ }
303
+
304
+ /**
305
+ * Bump `channels.openclaw-weixin.channelConfigUpdatedAt` in openclaw.json on each successful login
306
+ * so the gateway reloads config from disk (no empty `accounts: {}` placeholder).
307
+ */
308
+ export async function triggerWeixinChannelReload(): Promise<void> {
309
+ try {
310
+ const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
311
+ const cfg = loadConfig();
312
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
313
+ const existing = (channels["openclaw-weixin"] as Record<string, unknown> | undefined) ?? {};
314
+ const updated: OpenClawConfig = {
315
+ ...cfg,
316
+ channels: {
317
+ ...channels,
318
+ "openclaw-weixin": {
319
+ ...existing,
320
+ channelConfigUpdatedAt: new Date().toISOString(),
321
+ },
322
+ },
323
+ };
324
+ await writeConfigFile(updated);
325
+ logger.info("triggerWeixinChannelReload: wrote channel config to openclaw.json");
326
+ } catch (err) {
327
+ logger.warn(`triggerWeixinChannelReload: failed to update config: ${String(err)}`);
328
+ }
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Account resolution (merge config + stored credentials)
333
+ // ---------------------------------------------------------------------------
334
+
335
+ export type ResolvedWeixinAccount = {
336
+ accountId: string;
337
+ baseUrl: string;
338
+ cdnBaseUrl: string;
339
+ token?: string;
340
+ enabled: boolean;
341
+ /** true when a token has been obtained via QR login. */
342
+ configured: boolean;
343
+ name?: string;
344
+ };
345
+
346
+ type WeixinAccountConfig = {
347
+ name?: string;
348
+ enabled?: boolean;
349
+ baseUrl?: string;
350
+ cdnBaseUrl?: string;
351
+ /** Optional SKRouteTag source; read from openclaw.json when `accountId` is passed to `loadConfigRouteTag`. */
352
+ routeTag?: number | string;
353
+ };
354
+
355
+ type WeixinSectionConfig = WeixinAccountConfig & {
356
+ accounts?: Record<string, WeixinAccountConfig>;
357
+ /** Written on each successful login; see triggerWeixinChannelReload. */
358
+ channelConfigUpdatedAt?: string;
359
+ };
360
+
361
+ /** List accountIds from the index file (written at QR login). */
362
+ export function listWeixinAccountIds(_cfg: OpenClawConfig): string[] {
363
+ return listIndexedWeixinAccountIds();
364
+ }
365
+
366
+ /** Resolve a weixin account by ID, merging config and stored credentials. */
367
+ export function resolveWeixinAccount(
368
+ cfg: OpenClawConfig,
369
+ accountId?: string | null,
370
+ ): ResolvedWeixinAccount {
371
+ const raw = accountId?.trim();
372
+ if (!raw) {
373
+ throw new Error("weixin: accountId is required (no default account)");
374
+ }
375
+ const id = normalizeAccountId(raw);
376
+ const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
377
+ const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? {};
378
+
379
+ const accountData = loadWeixinAccount(id);
380
+ const token = accountData?.token?.trim() || undefined;
381
+ const stateBaseUrl = accountData?.baseUrl?.trim() || "";
382
+ const accountBaseUrl = accountCfg.baseUrl?.trim() || "";
383
+ const sectionBaseUrl = section?.baseUrl?.trim() || "";
384
+
385
+ return {
386
+ accountId: id,
387
+ baseUrl: accountBaseUrl || stateBaseUrl || sectionBaseUrl || DEFAULT_BASE_URL,
388
+ cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || section?.cdnBaseUrl?.trim() || CDN_BASE_URL,
389
+ token,
390
+ enabled: (accountCfg.enabled ?? section?.enabled) !== false,
391
+ configured: Boolean(token),
392
+ name: accountCfg.name?.trim() || section?.name?.trim() || undefined,
393
+ };
394
+ }