cli-wechat-bridge 1.1.6 → 1.1.8
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.
- package/LICENSE.txt +661 -661
- package/README.md +454 -475
- package/bin/_run-entry.mjs +23 -5
- package/bin/wecom-check-update.mjs +5 -0
- package/bin/wecom-claude.mjs +5 -0
- package/bin/wecom-codex.mjs +5 -0
- package/bin/wecom-daemon.mjs +5 -0
- package/bin/wecom-opencode.mjs +5 -0
- package/bin/wecom-pi.mjs +5 -0
- package/bin/wecom-setup.mjs +5 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +155 -4
- package/dist/bridge/bridge-adapters.codex.js +112 -7
- package/dist/bridge/bridge-adapters.core.js +19 -1
- package/dist/bridge/bridge-adapters.opencode.js +150 -1
- package/dist/bridge/bridge-adapters.pi.js +39 -0
- package/dist/bridge/bridge-process-reaper.js +35 -2
- package/dist/bridge/bridge-state.js +10 -3
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +253 -0
- package/dist/bridge/wechat-bridge.js +389 -87
- package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
- package/dist/channels/wecom/bridge-final-reply.js +26 -0
- package/dist/channels/wecom/setup-cli.js +6 -0
- package/dist/channels/wecom/setup.js +176 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +66 -0
- package/dist/channels/wecom/wecom-media.js +141 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +308 -0
- package/dist/companion/codex-remote-client.js +24 -3
- package/dist/companion/codex-visible-client-link.js +101 -0
- package/dist/companion/local-companion-start.js +56 -11
- package/dist/companion/local-companion.js +19 -1
- package/dist/companion/pi-tui-bridge-extension.js +56 -3
- package/dist/core/bridge-control.js +8 -0
- package/dist/core/channel-types.js +3 -1
- package/dist/core/conversation-routing.js +48 -0
- package/dist/core/direct-run.js +17 -0
- package/dist/core/inbound-message-claims.js +61 -0
- package/dist/daemon/daemon-link.js +3 -0
- package/dist/daemon/wechat-daemon.js +335 -60
- package/dist/i18n/messages-en.js +3 -1
- package/dist/i18n/messages-zh.js +3 -1
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/doctor.js +30 -10
- package/dist/utils/version-checker.js +11 -11
- package/dist/wechat/setup.js +113 -13
- package/dist/wechat/wechat-transport.js +96 -58
- package/package.json +116 -104
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -4,7 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { getWorkspaceChannelPaths } from "../../wechat/channel-config.js";
|
|
5
5
|
import { writeJsonFileAtomic } from "../../utils/atomic-file.js";
|
|
6
6
|
export function getPendingWechatMessagesFile(cwd) {
|
|
7
|
-
return
|
|
7
|
+
return getPendingChannelMessagesFile(cwd, "wechat");
|
|
8
|
+
}
|
|
9
|
+
export function getPendingChannelMessagesFile(cwd, channelId) {
|
|
10
|
+
return path.join(getWorkspaceChannelPaths(cwd).workspaceDir, `pending-${channelId}-messages.json`);
|
|
8
11
|
}
|
|
9
12
|
function readPendingMessages(filePath) {
|
|
10
13
|
try {
|
|
@@ -38,9 +41,12 @@ export class PendingWechatMessageStore {
|
|
|
38
41
|
this.messages = readPendingMessages(filePath);
|
|
39
42
|
}
|
|
40
43
|
list() {
|
|
41
|
-
return this.messages.map((message) => ({
|
|
44
|
+
return this.messages.map((message) => ({
|
|
45
|
+
...message,
|
|
46
|
+
target: message.target ? { ...message.target } : undefined,
|
|
47
|
+
}));
|
|
42
48
|
}
|
|
43
|
-
enqueue(recipientId, text, context) {
|
|
49
|
+
enqueue(recipientId, text, context, target) {
|
|
44
50
|
const normalizedRecipientId = recipientId.trim();
|
|
45
51
|
const normalizedText = text.trim();
|
|
46
52
|
if (!normalizedRecipientId || !normalizedText) {
|
|
@@ -52,6 +58,7 @@ export class PendingWechatMessageStore {
|
|
|
52
58
|
text: normalizedText,
|
|
53
59
|
context,
|
|
54
60
|
queuedAt: new Date().toISOString(),
|
|
61
|
+
target: target ? { ...target } : undefined,
|
|
55
62
|
};
|
|
56
63
|
this.messages.push(message);
|
|
57
64
|
this.persist();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "../../bridge/bridge-utils.js";
|
|
3
|
+
export async function forwardWecomFinalReply(params) {
|
|
4
|
+
const parsed = parseWechatFinalReply(params.rawText);
|
|
5
|
+
const sanitized = sanitizeWechatFinalReplyText(params.adapter, parsed.visibleText);
|
|
6
|
+
const visibleText = formatFinalReplyMessage(params.adapter, sanitized).trim();
|
|
7
|
+
if (visibleText) {
|
|
8
|
+
await params.sender.sendText(visibleText);
|
|
9
|
+
}
|
|
10
|
+
else if (params.adapter === "opencode" && parsed.visibleText.trim()) {
|
|
11
|
+
params.onEmptyVisibleReply?.(parsed.visibleText);
|
|
12
|
+
await params.sender.sendText("OpenCode did not produce a visible reply. Check the local terminal output or retry the message.");
|
|
13
|
+
}
|
|
14
|
+
for (const attachment of parsed.attachments) {
|
|
15
|
+
try {
|
|
16
|
+
await params.sender.sendAttachment({
|
|
17
|
+
kind: attachment.kind,
|
|
18
|
+
path: attachment.path,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
23
|
+
await params.sender.sendText(`Failed to send ${attachment.kind} attachment ${path.basename(attachment.path)}: ${errorText}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { Writable } from "node:stream";
|
|
5
|
+
import { WSClient, } from "@wecom/aibot-node-sdk";
|
|
6
|
+
import { loadStoredWecomAccount, resolveWecomAccount, saveWecomAccount, } from "./wecom-config.js";
|
|
7
|
+
const PAIRING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
+
function ask(prompt) {
|
|
9
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(prompt, (answer) => {
|
|
12
|
+
rl.close();
|
|
13
|
+
resolve(answer.trim());
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function askSecret(prompt) {
|
|
18
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
19
|
+
return ask(prompt);
|
|
20
|
+
}
|
|
21
|
+
let muted = false;
|
|
22
|
+
const output = new Writable({
|
|
23
|
+
write(chunk, _encoding, callback) {
|
|
24
|
+
if (!muted) {
|
|
25
|
+
process.stdout.write(chunk);
|
|
26
|
+
}
|
|
27
|
+
callback();
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const rl = readline.createInterface({
|
|
31
|
+
input: process.stdin,
|
|
32
|
+
output,
|
|
33
|
+
terminal: true,
|
|
34
|
+
});
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
rl.question(prompt, (answer) => {
|
|
37
|
+
muted = false;
|
|
38
|
+
rl.close();
|
|
39
|
+
process.stdout.write("\n");
|
|
40
|
+
resolve(answer.trim());
|
|
41
|
+
});
|
|
42
|
+
muted = true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function createWecomPairingCode() {
|
|
46
|
+
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
|
|
47
|
+
}
|
|
48
|
+
export function isWecomPairingMessage(frame, code) {
|
|
49
|
+
const body = frame.body;
|
|
50
|
+
return Boolean(body &&
|
|
51
|
+
body.chattype === "single" &&
|
|
52
|
+
body.from?.userid &&
|
|
53
|
+
body.msgtype === "text" &&
|
|
54
|
+
body.text?.content?.trim() === `/pair ${code}`);
|
|
55
|
+
}
|
|
56
|
+
function createSetupClient(botId, secret) {
|
|
57
|
+
return new WSClient({
|
|
58
|
+
botId,
|
|
59
|
+
secret,
|
|
60
|
+
maxReconnectAttempts: 3,
|
|
61
|
+
maxAuthFailureAttempts: 2,
|
|
62
|
+
heartbeatInterval: 30_000,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function pairWecomOperator(options) {
|
|
66
|
+
const log = options.log ?? console.log;
|
|
67
|
+
const code = options.code ?? createWecomPairingCode();
|
|
68
|
+
const client = (options.clientFactory ?? createSetupClient)(options.botId, options.secret);
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
const finish = (error, operatorUserId) => {
|
|
72
|
+
if (settled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
client.disconnect();
|
|
78
|
+
if (error) {
|
|
79
|
+
reject(error);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
resolve({
|
|
83
|
+
version: 1,
|
|
84
|
+
botId: options.botId,
|
|
85
|
+
secret: options.secret,
|
|
86
|
+
operatorUserId: operatorUserId,
|
|
87
|
+
pairedAt: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
finish(new Error("WeCom operator pairing timed out."));
|
|
92
|
+
}, options.timeoutMs ?? PAIRING_TIMEOUT_MS);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
client.on("authenticated", () => {
|
|
95
|
+
log("WeCom authentication succeeded.");
|
|
96
|
+
log(`Send /pair ${code} to the bot in a direct chat within 10 minutes.`);
|
|
97
|
+
});
|
|
98
|
+
client.on("message", (frame) => {
|
|
99
|
+
if (isWecomPairingMessage(frame, code)) {
|
|
100
|
+
finish(undefined, frame.body.from.userid);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
client.on("event.disconnected_event", () => {
|
|
104
|
+
finish(new Error("Another process connected the same WeCom bot during pairing."));
|
|
105
|
+
});
|
|
106
|
+
client.on("error", (error) => {
|
|
107
|
+
finish(error);
|
|
108
|
+
});
|
|
109
|
+
client.connect();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function validateWecomAccount(account, options = {}) {
|
|
113
|
+
const client = (options.clientFactory ?? createSetupClient)(account.botId, account.secret);
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (error) => {
|
|
117
|
+
if (settled) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
client.disconnect();
|
|
123
|
+
if (error) {
|
|
124
|
+
reject(error);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
resolve();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const timer = setTimeout(() => finish(new Error("WeCom credential validation timed out.")), options.timeoutMs ?? 15_000);
|
|
131
|
+
client.on("authenticated", () => finish());
|
|
132
|
+
client.on("error", (error) => finish(error));
|
|
133
|
+
client.on("event.disconnected_event", () => finish(new Error("Another process is already using this WeCom bot.")));
|
|
134
|
+
client.connect();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export async function ensureWecomAccount(options = {}) {
|
|
138
|
+
const existing = resolveWecomAccount();
|
|
139
|
+
if (existing) {
|
|
140
|
+
return existing;
|
|
141
|
+
}
|
|
142
|
+
options.log?.("No paired WeCom account found. Starting setup...");
|
|
143
|
+
return (options.setup ?? (() => runWecomSetup([])))();
|
|
144
|
+
}
|
|
145
|
+
export async function runWecomSetup(argv = process.argv.slice(2)) {
|
|
146
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
147
|
+
console.log("Usage: wecom-setup [--check]");
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log("Pairs one WeCom operator with the local CLI bridge.");
|
|
150
|
+
console.log("Bot credentials may also be supplied through WECOM_BOT_ID and WECOM_BOT_SECRET.");
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
const resolved = resolveWecomAccount();
|
|
154
|
+
if (argv.includes("--check")) {
|
|
155
|
+
if (!resolved) {
|
|
156
|
+
throw new Error("No complete WeCom credentials are configured.");
|
|
157
|
+
}
|
|
158
|
+
await validateWecomAccount(resolved);
|
|
159
|
+
console.log(`WeCom credentials are valid. Paired operator: ${resolved.operatorUserId}`);
|
|
160
|
+
return resolved;
|
|
161
|
+
}
|
|
162
|
+
const stored = loadStoredWecomAccount();
|
|
163
|
+
const botId = process.env.WECOM_BOT_ID?.trim() ||
|
|
164
|
+
await ask(`Bot ID${stored?.botId ? ` [${stored.botId}]` : ""}: `) || stored?.botId || "";
|
|
165
|
+
const secret = process.env.WECOM_BOT_SECRET?.trim() ||
|
|
166
|
+
await askSecret(`Bot Secret${stored?.secret ? " [press Enter to keep saved value]" : ""}: `) ||
|
|
167
|
+
stored?.secret || "";
|
|
168
|
+
if (!botId || !secret) {
|
|
169
|
+
throw new Error("Bot ID and Bot Secret are required.");
|
|
170
|
+
}
|
|
171
|
+
const account = await pairWecomOperator({ botId, secret });
|
|
172
|
+
saveWecomAccount(account);
|
|
173
|
+
console.log("WeCom operator pairing completed.");
|
|
174
|
+
console.log("Start from a project directory with wecom-codex, wecom-claude, wecom-opencode, wecom-pi, or wecom-daemon.");
|
|
175
|
+
return account;
|
|
176
|
+
}
|
|
@@ -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,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CHANNEL_DATA_DIR, ensureChannelDataDir, } from "../../wechat/channel-config.js";
|
|
4
|
+
export const WECOM_DATA_DIR = path.join(CHANNEL_DATA_DIR, "wecom");
|
|
5
|
+
export const WECOM_CREDENTIALS_FILE = path.join(WECOM_DATA_DIR, "account.json");
|
|
6
|
+
export const WECOM_INBOUND_ATTACHMENTS_DIR = path.join(WECOM_DATA_DIR, "inbound-attachments");
|
|
7
|
+
export const WECOM_INBOUND_MESSAGE_CLAIMS_DIR = path.join(WECOM_DATA_DIR, "inbound-message-claims");
|
|
8
|
+
export function ensureWecomDataDir() {
|
|
9
|
+
ensureChannelDataDir();
|
|
10
|
+
fs.mkdirSync(WECOM_DATA_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
export function loadStoredWecomAccount(filePath = WECOM_CREDENTIALS_FILE) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
15
|
+
if (parsed.version !== 1 ||
|
|
16
|
+
typeof parsed.botId !== "string" ||
|
|
17
|
+
!parsed.botId.trim() ||
|
|
18
|
+
typeof parsed.secret !== "string" ||
|
|
19
|
+
!parsed.secret.trim() ||
|
|
20
|
+
typeof parsed.operatorUserId !== "string" ||
|
|
21
|
+
!parsed.operatorUserId.trim() ||
|
|
22
|
+
typeof parsed.pairedAt !== "string") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
version: 1,
|
|
27
|
+
botId: parsed.botId.trim(),
|
|
28
|
+
secret: parsed.secret.trim(),
|
|
29
|
+
operatorUserId: parsed.operatorUserId.trim(),
|
|
30
|
+
pairedAt: parsed.pairedAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function resolveWecomAccount(env = process.env, stored = loadStoredWecomAccount()) {
|
|
38
|
+
const botId = env.WECOM_BOT_ID?.trim() || stored?.botId || "";
|
|
39
|
+
const secret = env.WECOM_BOT_SECRET?.trim() || stored?.secret || "";
|
|
40
|
+
const operatorUserId = env.WECOM_OPERATOR_USER_ID?.trim() || stored?.operatorUserId || "";
|
|
41
|
+
if (!botId || !secret || !operatorUserId) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
botId,
|
|
47
|
+
secret,
|
|
48
|
+
operatorUserId,
|
|
49
|
+
pairedAt: stored?.pairedAt || new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function saveWecomAccount(account, filePath = WECOM_CREDENTIALS_FILE) {
|
|
53
|
+
if (filePath === WECOM_CREDENTIALS_FILE) {
|
|
54
|
+
ensureWecomDataDir();
|
|
55
|
+
}
|
|
56
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
57
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
58
|
+
fs.writeFileSync(tempPath, JSON.stringify(account, null, 2), "utf-8");
|
|
59
|
+
try {
|
|
60
|
+
fs.chmodSync(tempPath, 0o600);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Best effort on Windows.
|
|
64
|
+
}
|
|
65
|
+
fs.renameSync(tempPath, filePath);
|
|
66
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { decryptFile } from "@wecom/aibot-node-sdk";
|
|
4
|
+
import { WECOM_INBOUND_ATTACHMENTS_DIR } from "./wecom-config.js";
|
|
5
|
+
const DEFAULT_INBOUND_LIMIT_MB = {
|
|
6
|
+
image: 20,
|
|
7
|
+
file: 50,
|
|
8
|
+
video: 50,
|
|
9
|
+
};
|
|
10
|
+
const INBOUND_LIMIT_ENV_KEYS = {
|
|
11
|
+
image: "WECOM_MAX_INBOUND_IMAGE_MB",
|
|
12
|
+
file: "WECOM_MAX_INBOUND_FILE_MB",
|
|
13
|
+
video: "WECOM_MAX_INBOUND_VIDEO_MB",
|
|
14
|
+
};
|
|
15
|
+
function resolvePositiveMb(value, fallback) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
18
|
+
}
|
|
19
|
+
export function getWecomInboundLimitBytes(kind, env = process.env) {
|
|
20
|
+
return Math.floor(resolvePositiveMb(env[INBOUND_LIMIT_ENV_KEYS[kind]], DEFAULT_INBOUND_LIMIT_MB[kind]) * 1024 * 1024);
|
|
21
|
+
}
|
|
22
|
+
function sanitizeFileName(value, fallback) {
|
|
23
|
+
const name = value.trim().replace(/\\/g, "/").split("/").pop() || fallback;
|
|
24
|
+
const cleaned = Array.from(name)
|
|
25
|
+
.map((character) => (character.charCodeAt(0) < 32 ? "_" : character))
|
|
26
|
+
.join("")
|
|
27
|
+
.replace(/[<>:"/\\|?*]+/g, "_")
|
|
28
|
+
.replace(/^\.+/, "")
|
|
29
|
+
.slice(0, 180);
|
|
30
|
+
return cleaned || fallback;
|
|
31
|
+
}
|
|
32
|
+
function parseContentDispositionFileName(value) {
|
|
33
|
+
if (!value) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
37
|
+
if (utf8) {
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(utf8.trim());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return utf8.trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value.match(/filename="?([^";]+)"?/i)?.[1]?.trim();
|
|
46
|
+
}
|
|
47
|
+
function extensionFor(kind, mimeType) {
|
|
48
|
+
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() || "";
|
|
49
|
+
const known = {
|
|
50
|
+
"image/jpeg": ".jpg",
|
|
51
|
+
"image/png": ".png",
|
|
52
|
+
"image/gif": ".gif",
|
|
53
|
+
"image/webp": ".webp",
|
|
54
|
+
"video/mp4": ".mp4",
|
|
55
|
+
"application/pdf": ".pdf",
|
|
56
|
+
"text/plain": ".txt",
|
|
57
|
+
};
|
|
58
|
+
return known[normalized] || (kind === "image" ? ".jpg" : kind === "video" ? ".mp4" : ".bin");
|
|
59
|
+
}
|
|
60
|
+
async function readResponseWithLimit(response, maxBytes) {
|
|
61
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
62
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes + 64) {
|
|
63
|
+
throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
|
|
64
|
+
}
|
|
65
|
+
if (!response.body) {
|
|
66
|
+
return Buffer.alloc(0);
|
|
67
|
+
}
|
|
68
|
+
const reader = response.body.getReader();
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let total = 0;
|
|
71
|
+
while (true) {
|
|
72
|
+
const next = await reader.read();
|
|
73
|
+
if (next.done) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
total += next.value.byteLength;
|
|
77
|
+
if (total > maxBytes + 64) {
|
|
78
|
+
await reader.cancel().catch(() => undefined);
|
|
79
|
+
throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
|
|
80
|
+
}
|
|
81
|
+
chunks.push(next.value);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
84
|
+
}
|
|
85
|
+
export async function downloadWecomAttachment(descriptor, options) {
|
|
86
|
+
const maxBytes = getWecomInboundLimitBytes(descriptor.kind, options.env);
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 60_000);
|
|
89
|
+
try {
|
|
90
|
+
const response = await (options.fetchImpl ?? fetch)(descriptor.url, {
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`download returned HTTP ${response.status}`);
|
|
95
|
+
}
|
|
96
|
+
const encrypted = await readResponseWithLimit(response, maxBytes);
|
|
97
|
+
const decrypted = descriptor.aesKey ? decryptFile(encrypted, descriptor.aesKey) : encrypted;
|
|
98
|
+
if (decrypted.length > maxBytes) {
|
|
99
|
+
throw new Error(`attachment exceeds the configured ${maxBytes}-byte limit`);
|
|
100
|
+
}
|
|
101
|
+
const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim();
|
|
102
|
+
const headerName = parseContentDispositionFileName(response.headers.get("content-disposition"));
|
|
103
|
+
const fallbackName = `${descriptor.kind}-${options.index + 1}${extensionFor(descriptor.kind, mimeType || "")}`;
|
|
104
|
+
const fileName = sanitizeFileName(descriptor.fileName || headerName || fallbackName, fallbackName);
|
|
105
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
106
|
+
const directory = path.join(options.attachmentsDir ?? WECOM_INBOUND_ATTACHMENTS_DIR, day);
|
|
107
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
108
|
+
const safeMessageId = options.messageId.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
|
|
109
|
+
const filePath = path.join(directory, `${safeMessageId}-${options.index + 1}-${fileName}`);
|
|
110
|
+
fs.writeFileSync(filePath, decrypted);
|
|
111
|
+
return {
|
|
112
|
+
kind: descriptor.kind,
|
|
113
|
+
path: filePath,
|
|
114
|
+
fileName,
|
|
115
|
+
mimeType,
|
|
116
|
+
sizeBytes: decrypted.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function downloadWecomInboundAttachments(parsed, options = {}) {
|
|
124
|
+
const attachments = [];
|
|
125
|
+
const failureNotes = [];
|
|
126
|
+
for (let index = 0; index < parsed.attachments.length; index += 1) {
|
|
127
|
+
const descriptor = parsed.attachments[index];
|
|
128
|
+
try {
|
|
129
|
+
attachments.push(await downloadWecomAttachment(descriptor, {
|
|
130
|
+
...options,
|
|
131
|
+
messageId: parsed.id,
|
|
132
|
+
index,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
failureNotes.push(`[WeCom attachment unavailable: ${descriptor.kind}; ${message}]`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { attachments, failureNotes };
|
|
141
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
function appendMixedContent(items, textParts, attachments) {
|
|
2
|
+
for (const item of items ?? []) {
|
|
3
|
+
if (item.msgtype === "text" && item.text?.content) {
|
|
4
|
+
textParts.push(item.text.content);
|
|
5
|
+
}
|
|
6
|
+
else if (item.msgtype === "image" && item.image?.url) {
|
|
7
|
+
attachments.push({
|
|
8
|
+
kind: "image",
|
|
9
|
+
url: item.image.url,
|
|
10
|
+
aesKey: item.image.aeskey,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function appendQuote(quote, textParts, attachments) {
|
|
16
|
+
if (!quote) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const quotedText = quote.text?.content || quote.voice?.content;
|
|
20
|
+
if (quotedText) {
|
|
21
|
+
textParts.push(`[Quoted message]\n${quotedText}`);
|
|
22
|
+
}
|
|
23
|
+
if (quote.image?.url) {
|
|
24
|
+
attachments.push({
|
|
25
|
+
kind: "image",
|
|
26
|
+
url: quote.image.url,
|
|
27
|
+
aesKey: quote.image.aeskey,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (quote.file?.url) {
|
|
31
|
+
attachments.push({
|
|
32
|
+
kind: "file",
|
|
33
|
+
url: quote.file.url,
|
|
34
|
+
aesKey: quote.file.aeskey,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const quotedVideo = quote.video;
|
|
38
|
+
if (quotedVideo?.url) {
|
|
39
|
+
attachments.push({
|
|
40
|
+
kind: "video",
|
|
41
|
+
url: quotedVideo.url,
|
|
42
|
+
aesKey: quotedVideo.aeskey,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
appendMixedContent(quote.mixed?.msg_item, textParts, attachments);
|
|
46
|
+
}
|
|
47
|
+
export function parseWecomInboundFrame(frame) {
|
|
48
|
+
const body = frame.body;
|
|
49
|
+
if (!body?.msgid || !body.from?.userid || !frame.headers?.req_id) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const textParts = [];
|
|
53
|
+
const attachments = [];
|
|
54
|
+
if (body.msgtype === "text" && body.text?.content) {
|
|
55
|
+
textParts.push(body.text.content);
|
|
56
|
+
}
|
|
57
|
+
else if (body.msgtype === "voice" && body.voice?.content) {
|
|
58
|
+
textParts.push(body.voice.content);
|
|
59
|
+
}
|
|
60
|
+
else if (body.msgtype === "mixed") {
|
|
61
|
+
appendMixedContent(body.mixed?.msg_item, textParts, attachments);
|
|
62
|
+
}
|
|
63
|
+
else if (body.msgtype === "image" && body.image?.url) {
|
|
64
|
+
attachments.push({
|
|
65
|
+
kind: "image",
|
|
66
|
+
url: body.image.url,
|
|
67
|
+
aesKey: body.image.aeskey,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else if (body.msgtype === "file" && body.file?.url) {
|
|
71
|
+
attachments.push({
|
|
72
|
+
kind: "file",
|
|
73
|
+
url: body.file.url,
|
|
74
|
+
aesKey: body.file.aeskey,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else if (body.msgtype === "video" && body.video?.url) {
|
|
78
|
+
attachments.push({
|
|
79
|
+
kind: "video",
|
|
80
|
+
url: body.video.url,
|
|
81
|
+
aesKey: body.video.aeskey,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
appendQuote(body.quote, textParts, attachments);
|
|
85
|
+
const isGroup = body.chattype === "group" && Boolean(body.chatid);
|
|
86
|
+
const timestampMs = typeof body.create_time === "number" && body.create_time > 0
|
|
87
|
+
? body.create_time * 1000
|
|
88
|
+
: Date.now();
|
|
89
|
+
return {
|
|
90
|
+
id: body.msgid,
|
|
91
|
+
reqId: frame.headers.req_id,
|
|
92
|
+
senderId: body.from.userid,
|
|
93
|
+
conversationId: isGroup ? body.chatid : body.from.userid,
|
|
94
|
+
chatType: isGroup ? "group" : "direct",
|
|
95
|
+
text: textParts.join("\n").trim(),
|
|
96
|
+
createdAt: new Date(timestampMs).toISOString(),
|
|
97
|
+
attachments,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function toWecomChannelInboundMessage(parsed, attachments, accountId) {
|
|
101
|
+
return {
|
|
102
|
+
id: parsed.id,
|
|
103
|
+
conversation: {
|
|
104
|
+
channelId: "wecom",
|
|
105
|
+
accountId,
|
|
106
|
+
conversationId: parsed.conversationId,
|
|
107
|
+
recipientId: parsed.conversationId,
|
|
108
|
+
opaqueRef: parsed.reqId,
|
|
109
|
+
metadata: {
|
|
110
|
+
chatType: parsed.chatType,
|
|
111
|
+
messageId: parsed.id,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
senderId: parsed.senderId,
|
|
115
|
+
text: parsed.text,
|
|
116
|
+
attachments,
|
|
117
|
+
createdAt: parsed.createdAt,
|
|
118
|
+
metadata: {
|
|
119
|
+
chatType: parsed.chatType,
|
|
120
|
+
reqId: parsed.reqId,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function buildWecomInboundPrompt(text, attachments = []) {
|
|
125
|
+
const normalizedText = text.trim();
|
|
126
|
+
if (attachments.length === 0) {
|
|
127
|
+
return normalizedText;
|
|
128
|
+
}
|
|
129
|
+
const lines = [
|
|
130
|
+
"[WeCom inbound attachments — ACTION REQUIRED]",
|
|
131
|
+
"Use the local attachment paths below when the user asks you to inspect or process them.",
|
|
132
|
+
];
|
|
133
|
+
attachments.forEach((attachment, index) => {
|
|
134
|
+
const metadata = [
|
|
135
|
+
`kind=${attachment.kind}`,
|
|
136
|
+
attachment.fileName ? `name=${attachment.fileName}` : "",
|
|
137
|
+
typeof attachment.sizeBytes === "number" ? `bytes=${attachment.sizeBytes}` : "",
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
|
|
140
|
+
});
|
|
141
|
+
return [normalizedText || "Received WeCom attachment(s).", lines.join("\n")]
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join("\n\n");
|
|
144
|
+
}
|
|
145
|
+
export function formatWecomVisibleText(text) {
|
|
146
|
+
return text
|
|
147
|
+
.replace(/\bWeChat\b/g, "WeCom")
|
|
148
|
+
.replace(/\bwechat-(codex|claude|opencode|pi|daemon|setup)\b/g, "wecom-$1");
|
|
149
|
+
}
|