@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,20 @@
1
+ /**
2
+ * Unified CDN URL construction for Weixin CDN upload/download.
3
+ */
4
+
5
+ /** 设为 true 时,当服务端未返回 full_url 字段,回退到客户端拼接 URL;false 则直接报错。 */
6
+ export const ENABLE_CDN_URL_FALLBACK = true;
7
+
8
+ /** Build a CDN download URL from encrypt_query_param. */
9
+ export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
10
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
11
+ }
12
+
13
+ /** Build a CDN upload URL from upload_param and filekey. */
14
+ export function buildCdnUploadUrl(params: {
15
+ cdnBaseUrl: string;
16
+ uploadParam: string;
17
+ filekey: string;
18
+ }): string {
19
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
20
+ }
@@ -0,0 +1,101 @@
1
+ import { decryptAesEcb } from "./aes-ecb.js";
2
+ import { buildCdnDownloadUrl, ENABLE_CDN_URL_FALLBACK } from "./cdn-url.js";
3
+ import { logger } from "../util/logger.js";
4
+
5
+ /**
6
+ * Download raw bytes from the CDN (no decryption).
7
+ */
8
+ async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
9
+ let res: Response;
10
+ try {
11
+ res = await fetch(url);
12
+ } catch (err) {
13
+ const cause =
14
+ (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)";
15
+ logger.error(
16
+ `${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,
17
+ );
18
+ throw err;
19
+ }
20
+ logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
21
+ if (!res.ok) {
22
+ const body = await res.text().catch(() => "(unreadable)");
23
+ const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
24
+ logger.error(msg);
25
+ throw new Error(msg);
26
+ }
27
+ return Buffer.from(await res.arrayBuffer());
28
+ }
29
+
30
+ /**
31
+ * Parse CDNMedia.aes_key into a raw 16-byte AES key.
32
+ *
33
+ * Two encodings are seen in the wild:
34
+ * - base64(raw 16 bytes) → images (aes_key from media field)
35
+ * - base64(hex string of 16 bytes) → file / voice / video
36
+ *
37
+ * In the second case, base64-decoding yields 32 ASCII hex chars which must
38
+ * then be parsed as hex to recover the actual 16-byte key.
39
+ */
40
+ function parseAesKey(aesKeyBase64: string, label: string): Buffer {
41
+ const decoded = Buffer.from(aesKeyBase64, "base64");
42
+ if (decoded.length === 16) {
43
+ return decoded;
44
+ }
45
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
46
+ // hex-encoded key: base64 → hex string → raw bytes
47
+ return Buffer.from(decoded.toString("ascii"), "hex");
48
+ }
49
+ const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
50
+ logger.error(msg);
51
+ throw new Error(msg);
52
+ }
53
+
54
+ /**
55
+ * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
56
+ * aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
57
+ */
58
+ export async function downloadAndDecryptBuffer(
59
+ encryptedQueryParam: string,
60
+ aesKeyBase64: string,
61
+ cdnBaseUrl: string,
62
+ label: string,
63
+ fullUrl?: string,
64
+ ): Promise<Buffer> {
65
+ const key = parseAesKey(aesKeyBase64, label);
66
+ let url: string;
67
+ if (fullUrl) {
68
+ url = fullUrl;
69
+ } else if (ENABLE_CDN_URL_FALLBACK) {
70
+ url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
71
+ } else {
72
+ throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
73
+ }
74
+ logger.debug(`${label}: fetching url=${url}`);
75
+ const encrypted = await fetchCdnBytes(url, label);
76
+ logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
77
+ const decrypted = decryptAesEcb(encrypted, key);
78
+ logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
79
+ return decrypted;
80
+ }
81
+
82
+ /**
83
+ * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
84
+ */
85
+ export async function downloadPlainCdnBuffer(
86
+ encryptedQueryParam: string,
87
+ cdnBaseUrl: string,
88
+ label: string,
89
+ fullUrl?: string,
90
+ ): Promise<Buffer> {
91
+ let url: string;
92
+ if (fullUrl) {
93
+ url = fullUrl;
94
+ } else if (ENABLE_CDN_URL_FALLBACK) {
95
+ url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
96
+ } else {
97
+ throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
98
+ }
99
+ logger.debug(`${label}: fetching url=${url}`);
100
+ return fetchCdnBytes(url, label);
101
+ }
@@ -0,0 +1,168 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { getUploadUrl } from "../api/api.js";
6
+ import type { WeixinApiOptions } from "../api/api.js";
7
+ import { aesEcbPaddedSize } from "./aes-ecb.js";
8
+ import { uploadBufferToCdn } from "./cdn-upload.js";
9
+ import { logger } from "../util/logger.js";
10
+ import { redactUrl } from "../util/redact.js";
11
+ import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
12
+ import { tempFileName } from "../util/random.js";
13
+ import { UploadMediaType } from "../api/types.js";
14
+
15
+ export type UploadedFileInfo = {
16
+ filekey: string;
17
+ /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
18
+ downloadEncryptedQueryParam: string;
19
+ /** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
20
+ aeskey: string;
21
+ /** Plaintext file size in bytes */
22
+ fileSize: number;
23
+ /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
24
+ fileSizeCiphertext: number;
25
+ };
26
+
27
+ /**
28
+ * Download a remote media URL (image, video, file) to a local temp file in destDir.
29
+ * Returns the local file path; extension is inferred from Content-Type / URL.
30
+ */
31
+ export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
32
+ logger.debug(`downloadRemoteImageToTemp: fetching url=${redactUrl(url)}`);
33
+ let res: Response;
34
+ try {
35
+ res = await fetch(url);
36
+ } catch (err) {
37
+ const cause =
38
+ (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
39
+ logger.error(
40
+ `downloadRemoteImageToTemp: fetch network error url=${redactUrl(url)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
41
+ );
42
+ throw err;
43
+ }
44
+ if (!res.ok) {
45
+ const msg = `remote media download failed: ${res.status} ${res.statusText} url=${redactUrl(url)}`;
46
+ logger.error(`downloadRemoteImageToTemp: ${msg}`);
47
+ throw new Error(msg);
48
+ }
49
+ const buf = Buffer.from(await res.arrayBuffer());
50
+ logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
51
+ await fs.mkdir(destDir, { recursive: true });
52
+ const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
53
+ const name = tempFileName("weixin-remote", ext);
54
+ const filePath = path.join(destDir, name);
55
+ await fs.writeFile(filePath, buf);
56
+ logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
57
+ return filePath;
58
+ }
59
+
60
+ /**
61
+ * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
62
+ */
63
+ async function uploadMediaToCdn(params: {
64
+ filePath: string;
65
+ toUserId: string;
66
+ opts: WeixinApiOptions;
67
+ cdnBaseUrl: string;
68
+ mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
69
+ label: string;
70
+ }): Promise<UploadedFileInfo> {
71
+ const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
72
+
73
+ const plaintext = await fs.readFile(filePath);
74
+ const rawsize = plaintext.length;
75
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
76
+ const filesize = aesEcbPaddedSize(rawsize);
77
+ const filekey = crypto.randomBytes(16).toString("hex");
78
+ const aeskey = crypto.randomBytes(16);
79
+
80
+ logger.debug(
81
+ `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
82
+ );
83
+
84
+ const uploadUrlResp = await getUploadUrl({
85
+ ...opts,
86
+ filekey,
87
+ media_type: mediaType,
88
+ to_user_id: toUserId,
89
+ rawsize,
90
+ rawfilemd5,
91
+ filesize,
92
+ no_need_thumb: true,
93
+ aeskey: aeskey.toString("hex"),
94
+ });
95
+
96
+ const uploadFullUrl = uploadUrlResp.upload_full_url?.trim();
97
+ const uploadParam = uploadUrlResp.upload_param;
98
+ if (!uploadFullUrl && !uploadParam) {
99
+ logger.error(
100
+ `${label}: getUploadUrl returned no upload URL (need upload_full_url or upload_param), resp=${JSON.stringify(uploadUrlResp)}`,
101
+ );
102
+ throw new Error(`${label}: getUploadUrl returned no upload URL`);
103
+ }
104
+
105
+ const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
106
+ buf: plaintext,
107
+ uploadFullUrl: uploadFullUrl || undefined,
108
+ uploadParam: uploadParam ?? undefined,
109
+ filekey,
110
+ cdnBaseUrl,
111
+ aeskey,
112
+ label: `${label}[orig filekey=${filekey}]`,
113
+ });
114
+
115
+ return {
116
+ filekey,
117
+ downloadEncryptedQueryParam,
118
+ aeskey: aeskey.toString("hex"),
119
+ fileSize: rawsize,
120
+ fileSizeCiphertext: filesize,
121
+ };
122
+ }
123
+
124
+ /** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
125
+ export async function uploadFileToWeixin(params: {
126
+ filePath: string;
127
+ toUserId: string;
128
+ opts: WeixinApiOptions;
129
+ cdnBaseUrl: string;
130
+ }): Promise<UploadedFileInfo> {
131
+ return uploadMediaToCdn({
132
+ ...params,
133
+ mediaType: UploadMediaType.IMAGE,
134
+ label: "uploadFileToWeixin",
135
+ });
136
+ }
137
+
138
+ /** Upload a local video file to the Weixin CDN. */
139
+ export async function uploadVideoToWeixin(params: {
140
+ filePath: string;
141
+ toUserId: string;
142
+ opts: WeixinApiOptions;
143
+ cdnBaseUrl: string;
144
+ }): Promise<UploadedFileInfo> {
145
+ return uploadMediaToCdn({
146
+ ...params,
147
+ mediaType: UploadMediaType.VIDEO,
148
+ label: "uploadVideoToWeixin",
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Upload a local file attachment (non-image, non-video) to the Weixin CDN.
154
+ * Uses media_type=FILE; no thumbnail required.
155
+ */
156
+ export async function uploadFileAttachmentToWeixin(params: {
157
+ filePath: string;
158
+ fileName: string;
159
+ toUserId: string;
160
+ opts: WeixinApiOptions;
161
+ cdnBaseUrl: string;
162
+ }): Promise<UploadedFileInfo> {
163
+ return uploadMediaToCdn({
164
+ ...params,
165
+ mediaType: UploadMediaType.FILE,
166
+ label: "uploadFileAttachmentToWeixin",
167
+ });
168
+ }