@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,76 @@
1
+ import path from "node:path";
2
+
3
+ const EXTENSION_TO_MIME: Record<string, string> = {
4
+ ".pdf": "application/pdf",
5
+ ".doc": "application/msword",
6
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
7
+ ".xls": "application/vnd.ms-excel",
8
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
9
+ ".ppt": "application/vnd.ms-powerpoint",
10
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
11
+ ".txt": "text/plain",
12
+ ".csv": "text/csv",
13
+ ".zip": "application/zip",
14
+ ".tar": "application/x-tar",
15
+ ".gz": "application/gzip",
16
+ ".mp3": "audio/mpeg",
17
+ ".ogg": "audio/ogg",
18
+ ".wav": "audio/wav",
19
+ ".mp4": "video/mp4",
20
+ ".mov": "video/quicktime",
21
+ ".webm": "video/webm",
22
+ ".mkv": "video/x-matroska",
23
+ ".avi": "video/x-msvideo",
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".bmp": "image/bmp",
30
+ };
31
+
32
+ const MIME_TO_EXTENSION: Record<string, string> = {
33
+ "image/jpeg": ".jpg",
34
+ "image/jpg": ".jpg",
35
+ "image/png": ".png",
36
+ "image/gif": ".gif",
37
+ "image/webp": ".webp",
38
+ "image/bmp": ".bmp",
39
+ "video/mp4": ".mp4",
40
+ "video/quicktime": ".mov",
41
+ "video/webm": ".webm",
42
+ "video/x-matroska": ".mkv",
43
+ "video/x-msvideo": ".avi",
44
+ "audio/mpeg": ".mp3",
45
+ "audio/ogg": ".ogg",
46
+ "audio/wav": ".wav",
47
+ "application/pdf": ".pdf",
48
+ "application/zip": ".zip",
49
+ "application/x-tar": ".tar",
50
+ "application/gzip": ".gz",
51
+ "text/plain": ".txt",
52
+ "text/csv": ".csv",
53
+ };
54
+
55
+ /** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */
56
+ export function getMimeFromFilename(filename: string): string {
57
+ const ext = path.extname(filename).toLowerCase();
58
+ return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
59
+ }
60
+
61
+ /** Get file extension from MIME type. Returns ".bin" for unknown types. */
62
+ export function getExtensionFromMime(mimeType: string): string {
63
+ const ct = mimeType.split(";")[0].trim().toLowerCase();
64
+ return MIME_TO_EXTENSION[ct] ?? ".bin";
65
+ }
66
+
67
+ /** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */
68
+ export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
69
+ if (contentType) {
70
+ const ext = getExtensionFromMime(contentType);
71
+ if (ext !== ".bin") return ext;
72
+ }
73
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
74
+ const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
75
+ return knownExts.has(ext) ? ext : ".bin";
76
+ }
@@ -0,0 +1,74 @@
1
+ import { logger } from "../util/logger.js";
2
+
3
+ /** Default sample rate for Weixin voice messages. */
4
+ const SILK_SAMPLE_RATE = 24_000;
5
+
6
+ /**
7
+ * Wrap raw pcm_s16le bytes in a WAV container.
8
+ * Mono channel, 16-bit signed little-endian.
9
+ */
10
+ function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
11
+ const pcmBytes = pcm.byteLength;
12
+ const totalSize = 44 + pcmBytes;
13
+ const buf = Buffer.allocUnsafe(totalSize);
14
+ let offset = 0;
15
+
16
+ buf.write("RIFF", offset);
17
+ offset += 4;
18
+ buf.writeUInt32LE(totalSize - 8, offset);
19
+ offset += 4;
20
+ buf.write("WAVE", offset);
21
+ offset += 4;
22
+
23
+ buf.write("fmt ", offset);
24
+ offset += 4;
25
+ buf.writeUInt32LE(16, offset);
26
+ offset += 4; // fmt chunk size
27
+ buf.writeUInt16LE(1, offset);
28
+ offset += 2; // PCM format
29
+ buf.writeUInt16LE(1, offset);
30
+ offset += 2; // mono
31
+ buf.writeUInt32LE(sampleRate, offset);
32
+ offset += 4;
33
+ buf.writeUInt32LE(sampleRate * 2, offset);
34
+ offset += 4; // byte rate (mono 16-bit)
35
+ buf.writeUInt16LE(2, offset);
36
+ offset += 2; // block align
37
+ buf.writeUInt16LE(16, offset);
38
+ offset += 2; // bits per sample
39
+
40
+ buf.write("data", offset);
41
+ offset += 4;
42
+ buf.writeUInt32LE(pcmBytes, offset);
43
+ offset += 4;
44
+
45
+ Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
46
+
47
+ return buf;
48
+ }
49
+
50
+ /**
51
+ * Try to transcode a SILK audio buffer to WAV using silk-wasm.
52
+ * silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.
53
+ *
54
+ * Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.
55
+ * Callers should fall back to passing the raw SILK file when null is returned.
56
+ */
57
+ export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
58
+ try {
59
+ const { decode } = await import("silk-wasm");
60
+
61
+ logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
62
+ const result = await decode(silkBuf, SILK_SAMPLE_RATE);
63
+ logger.debug(
64
+ `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
65
+ );
66
+
67
+ const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
68
+ logger.debug(`silkToWav: WAV size=${wav.length}`);
69
+ return wav;
70
+ } catch (err) {
71
+ logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.
3
+ *
4
+ * State file: `<stateDir>/openclaw-weixin/debug-mode.json`
5
+ * Format: `{ "accounts": { "<accountId>": true, ... } }`
6
+ *
7
+ * When enabled, processOneMessage appends a timing summary after each
8
+ * AI reply is delivered to the user.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import { resolveStateDir } from "../storage/state-dir.js";
14
+ import { logger } from "../util/logger.js";
15
+
16
+ interface DebugModeState {
17
+ accounts: Record<string, boolean>;
18
+ }
19
+
20
+ function resolveDebugModePath(): string {
21
+ return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json");
22
+ }
23
+
24
+ function loadState(): DebugModeState {
25
+ try {
26
+ const raw = fs.readFileSync(resolveDebugModePath(), "utf-8");
27
+ const parsed = JSON.parse(raw) as DebugModeState;
28
+ if (parsed && typeof parsed.accounts === "object") return parsed;
29
+ } catch {
30
+ // missing or corrupt — start fresh
31
+ }
32
+ return { accounts: {} };
33
+ }
34
+
35
+ function saveState(state: DebugModeState): void {
36
+ const filePath = resolveDebugModePath();
37
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
38
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
39
+ }
40
+
41
+ /** Toggle debug mode for a bot account. Returns the new state. */
42
+ export function toggleDebugMode(accountId: string): boolean {
43
+ const state = loadState();
44
+ const next = !state.accounts[accountId];
45
+ state.accounts[accountId] = next;
46
+ try {
47
+ saveState(state);
48
+ } catch (err) {
49
+ logger.error(`debug-mode: failed to persist state: ${String(err)}`);
50
+ }
51
+ return next;
52
+ }
53
+
54
+ /** Check whether debug mode is active for a bot account. */
55
+ export function isDebugMode(accountId: string): boolean {
56
+ return loadState().accounts[accountId] === true;
57
+ }
58
+
59
+ /**
60
+ * Reset internal state — only for tests.
61
+ * @internal
62
+ */
63
+ export function _resetForTest(): void {
64
+ try {
65
+ fs.unlinkSync(resolveDebugModePath());
66
+ } catch {
67
+ // ignore if not present
68
+ }
69
+ }
@@ -0,0 +1,31 @@
1
+ import { logger } from "../util/logger.js";
2
+ import { sendMessageWeixin } from "./send.js";
3
+
4
+ /**
5
+ * Send a plain-text error notice back to the user.
6
+ * Fire-and-forget: errors are logged but never thrown, so callers stay unaffected.
7
+ * No-op when contextToken is absent (we have no conversation reference to reply into).
8
+ */
9
+ export async function sendWeixinErrorNotice(params: {
10
+ to: string;
11
+ contextToken: string | undefined;
12
+ message: string;
13
+ baseUrl: string;
14
+ token?: string;
15
+ errLog: (m: string) => void;
16
+ }): Promise<void> {
17
+ if (!params.contextToken) {
18
+ logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
19
+ return;
20
+ }
21
+ try {
22
+ await sendMessageWeixin({ to: params.to, text: params.message, opts: {
23
+ baseUrl: params.baseUrl,
24
+ token: params.token,
25
+ contextToken: params.contextToken,
26
+ }});
27
+ logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
28
+ } catch (err) {
29
+ params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
30
+ }
31
+ }
@@ -0,0 +1,171 @@
1
+ import { logger } from "../util/logger.js";
2
+ import { generateId } from "../util/random.js";
3
+ import type { WeixinMessage, MessageItem } from "../api/types.js";
4
+ import { MessageItemType } from "../api/types.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Context token store (in-process cache: accountId+userId → contextToken)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * contextToken is issued per-message by the Weixin getupdates API and must
12
+ * be echoed verbatim in every outbound send. It is not persisted: the monitor
13
+ * loop populates this map on each inbound message, and the outbound adapter
14
+ * reads it back when the agent sends a reply.
15
+ */
16
+ const contextTokenStore = new Map<string, string>();
17
+
18
+ function contextTokenKey(accountId: string, userId: string): string {
19
+ return `${accountId}:${userId}`;
20
+ }
21
+
22
+ /** Store a context token for a given account+user pair. */
23
+ export function setContextToken(accountId: string, userId: string, token: string): void {
24
+ const k = contextTokenKey(accountId, userId);
25
+ logger.debug(`setContextToken: key=${k}`);
26
+ contextTokenStore.set(k, token);
27
+ }
28
+
29
+ /** Retrieve the cached context token for a given account+user pair. */
30
+ export function getContextToken(accountId: string, userId: string): string | undefined {
31
+ const k = contextTokenKey(accountId, userId);
32
+ const val = contextTokenStore.get(k);
33
+ logger.debug(
34
+ `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
35
+ );
36
+ return val;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Message ID generation
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function generateMessageSid(): string {
44
+ return generateId("openclaw-weixin");
45
+ }
46
+
47
+ /** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
48
+ export type WeixinMsgContext = {
49
+ Body: string;
50
+ From: string;
51
+ To: string;
52
+ AccountId: string;
53
+ OriginatingChannel: "openclaw-weixin";
54
+ OriginatingTo: string;
55
+ MessageSid: string;
56
+ Timestamp?: number;
57
+ Provider: "openclaw-weixin";
58
+ ChatType: "direct";
59
+ /** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
60
+ SessionKey?: string;
61
+ context_token?: string;
62
+ MediaUrl?: string;
63
+ MediaPath?: string;
64
+ MediaType?: string;
65
+ /** Raw message body for framework command authorization. */
66
+ CommandBody?: string;
67
+ /** Whether the sender is authorized to execute slash commands. */
68
+ CommandAuthorized?: boolean;
69
+ };
70
+
71
+ /** Returns true if the message item is a media type (image, video, file, or voice). */
72
+ export function isMediaItem(item: MessageItem): boolean {
73
+ return (
74
+ item.type === MessageItemType.IMAGE ||
75
+ item.type === MessageItemType.VIDEO ||
76
+ item.type === MessageItemType.FILE ||
77
+ item.type === MessageItemType.VOICE
78
+ );
79
+ }
80
+
81
+ function bodyFromItemList(itemList?: MessageItem[]): string {
82
+ if (!itemList?.length) return "";
83
+ for (const item of itemList) {
84
+ if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
85
+ const text = String(item.text_item.text);
86
+ const ref = item.ref_msg;
87
+ if (!ref) return text;
88
+ // Quoted media is passed as MediaPath; only include the current text as body.
89
+ if (ref.message_item && isMediaItem(ref.message_item)) return text;
90
+ // Build quoted context from both title and message_item content.
91
+ const parts: string[] = [];
92
+ if (ref.title) parts.push(ref.title);
93
+ if (ref.message_item) {
94
+ const refBody = bodyFromItemList([ref.message_item]);
95
+ if (refBody) parts.push(refBody);
96
+ }
97
+ if (!parts.length) return text;
98
+ return `[引用: ${parts.join(" | ")}]\n${text}`;
99
+ }
100
+ // 语音转文字:如果语音消息有 text 字段,直接使用文字内容
101
+ if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
102
+ return item.voice_item.text;
103
+ }
104
+ }
105
+ return "";
106
+ }
107
+
108
+ export type WeixinInboundMediaOpts = {
109
+ /** Local path to decrypted image file. */
110
+ decryptedPicPath?: string;
111
+ /** Local path to transcoded/raw voice file (.wav or .silk). */
112
+ decryptedVoicePath?: string;
113
+ /** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
114
+ voiceMediaType?: string;
115
+ /** Local path to decrypted file attachment. */
116
+ decryptedFilePath?: string;
117
+ /** MIME type for the file attachment (guessed from file_name). */
118
+ fileMediaType?: string;
119
+ /** Local path to decrypted video file. */
120
+ decryptedVideoPath?: string;
121
+ };
122
+
123
+ /**
124
+ * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
125
+ * Media: only pass MediaPath (local file, after CDN download + decrypt).
126
+ * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.
127
+ * Priority when multiple media types present: image > video > file > voice.
128
+ */
129
+ export function weixinMessageToMsgContext(
130
+ msg: WeixinMessage,
131
+ accountId: string,
132
+ opts?: WeixinInboundMediaOpts,
133
+ ): WeixinMsgContext {
134
+ const from_user_id = msg.from_user_id ?? "";
135
+ const ctx: WeixinMsgContext = {
136
+ Body: bodyFromItemList(msg.item_list),
137
+ From: from_user_id,
138
+ To: from_user_id,
139
+ AccountId: accountId,
140
+ OriginatingChannel: "openclaw-weixin",
141
+ OriginatingTo: from_user_id,
142
+ MessageSid: generateMessageSid(),
143
+ Timestamp: msg.create_time_ms,
144
+ Provider: "openclaw-weixin",
145
+ ChatType: "direct",
146
+ };
147
+ if (msg.context_token) {
148
+ ctx.context_token = msg.context_token;
149
+ }
150
+
151
+ if (opts?.decryptedPicPath) {
152
+ ctx.MediaPath = opts.decryptedPicPath;
153
+ ctx.MediaType = "image/*";
154
+ } else if (opts?.decryptedVideoPath) {
155
+ ctx.MediaPath = opts.decryptedVideoPath;
156
+ ctx.MediaType = "video/mp4";
157
+ } else if (opts?.decryptedFilePath) {
158
+ ctx.MediaPath = opts.decryptedFilePath;
159
+ ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
160
+ } else if (opts?.decryptedVoicePath) {
161
+ ctx.MediaPath = opts.decryptedVoicePath;
162
+ ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
163
+ }
164
+
165
+ return ctx;
166
+ }
167
+
168
+ /** Extract the context_token from an inbound WeixinMsgContext. */
169
+ export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
170
+ return ctx.context_token;
171
+ }