@unlinearity/cli-wechat-bridge 1.1.5 → 1.2.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 (89) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +434 -427
  3. package/bin/_run-entry.mjs +47 -9
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +10 -0
  12. package/bin/wecom-claude.mjs +10 -0
  13. package/bin/wecom-codex.mjs +10 -0
  14. package/bin/wecom-daemon.mjs +10 -0
  15. package/bin/wecom-opencode.mjs +10 -0
  16. package/bin/wecom-pi.mjs +10 -0
  17. package/bin/wecom-setup.mjs +10 -0
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +163 -5
  20. package/dist/bridge/bridge-adapters.codex.js +331 -29
  21. package/dist/bridge/bridge-adapters.core.js +27 -2
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +46 -1
  24. package/dist/bridge/bridge-adapters.shared.js +8 -1
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +8 -65
  27. package/dist/bridge/bridge-process-reaper.js +41 -2
  28. package/dist/bridge/bridge-state.js +17 -4
  29. package/dist/bridge/bridge-utils.js +10 -127
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +520 -274
  34. package/dist/bridge/wechat-forwarding.js +8 -58
  35. package/dist/bridge/wechat-outbound-queue.js +8 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +56 -0
  37. package/dist/channels/wechat/channel-message.js +24 -0
  38. package/dist/channels/wechat/index.js +11 -0
  39. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  40. package/dist/channels/wechat/wechat-driver.js +191 -0
  41. package/dist/channels/wechat/wechat-forwarding.js +97 -0
  42. package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
  43. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  44. package/dist/channels/wecom/setup-cli.js +12 -0
  45. package/dist/channels/wecom/setup.js +182 -0
  46. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  47. package/dist/channels/wecom/wecom-config.js +72 -0
  48. package/dist/channels/wecom/wecom-driver.js +106 -0
  49. package/dist/channels/wecom/wecom-media.js +147 -0
  50. package/dist/channels/wecom/wecom-message.js +149 -0
  51. package/dist/channels/wecom/wecom-transport.js +314 -0
  52. package/dist/commands/check-update.js +6 -0
  53. package/dist/companion/codex-remote-client.js +30 -3
  54. package/dist/companion/codex-visible-client-link.js +107 -0
  55. package/dist/companion/local-companion-link.js +6 -0
  56. package/dist/companion/local-companion-start.js +62 -11
  57. package/dist/companion/local-companion.js +25 -1
  58. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  59. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  60. package/dist/core/bridge-control.js +42 -0
  61. package/dist/core/bridge-defer.js +25 -0
  62. package/dist/core/bridge-event-forwarder.js +50 -0
  63. package/dist/core/bridge-message-router.js +32 -0
  64. package/dist/core/channel-driver.js +1 -0
  65. package/dist/core/channel-types.js +3 -0
  66. package/dist/core/conversation-routing.js +58 -0
  67. package/dist/core/direct-run.js +23 -0
  68. package/dist/core/inbound-message-claims.js +67 -0
  69. package/dist/core/outbound-target.js +32 -0
  70. package/dist/core/text-utils.js +69 -0
  71. package/dist/core/turn-coordinator.js +85 -0
  72. package/dist/daemon/daemon-link.js +9 -0
  73. package/dist/daemon/emoji-bindings.js +6 -0
  74. package/dist/daemon/wechat-daemon.js +827 -209
  75. package/dist/i18n/index.js +6 -0
  76. package/dist/i18n/messages-en.js +11 -3
  77. package/dist/i18n/messages-zh.js +11 -3
  78. package/dist/runtime/create-runtime-host.js +6 -0
  79. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  80. package/dist/runtime/runtime-types.js +3 -0
  81. package/dist/utils/atomic-file.js +6 -0
  82. package/dist/utils/doctor.js +36 -10
  83. package/dist/utils/version-checker.js +17 -11
  84. package/dist/wechat/channel-config.js +6 -0
  85. package/dist/wechat/setup.js +119 -13
  86. package/dist/wechat/wechat-channel.js +6 -0
  87. package/dist/wechat/wechat-transport.js +169 -58
  88. package/package.json +14 -4
  89. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import path from "node:path";
8
+ import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "../../bridge/bridge-utils.js";
9
+ export async function forwardWecomFinalReply(params) {
10
+ const parsed = parseWechatFinalReply(params.rawText);
11
+ const sanitized = sanitizeWechatFinalReplyText(params.adapter, parsed.visibleText);
12
+ const visibleText = formatFinalReplyMessage(params.adapter, sanitized).trim();
13
+ if (visibleText) {
14
+ await params.sender.sendText(visibleText);
15
+ }
16
+ else if (params.adapter === "opencode" && parsed.visibleText.trim()) {
17
+ params.onEmptyVisibleReply?.(parsed.visibleText);
18
+ await params.sender.sendText("OpenCode did not produce a visible reply. Check the local terminal output or retry the message.");
19
+ }
20
+ for (const attachment of parsed.attachments) {
21
+ try {
22
+ await params.sender.sendAttachment({
23
+ kind: attachment.kind,
24
+ path: attachment.path,
25
+ });
26
+ }
27
+ catch (error) {
28
+ const errorText = error instanceof Error ? error.message : String(error);
29
+ await params.sender.sendText(`Failed to send ${attachment.kind} attachment ${path.basename(attachment.path)}: ${errorText}`);
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
8
+ import { runWecomSetup } from "./setup.js";
9
+ runWecomSetup().catch((error) => {
10
+ console.error(error instanceof Error ? error.message : String(error));
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
8
+ import crypto from "node:crypto";
9
+ import readline from "node:readline";
10
+ import { Writable } from "node:stream";
11
+ import { WSClient, } from "@wecom/aibot-node-sdk";
12
+ import { loadStoredWecomAccount, resolveWecomAccount, saveWecomAccount, } from "./wecom-config.js";
13
+ const PAIRING_TIMEOUT_MS = 10 * 60 * 1000;
14
+ function ask(prompt) {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ return new Promise((resolve) => {
17
+ rl.question(prompt, (answer) => {
18
+ rl.close();
19
+ resolve(answer.trim());
20
+ });
21
+ });
22
+ }
23
+ function askSecret(prompt) {
24
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
25
+ return ask(prompt);
26
+ }
27
+ let muted = false;
28
+ const output = new Writable({
29
+ write(chunk, _encoding, callback) {
30
+ if (!muted) {
31
+ process.stdout.write(chunk);
32
+ }
33
+ callback();
34
+ },
35
+ });
36
+ const rl = readline.createInterface({
37
+ input: process.stdin,
38
+ output,
39
+ terminal: true,
40
+ });
41
+ return new Promise((resolve) => {
42
+ rl.question(prompt, (answer) => {
43
+ muted = false;
44
+ rl.close();
45
+ process.stdout.write("\n");
46
+ resolve(answer.trim());
47
+ });
48
+ muted = true;
49
+ });
50
+ }
51
+ export function createWecomPairingCode() {
52
+ return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
53
+ }
54
+ export function isWecomPairingMessage(frame, code) {
55
+ const body = frame.body;
56
+ return Boolean(body &&
57
+ body.chattype === "single" &&
58
+ body.from?.userid &&
59
+ body.msgtype === "text" &&
60
+ body.text?.content?.trim() === `/pair ${code}`);
61
+ }
62
+ function createSetupClient(botId, secret) {
63
+ return new WSClient({
64
+ botId,
65
+ secret,
66
+ maxReconnectAttempts: 3,
67
+ maxAuthFailureAttempts: 2,
68
+ heartbeatInterval: 30_000,
69
+ });
70
+ }
71
+ export async function pairWecomOperator(options) {
72
+ const log = options.log ?? console.log;
73
+ const code = options.code ?? createWecomPairingCode();
74
+ const client = (options.clientFactory ?? createSetupClient)(options.botId, options.secret);
75
+ return new Promise((resolve, reject) => {
76
+ let settled = false;
77
+ const finish = (error, operatorUserId) => {
78
+ if (settled) {
79
+ return;
80
+ }
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ client.disconnect();
84
+ if (error) {
85
+ reject(error);
86
+ return;
87
+ }
88
+ resolve({
89
+ version: 1,
90
+ botId: options.botId,
91
+ secret: options.secret,
92
+ operatorUserId: operatorUserId,
93
+ pairedAt: new Date().toISOString(),
94
+ });
95
+ };
96
+ const timer = setTimeout(() => {
97
+ finish(new Error("WeCom operator pairing timed out."));
98
+ }, options.timeoutMs ?? PAIRING_TIMEOUT_MS);
99
+ timer.unref?.();
100
+ client.on("authenticated", () => {
101
+ log("WeCom authentication succeeded.");
102
+ log(`Send /pair ${code} to the bot in a direct chat within 10 minutes.`);
103
+ });
104
+ client.on("message", (frame) => {
105
+ if (isWecomPairingMessage(frame, code)) {
106
+ finish(undefined, frame.body.from.userid);
107
+ }
108
+ });
109
+ client.on("event.disconnected_event", () => {
110
+ finish(new Error("Another process connected the same WeCom bot during pairing."));
111
+ });
112
+ client.on("error", (error) => {
113
+ finish(error);
114
+ });
115
+ client.connect();
116
+ });
117
+ }
118
+ async function validateWecomAccount(account, options = {}) {
119
+ const client = (options.clientFactory ?? createSetupClient)(account.botId, account.secret);
120
+ await new Promise((resolve, reject) => {
121
+ let settled = false;
122
+ const finish = (error) => {
123
+ if (settled) {
124
+ return;
125
+ }
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ client.disconnect();
129
+ if (error) {
130
+ reject(error);
131
+ }
132
+ else {
133
+ resolve();
134
+ }
135
+ };
136
+ const timer = setTimeout(() => finish(new Error("WeCom credential validation timed out.")), options.timeoutMs ?? 15_000);
137
+ client.on("authenticated", () => finish());
138
+ client.on("error", (error) => finish(error));
139
+ client.on("event.disconnected_event", () => finish(new Error("Another process is already using this WeCom bot.")));
140
+ client.connect();
141
+ });
142
+ }
143
+ export async function ensureWecomAccount(options = {}) {
144
+ const existing = resolveWecomAccount();
145
+ if (existing) {
146
+ return existing;
147
+ }
148
+ options.log?.("No paired WeCom account found. Starting setup...");
149
+ return (options.setup ?? (() => runWecomSetup([])))();
150
+ }
151
+ export async function runWecomSetup(argv = process.argv.slice(2)) {
152
+ if (argv.includes("--help") || argv.includes("-h")) {
153
+ console.log("Usage: wecom-setup [--check]");
154
+ console.log("");
155
+ console.log("Pairs one WeCom operator with the local CLI bridge.");
156
+ console.log("Bot credentials may also be supplied through WECOM_BOT_ID and WECOM_BOT_SECRET.");
157
+ process.exit(0);
158
+ }
159
+ const resolved = resolveWecomAccount();
160
+ if (argv.includes("--check")) {
161
+ if (!resolved) {
162
+ throw new Error("No complete WeCom credentials are configured.");
163
+ }
164
+ await validateWecomAccount(resolved);
165
+ console.log(`WeCom credentials are valid. Paired operator: ${resolved.operatorUserId}`);
166
+ return resolved;
167
+ }
168
+ const stored = loadStoredWecomAccount();
169
+ const botId = process.env.WECOM_BOT_ID?.trim() ||
170
+ await ask(`Bot ID${stored?.botId ? ` [${stored.botId}]` : ""}: `) || stored?.botId || "";
171
+ const secret = process.env.WECOM_BOT_SECRET?.trim() ||
172
+ await askSecret(`Bot Secret${stored?.secret ? " [press Enter to keep saved value]" : ""}: `) ||
173
+ stored?.secret || "";
174
+ if (!botId || !secret) {
175
+ throw new Error("Bot ID and Bot Secret are required.");
176
+ }
177
+ const account = await pairWecomOperator({ botId, secret });
178
+ saveWecomAccount(account);
179
+ console.log("WeCom operator pairing completed.");
180
+ console.log("Start from a project directory with wecom-codex, wecom-claude, wecom-opencode, wecom-pi, or wecom-daemon.");
181
+ return account;
182
+ }
@@ -0,0 +1,45 @@
1
+ import { forwardWecomFinalReply } from "./bridge-final-reply.js";
2
+ import { formatWecomVisibleText } from "./wecom-message.js";
3
+ export class WecomChannelPort {
4
+ channelId = "wecom";
5
+ deps;
6
+ constructor(deps) {
7
+ this.deps = deps;
8
+ }
9
+ sendText(target, text, kind) {
10
+ return this.deps.sendText?.(target, text, kind) ??
11
+ this.deps.transport.sendText(target, text, kind);
12
+ }
13
+ sendAttachment(target, attachment) {
14
+ return this.deps.sendAttachment?.(target, attachment) ??
15
+ this.deps.transport.sendAttachment(target, attachment);
16
+ }
17
+ async send(output) {
18
+ if (output.target.channelId !== this.channelId) {
19
+ throw new Error(`WeCom channel cannot send to ${output.target.channelId}.`);
20
+ }
21
+ const prefix = (text) => formatWecomVisibleText(this.deps.prefixText?.(output.adapter, text) ?? text);
22
+ if (output.kind === "final_reply") {
23
+ await forwardWecomFinalReply({
24
+ adapter: output.adapter ?? "codex",
25
+ rawText: output.text ?? "",
26
+ target: output.target,
27
+ onEmptyVisibleReply: (rawText) => {
28
+ this.deps.onEmptyVisibleReply?.(output.adapter, rawText);
29
+ },
30
+ sender: {
31
+ sendText: (text) => this.sendText(output.target, prefix(text), "final_reply"),
32
+ sendAttachment: (attachment) => this.sendAttachment(output.target, attachment),
33
+ },
34
+ });
35
+ return true;
36
+ }
37
+ if (output.attachment) {
38
+ await this.sendAttachment(output.target, output.attachment);
39
+ }
40
+ if (output.text?.trim()) {
41
+ return this.sendText(output.target, prefix(output.text), output.kind);
42
+ }
43
+ return true;
44
+ }
45
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { CHANNEL_DATA_DIR, ensureChannelDataDir, } from "../../wechat/channel-config.js";
10
+ export const WECOM_DATA_DIR = path.join(CHANNEL_DATA_DIR, "wecom");
11
+ export const WECOM_CREDENTIALS_FILE = path.join(WECOM_DATA_DIR, "account.json");
12
+ export const WECOM_INBOUND_ATTACHMENTS_DIR = path.join(WECOM_DATA_DIR, "inbound-attachments");
13
+ export const WECOM_INBOUND_MESSAGE_CLAIMS_DIR = path.join(WECOM_DATA_DIR, "inbound-message-claims");
14
+ export function ensureWecomDataDir() {
15
+ ensureChannelDataDir();
16
+ fs.mkdirSync(WECOM_DATA_DIR, { recursive: true });
17
+ }
18
+ export function loadStoredWecomAccount(filePath = WECOM_CREDENTIALS_FILE) {
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
21
+ if (parsed.version !== 1 ||
22
+ typeof parsed.botId !== "string" ||
23
+ !parsed.botId.trim() ||
24
+ typeof parsed.secret !== "string" ||
25
+ !parsed.secret.trim() ||
26
+ typeof parsed.operatorUserId !== "string" ||
27
+ !parsed.operatorUserId.trim() ||
28
+ typeof parsed.pairedAt !== "string") {
29
+ return null;
30
+ }
31
+ return {
32
+ version: 1,
33
+ botId: parsed.botId.trim(),
34
+ secret: parsed.secret.trim(),
35
+ operatorUserId: parsed.operatorUserId.trim(),
36
+ pairedAt: parsed.pairedAt,
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function resolveWecomAccount(env = process.env, stored = loadStoredWecomAccount()) {
44
+ const botId = env.WECOM_BOT_ID?.trim() || stored?.botId || "";
45
+ const secret = env.WECOM_BOT_SECRET?.trim() || stored?.secret || "";
46
+ const operatorUserId = env.WECOM_OPERATOR_USER_ID?.trim() || stored?.operatorUserId || "";
47
+ if (!botId || !secret || !operatorUserId) {
48
+ return null;
49
+ }
50
+ return {
51
+ version: 1,
52
+ botId,
53
+ secret,
54
+ operatorUserId,
55
+ pairedAt: stored?.pairedAt || new Date().toISOString(),
56
+ };
57
+ }
58
+ export function saveWecomAccount(account, filePath = WECOM_CREDENTIALS_FILE) {
59
+ if (filePath === WECOM_CREDENTIALS_FILE) {
60
+ ensureWecomDataDir();
61
+ }
62
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
63
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
64
+ fs.writeFileSync(tempPath, JSON.stringify(account, null, 2), "utf-8");
65
+ try {
66
+ fs.chmodSync(tempPath, 0o600);
67
+ }
68
+ catch {
69
+ // Best effort on Windows.
70
+ }
71
+ fs.renameSync(tempPath, filePath);
72
+ }
@@ -0,0 +1,106 @@
1
+ import { buildWecomInboundPrompt, formatWecomVisibleText, } from "./wecom-message.js";
2
+ import { truncatePreview } from "../../core/text-utils.js";
3
+ export function toWecomOutputKind(context) {
4
+ const known = new Set([
5
+ "status",
6
+ "notice",
7
+ "thinking",
8
+ "final_reply",
9
+ "approval_required",
10
+ "user_input_required",
11
+ "mirrored_input",
12
+ "task_failed",
13
+ "fatal_error",
14
+ ]);
15
+ return known.has(context)
16
+ ? context
17
+ : context === "mirrored_user_input"
18
+ ? "mirrored_input"
19
+ : "notice";
20
+ }
21
+ /** ChannelDriver over the official WeCom smart-bot WebSocket transport. */
22
+ export class WecomChannelDriver {
23
+ id = "wecom";
24
+ displayName = "WeCom";
25
+ operatorDescription = "the paired WeCom operator";
26
+ capabilities = {
27
+ streamingReplies: true,
28
+ outboundAttachments: true,
29
+ multiConversation: true,
30
+ pushInbound: true,
31
+ };
32
+ transport;
33
+ accountId;
34
+ operatorId;
35
+ logError;
36
+ constructor(options) {
37
+ this.transport = options.transport;
38
+ this.accountId = options.accountId;
39
+ this.operatorId = options.operatorId;
40
+ this.logError = options.logError;
41
+ }
42
+ defaultConversation() {
43
+ return this.directConversation(this.operatorId);
44
+ }
45
+ directConversation(senderId) {
46
+ return {
47
+ channelId: "wecom",
48
+ accountId: this.accountId,
49
+ conversationId: senderId,
50
+ recipientId: senderId,
51
+ metadata: { chatType: "direct" },
52
+ };
53
+ }
54
+ start(handlers) {
55
+ this.transport.setHandlers({
56
+ onMessage: async (message) => {
57
+ await handlers.onInboundMessage(message);
58
+ },
59
+ onUnauthorized: handlers.onUnauthorizedSender
60
+ ? async (senderId, chatType) => {
61
+ await handlers.onUnauthorizedSender(senderId, chatType);
62
+ }
63
+ : undefined,
64
+ onFatal: handlers.onChannelFatal
65
+ ? async (error) => {
66
+ await handlers.onChannelFatal(error);
67
+ }
68
+ : undefined,
69
+ onConnected: handlers.onChannelConnected
70
+ ? async () => {
71
+ await handlers.onChannelConnected();
72
+ }
73
+ : undefined,
74
+ });
75
+ this.transport.start();
76
+ }
77
+ waitUntilReady() {
78
+ return this.transport.waitUntilConnected();
79
+ }
80
+ async sendText(params) {
81
+ const { target, text, context, log } = params;
82
+ const startedAtMs = Date.now();
83
+ log(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
84
+ try {
85
+ await this.transport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
86
+ log(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
87
+ return { status: "sent" };
88
+ }
89
+ catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ log(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
92
+ this.logError(`Failed to send WeCom ${context}: ${message}`);
93
+ return {
94
+ status: "target_stale",
95
+ error,
96
+ target,
97
+ };
98
+ }
99
+ }
100
+ buildInboundPrompt(text, attachments) {
101
+ return buildWecomInboundPrompt(text, attachments);
102
+ }
103
+ formatVisibleText(text) {
104
+ return formatWecomVisibleText(text);
105
+ }
106
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { decryptFile } from "@wecom/aibot-node-sdk";
10
+ import { WECOM_INBOUND_ATTACHMENTS_DIR } from "./wecom-config.js";
11
+ const DEFAULT_INBOUND_LIMIT_MB = {
12
+ image: 20,
13
+ file: 50,
14
+ video: 50,
15
+ };
16
+ const INBOUND_LIMIT_ENV_KEYS = {
17
+ image: "WECOM_MAX_INBOUND_IMAGE_MB",
18
+ file: "WECOM_MAX_INBOUND_FILE_MB",
19
+ video: "WECOM_MAX_INBOUND_VIDEO_MB",
20
+ };
21
+ function resolvePositiveMb(value, fallback) {
22
+ const parsed = Number(value);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
24
+ }
25
+ export function getWecomInboundLimitBytes(kind, env = process.env) {
26
+ return Math.floor(resolvePositiveMb(env[INBOUND_LIMIT_ENV_KEYS[kind]], DEFAULT_INBOUND_LIMIT_MB[kind]) * 1024 * 1024);
27
+ }
28
+ function sanitizeFileName(value, fallback) {
29
+ const name = value.trim().replace(/\\/g, "/").split("/").pop() || fallback;
30
+ const cleaned = Array.from(name)
31
+ .map((character) => (character.charCodeAt(0) < 32 ? "_" : character))
32
+ .join("")
33
+ .replace(/[<>:"/\\|?*]+/g, "_")
34
+ .replace(/^\.+/, "")
35
+ .slice(0, 180);
36
+ return cleaned || fallback;
37
+ }
38
+ function parseContentDispositionFileName(value) {
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
43
+ if (utf8) {
44
+ try {
45
+ return decodeURIComponent(utf8.trim());
46
+ }
47
+ catch {
48
+ return utf8.trim();
49
+ }
50
+ }
51
+ return value.match(/filename="?([^";]+)"?/i)?.[1]?.trim();
52
+ }
53
+ function extensionFor(kind, mimeType) {
54
+ const normalized = mimeType.split(";")[0]?.trim().toLowerCase() || "";
55
+ const known = {
56
+ "image/jpeg": ".jpg",
57
+ "image/png": ".png",
58
+ "image/gif": ".gif",
59
+ "image/webp": ".webp",
60
+ "video/mp4": ".mp4",
61
+ "application/pdf": ".pdf",
62
+ "text/plain": ".txt",
63
+ };
64
+ return known[normalized] || (kind === "image" ? ".jpg" : kind === "video" ? ".mp4" : ".bin");
65
+ }
66
+ async function readResponseWithLimit(response, maxBytes) {
67
+ const declaredLength = Number(response.headers.get("content-length"));
68
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes + 64) {
69
+ throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
70
+ }
71
+ if (!response.body) {
72
+ return Buffer.alloc(0);
73
+ }
74
+ const reader = response.body.getReader();
75
+ const chunks = [];
76
+ let total = 0;
77
+ while (true) {
78
+ const next = await reader.read();
79
+ if (next.done) {
80
+ break;
81
+ }
82
+ total += next.value.byteLength;
83
+ if (total > maxBytes + 64) {
84
+ await reader.cancel().catch(() => undefined);
85
+ throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
86
+ }
87
+ chunks.push(next.value);
88
+ }
89
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
90
+ }
91
+ export async function downloadWecomAttachment(descriptor, options) {
92
+ const maxBytes = getWecomInboundLimitBytes(descriptor.kind, options.env);
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 60_000);
95
+ try {
96
+ const response = await (options.fetchImpl ?? fetch)(descriptor.url, {
97
+ signal: controller.signal,
98
+ });
99
+ if (!response.ok) {
100
+ throw new Error(`download returned HTTP ${response.status}`);
101
+ }
102
+ const encrypted = await readResponseWithLimit(response, maxBytes);
103
+ const decrypted = descriptor.aesKey ? decryptFile(encrypted, descriptor.aesKey) : encrypted;
104
+ if (decrypted.length > maxBytes) {
105
+ throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
106
+ }
107
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim();
108
+ const headerName = parseContentDispositionFileName(response.headers.get("content-disposition"));
109
+ const fallbackName = `${descriptor.kind}-${options.index + 1}${extensionFor(descriptor.kind, mimeType || "")}`;
110
+ const fileName = sanitizeFileName(descriptor.fileName || headerName || fallbackName, fallbackName);
111
+ const day = new Date().toISOString().slice(0, 10);
112
+ const directory = path.join(options.attachmentsDir ?? WECOM_INBOUND_ATTACHMENTS_DIR, day);
113
+ fs.mkdirSync(directory, { recursive: true });
114
+ const safeMessageId = options.messageId.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
115
+ const filePath = path.join(directory, `${safeMessageId}-${options.index + 1}-${fileName}`);
116
+ fs.writeFileSync(filePath, decrypted);
117
+ return {
118
+ kind: descriptor.kind,
119
+ path: filePath,
120
+ fileName,
121
+ mimeType,
122
+ sizeBytes: decrypted.length,
123
+ };
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+ export async function downloadWecomInboundAttachments(parsed, options = {}) {
130
+ const attachments = [];
131
+ const failureNotes = [];
132
+ for (let index = 0; index < parsed.attachments.length; index += 1) {
133
+ const descriptor = parsed.attachments[index];
134
+ try {
135
+ attachments.push(await downloadWecomAttachment(descriptor, {
136
+ ...options,
137
+ messageId: parsed.id,
138
+ index,
139
+ }));
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ failureNotes.push(`[WeCom attachment unavailable: ${descriptor.kind}; ${message}]`);
144
+ }
145
+ }
146
+ return { attachments, failureNotes };
147
+ }