@tencent-weixin/openclaw-weixin 1.0.0

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 (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/CHANGELOG.zh_CN.md +3 -0
  3. package/LICENSE +21 -0
  4. package/README.md +271 -0
  5. package/README.zh_CN.md +269 -0
  6. package/index.ts +27 -0
  7. package/openclaw.plugin.json +9 -0
  8. package/package.json +55 -0
  9. package/src/api/api.ts +240 -0
  10. package/src/api/config-cache.ts +79 -0
  11. package/src/api/session-guard.ts +58 -0
  12. package/src/api/types.ts +222 -0
  13. package/src/auth/accounts.ts +321 -0
  14. package/src/auth/login-qr.ts +331 -0
  15. package/src/auth/pairing.ts +120 -0
  16. package/src/cdn/aes-ecb.ts +21 -0
  17. package/src/cdn/cdn-upload.ts +77 -0
  18. package/src/cdn/cdn-url.ts +17 -0
  19. package/src/cdn/pic-decrypt.ts +85 -0
  20. package/src/cdn/upload.ts +155 -0
  21. package/src/channel.ts +380 -0
  22. package/src/config/config-schema.ts +22 -0
  23. package/src/log-upload.ts +126 -0
  24. package/src/media/media-download.ts +141 -0
  25. package/src/media/mime.ts +76 -0
  26. package/src/media/silk-transcode.ts +74 -0
  27. package/src/messaging/debug-mode.ts +69 -0
  28. package/src/messaging/error-notice.ts +31 -0
  29. package/src/messaging/inbound.ts +171 -0
  30. package/src/messaging/process-message.ts +381 -0
  31. package/src/messaging/send-media.ts +72 -0
  32. package/src/messaging/send.ts +267 -0
  33. package/src/messaging/slash-commands.ts +110 -0
  34. package/src/monitor/monitor.ts +221 -0
  35. package/src/runtime.ts +70 -0
  36. package/src/storage/state-dir.ts +11 -0
  37. package/src/storage/sync-buf.ts +81 -0
  38. package/src/util/logger.ts +143 -0
  39. package/src/util/random.ts +17 -0
  40. package/src/util/redact.ts +46 -0
  41. package/src/vendor.d.ts +25 -0
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { withFileLock } from "openclaw/plugin-sdk";
5
+
6
+ import { resolveStateDir } from "../storage/state-dir.js";
7
+ import { logger } from "../util/logger.js";
8
+
9
+ /**
10
+ * Resolve the framework credentials directory (mirrors core resolveOAuthDir).
11
+ * Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials
12
+ */
13
+ function resolveCredentialsDir(): string {
14
+ const override = process.env.OPENCLAW_OAUTH_DIR?.trim();
15
+ if (override) return override;
16
+ return path.join(resolveStateDir(), "credentials");
17
+ }
18
+
19
+ /**
20
+ * Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey).
21
+ */
22
+ function safeKey(raw: string): string {
23
+ const trimmed = raw.trim().toLowerCase();
24
+ if (!trimmed) throw new Error("invalid key for allowFrom path");
25
+ const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
26
+ if (!safe || safe === "_") throw new Error("invalid key for allowFrom path");
27
+ return safe;
28
+ }
29
+
30
+ /**
31
+ * Resolve the framework allowFrom file path for a given account.
32
+ * Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core.
33
+ * Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json`
34
+ */
35
+ export function resolveFrameworkAllowFromPath(accountId: string): string {
36
+ const base = safeKey("openclaw-weixin");
37
+ const safeAccount = safeKey(accountId);
38
+ return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
39
+ }
40
+
41
+ type AllowFromFileContent = {
42
+ version: number;
43
+ allowFrom: string[];
44
+ };
45
+
46
+ /**
47
+ * Read the framework allowFrom list for an account (user IDs authorized via pairing).
48
+ * Returns an empty array when the file is missing or unreadable.
49
+ */
50
+ export function readFrameworkAllowFromList(accountId: string): string[] {
51
+ const filePath = resolveFrameworkAllowFromPath(accountId);
52
+ try {
53
+ if (!fs.existsSync(filePath)) return [];
54
+ const raw = fs.readFileSync(filePath, "utf-8");
55
+ const parsed = JSON.parse(raw) as AllowFromFileContent;
56
+ if (Array.isArray(parsed.allowFrom)) {
57
+ return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== "");
58
+ }
59
+ } catch {
60
+ // best-effort
61
+ }
62
+ return [];
63
+ }
64
+
65
+ /** File lock options matching the framework's pairing store lock settings. */
66
+ const LOCK_OPTIONS = {
67
+ retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
68
+ stale: 10_000,
69
+ };
70
+
71
+ /**
72
+ * Register a user ID in the framework's channel allowFrom store.
73
+ * This writes directly to the same JSON file that `readChannelAllowFromStore` reads,
74
+ * making the user visible to the framework authorization pipeline.
75
+ *
76
+ * Uses file locking to avoid races with concurrent readers/writers.
77
+ */
78
+ export async function registerUserInFrameworkStore(params: {
79
+ accountId: string;
80
+ userId: string;
81
+ }): Promise<{ changed: boolean }> {
82
+ const { accountId, userId } = params;
83
+ const trimmedUserId = userId.trim();
84
+ if (!trimmedUserId) return { changed: false };
85
+
86
+ const filePath = resolveFrameworkAllowFromPath(accountId);
87
+
88
+ const dir = path.dirname(filePath);
89
+ fs.mkdirSync(dir, { recursive: true });
90
+
91
+ // Ensure the file exists before locking
92
+ if (!fs.existsSync(filePath)) {
93
+ const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
94
+ fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
95
+ }
96
+
97
+ return await withFileLock(filePath, LOCK_OPTIONS, async () => {
98
+ let content: AllowFromFileContent = { version: 1, allowFrom: [] };
99
+ try {
100
+ const raw = fs.readFileSync(filePath, "utf-8");
101
+ const parsed = JSON.parse(raw) as AllowFromFileContent;
102
+ if (Array.isArray(parsed.allowFrom)) {
103
+ content = parsed;
104
+ }
105
+ } catch {
106
+ // If read/parse fails, start fresh
107
+ }
108
+
109
+ if (content.allowFrom.includes(trimmedUserId)) {
110
+ return { changed: false };
111
+ }
112
+
113
+ content.allowFrom.push(trimmedUserId);
114
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
115
+ logger.info(
116
+ `registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,
117
+ );
118
+ return { changed: true };
119
+ });
120
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared AES-128-ECB crypto utilities for CDN upload and download.
3
+ */
4
+ import { createCipheriv, createDecipheriv } from "node:crypto";
5
+
6
+ /** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
7
+ export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
8
+ const cipher = createCipheriv("aes-128-ecb", key, null);
9
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
10
+ }
11
+
12
+ /** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
13
+ export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
14
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
15
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
16
+ }
17
+
18
+ /** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
19
+ export function aesEcbPaddedSize(plaintextSize: number): number {
20
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
21
+ }
@@ -0,0 +1,77 @@
1
+ import { encryptAesEcb } from "./aes-ecb.js";
2
+ import { buildCdnUploadUrl } from "./cdn-url.js";
3
+ import { logger } from "../util/logger.js";
4
+ import { redactUrl } from "../util/redact.js";
5
+
6
+ /** Maximum retry attempts for CDN upload. */
7
+ const UPLOAD_MAX_RETRIES = 3;
8
+
9
+ /**
10
+ * Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
11
+ * Returns the download encrypted_query_param from the CDN response.
12
+ * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
13
+ */
14
+ export async function uploadBufferToCdn(params: {
15
+ buf: Buffer;
16
+ uploadParam: string;
17
+ filekey: string;
18
+ cdnBaseUrl: string;
19
+ label: string;
20
+ aeskey: Buffer;
21
+ }): Promise<{ downloadParam: string }> {
22
+ const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
23
+ const ciphertext = encryptAesEcb(buf, aeskey);
24
+ const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
25
+ logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
26
+
27
+ let downloadParam: string | undefined;
28
+ let lastError: unknown;
29
+
30
+ for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
31
+ try {
32
+ const res = await fetch(cdnUrl, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/octet-stream" },
35
+ body: new Uint8Array(ciphertext),
36
+ });
37
+ if (res.status >= 400 && res.status < 500) {
38
+ const errMsg = res.headers.get("x-error-message") ?? (await res.text());
39
+ logger.error(
40
+ `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
41
+ );
42
+ throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
43
+ }
44
+ if (res.status !== 200) {
45
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
46
+ logger.error(
47
+ `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
48
+ );
49
+ throw new Error(`CDN upload server error: ${errMsg}`);
50
+ }
51
+ downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
52
+ if (!downloadParam) {
53
+ logger.error(
54
+ `${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,
55
+ );
56
+ throw new Error("CDN upload response missing x-encrypted-param header");
57
+ }
58
+ logger.debug(`${label}: CDN upload success attempt=${attempt}`);
59
+ break;
60
+ } catch (err) {
61
+ lastError = err;
62
+ if (err instanceof Error && err.message.includes("client error")) throw err;
63
+ if (attempt < UPLOAD_MAX_RETRIES) {
64
+ logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
65
+ } else {
66
+ logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ if (!downloadParam) {
72
+ throw lastError instanceof Error
73
+ ? lastError
74
+ : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
75
+ }
76
+ return { downloadParam };
77
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Unified CDN URL construction for Weixin CDN upload/download.
3
+ */
4
+
5
+ /** Build a CDN download URL from encrypt_query_param. */
6
+ export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
7
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
8
+ }
9
+
10
+ /** Build a CDN upload URL from upload_param and filekey. */
11
+ export function buildCdnUploadUrl(params: {
12
+ cdnBaseUrl: string;
13
+ uploadParam: string;
14
+ filekey: string;
15
+ }): string {
16
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
17
+ }
@@ -0,0 +1,85 @@
1
+ import { decryptAesEcb } from "./aes-ecb.js";
2
+ import { buildCdnDownloadUrl } 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
+ ): Promise<Buffer> {
64
+ const key = parseAesKey(aesKeyBase64, label);
65
+ const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
66
+ logger.debug(`${label}: fetching url=${url}`);
67
+ const encrypted = await fetchCdnBytes(url, label);
68
+ logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
69
+ const decrypted = decryptAesEcb(encrypted, key);
70
+ logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
71
+ return decrypted;
72
+ }
73
+
74
+ /**
75
+ * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
76
+ */
77
+ export async function downloadPlainCdnBuffer(
78
+ encryptedQueryParam: string,
79
+ cdnBaseUrl: string,
80
+ label: string,
81
+ ): Promise<Buffer> {
82
+ const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
83
+ logger.debug(`${label}: fetching url=${url}`);
84
+ return fetchCdnBytes(url, label);
85
+ }
@@ -0,0 +1,155 @@
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 { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
11
+ import { tempFileName } from "../util/random.js";
12
+ import { UploadMediaType } from "../api/types.js";
13
+
14
+ export type UploadedFileInfo = {
15
+ filekey: string;
16
+ /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
17
+ downloadEncryptedQueryParam: string;
18
+ /** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
19
+ aeskey: string;
20
+ /** Plaintext file size in bytes */
21
+ fileSize: number;
22
+ /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
23
+ fileSizeCiphertext: number;
24
+ };
25
+
26
+ /**
27
+ * Download a remote media URL (image, video, file) to a local temp file in destDir.
28
+ * Returns the local file path; extension is inferred from Content-Type / URL.
29
+ */
30
+ export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
31
+ logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
32
+ const res = await fetch(url);
33
+ if (!res.ok) {
34
+ const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
35
+ logger.error(`downloadRemoteImageToTemp: ${msg}`);
36
+ throw new Error(msg);
37
+ }
38
+ const buf = Buffer.from(await res.arrayBuffer());
39
+ logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
40
+ await fs.mkdir(destDir, { recursive: true });
41
+ const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
42
+ const name = tempFileName("weixin-remote", ext);
43
+ const filePath = path.join(destDir, name);
44
+ await fs.writeFile(filePath, buf);
45
+ logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
46
+ return filePath;
47
+ }
48
+
49
+ /**
50
+ * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
51
+ */
52
+ async function uploadMediaToCdn(params: {
53
+ filePath: string;
54
+ toUserId: string;
55
+ opts: WeixinApiOptions;
56
+ cdnBaseUrl: string;
57
+ mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
58
+ label: string;
59
+ }): Promise<UploadedFileInfo> {
60
+ const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
61
+
62
+ const plaintext = await fs.readFile(filePath);
63
+ const rawsize = plaintext.length;
64
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
65
+ const filesize = aesEcbPaddedSize(rawsize);
66
+ const filekey = crypto.randomBytes(16).toString("hex");
67
+ const aeskey = crypto.randomBytes(16);
68
+
69
+ logger.debug(
70
+ `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
71
+ );
72
+
73
+ const uploadUrlResp = await getUploadUrl({
74
+ ...opts,
75
+ filekey,
76
+ media_type: mediaType,
77
+ to_user_id: toUserId,
78
+ rawsize,
79
+ rawfilemd5,
80
+ filesize,
81
+ no_need_thumb: true,
82
+ aeskey: aeskey.toString("hex"),
83
+ });
84
+
85
+ const uploadParam = uploadUrlResp.upload_param;
86
+ if (!uploadParam) {
87
+ logger.error(
88
+ `${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
89
+ );
90
+ throw new Error(`${label}: getUploadUrl returned no upload_param`);
91
+ }
92
+
93
+ const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
94
+ buf: plaintext,
95
+ uploadParam,
96
+ filekey,
97
+ cdnBaseUrl,
98
+ aeskey,
99
+ label: `${label}[orig filekey=${filekey}]`,
100
+ });
101
+
102
+ return {
103
+ filekey,
104
+ downloadEncryptedQueryParam,
105
+ aeskey: aeskey.toString("hex"),
106
+ fileSize: rawsize,
107
+ fileSizeCiphertext: filesize,
108
+ };
109
+ }
110
+
111
+ /** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
112
+ export async function uploadFileToWeixin(params: {
113
+ filePath: string;
114
+ toUserId: string;
115
+ opts: WeixinApiOptions;
116
+ cdnBaseUrl: string;
117
+ }): Promise<UploadedFileInfo> {
118
+ return uploadMediaToCdn({
119
+ ...params,
120
+ mediaType: UploadMediaType.IMAGE,
121
+ label: "uploadFileToWeixin",
122
+ });
123
+ }
124
+
125
+ /** Upload a local video file to the Weixin CDN. */
126
+ export async function uploadVideoToWeixin(params: {
127
+ filePath: string;
128
+ toUserId: string;
129
+ opts: WeixinApiOptions;
130
+ cdnBaseUrl: string;
131
+ }): Promise<UploadedFileInfo> {
132
+ return uploadMediaToCdn({
133
+ ...params,
134
+ mediaType: UploadMediaType.VIDEO,
135
+ label: "uploadVideoToWeixin",
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Upload a local file attachment (non-image, non-video) to the Weixin CDN.
141
+ * Uses media_type=FILE; no thumbnail required.
142
+ */
143
+ export async function uploadFileAttachmentToWeixin(params: {
144
+ filePath: string;
145
+ fileName: string;
146
+ toUserId: string;
147
+ opts: WeixinApiOptions;
148
+ cdnBaseUrl: string;
149
+ }): Promise<UploadedFileInfo> {
150
+ return uploadMediaToCdn({
151
+ ...params,
152
+ mediaType: UploadMediaType.FILE,
153
+ label: "uploadFileAttachmentToWeixin",
154
+ });
155
+ }