cli-wechat-bridge 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +212 -232
  2. package/bin/_run-entry.mjs +20 -5
  3. package/bin/wecom-check-update.mjs +5 -0
  4. package/bin/wecom-claude.mjs +5 -0
  5. package/bin/wecom-codex.mjs +5 -0
  6. package/bin/wecom-daemon.mjs +5 -0
  7. package/bin/wecom-opencode.mjs +5 -0
  8. package/bin/wecom-pi.mjs +5 -0
  9. package/bin/wecom-setup.mjs +5 -0
  10. package/dist/bridge/bridge-adapters.claude.js +128 -4
  11. package/dist/bridge/bridge-adapters.codex.js +214 -23
  12. package/dist/bridge/bridge-adapters.core.js +2 -1
  13. package/dist/bridge/bridge-adapters.pi.js +1 -1
  14. package/dist/bridge/bridge-adapters.shared.js +2 -1
  15. package/dist/bridge/bridge-final-reply.js +2 -65
  16. package/dist/bridge/bridge-process-reaper.js +35 -2
  17. package/dist/bridge/bridge-state.js +11 -4
  18. package/dist/bridge/bridge-utils.js +4 -96
  19. package/dist/bridge/wechat-bridge.js +476 -206
  20. package/dist/bridge/wechat-forwarding.js +2 -58
  21. package/dist/bridge/wechat-outbound-queue.js +2 -0
  22. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  23. package/dist/channels/wechat/channel-message.js +24 -0
  24. package/dist/channels/wechat/index.js +5 -0
  25. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  26. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  27. package/dist/channels/wechat/wechat-outbound-queue.js +79 -0
  28. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  29. package/dist/channels/wecom/setup-cli.js +6 -0
  30. package/dist/channels/wecom/setup.js +176 -0
  31. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  32. package/dist/channels/wecom/wecom-config.js +66 -0
  33. package/dist/channels/wecom/wecom-media.js +141 -0
  34. package/dist/channels/wecom/wecom-message.js +149 -0
  35. package/dist/channels/wecom/wecom-transport.js +308 -0
  36. package/dist/companion/codex-remote-client.js +2 -1
  37. package/dist/companion/local-companion-start.js +49 -9
  38. package/dist/companion/local-companion.js +2 -1
  39. package/dist/core/bridge-control.js +32 -0
  40. package/dist/core/bridge-event-forwarder.js +50 -0
  41. package/dist/core/bridge-message-router.js +32 -0
  42. package/dist/core/channel-types.js +3 -0
  43. package/dist/core/direct-run.js +17 -0
  44. package/dist/core/inbound-message-claims.js +61 -0
  45. package/dist/core/text-utils.js +63 -0
  46. package/dist/daemon/daemon-link.js +3 -0
  47. package/dist/daemon/wechat-daemon.js +466 -172
  48. package/dist/i18n/messages-en.js +4 -2
  49. package/dist/i18n/messages-zh.js +4 -2
  50. package/dist/utils/doctor.js +30 -10
  51. package/dist/wechat/setup.js +113 -13
  52. package/package.json +117 -107
@@ -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
+ }
@@ -0,0 +1,308 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { generateReqId, WSAuthFailureError, WSClient, WSReconnectExhaustedError, } from "@wecom/aibot-node-sdk";
4
+ import { tryClaimChannelInboundMessage } from "../../core/inbound-message-claims.js";
5
+ import { downloadWecomInboundAttachments, } from "./wecom-media.js";
6
+ import { parseWecomInboundFrame, toWecomChannelInboundMessage, } from "./wecom-message.js";
7
+ import { WECOM_INBOUND_MESSAGE_CLAIMS_DIR, } from "./wecom-config.js";
8
+ const WECOM_TEXT_MAX_BYTES = 20_480;
9
+ const WECOM_TEXT_CHUNK_BYTES = 18_000;
10
+ const WECOM_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
11
+ const WECOM_VIDEO_MAX_BYTES = 10 * 1024 * 1024;
12
+ const WECOM_VOICE_MAX_BYTES = 2 * 1024 * 1024;
13
+ const WECOM_FILE_MAX_BYTES = 20 * 1024 * 1024;
14
+ function createDefaultClient(account, logger) {
15
+ return new WSClient({
16
+ botId: account.botId,
17
+ secret: account.secret,
18
+ maxReconnectAttempts: 10,
19
+ maxAuthFailureAttempts: 5,
20
+ heartbeatInterval: 30_000,
21
+ maxReplyQueueSize: 500,
22
+ logger: {
23
+ debug: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
24
+ info: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
25
+ warn: (message, ...args) => logger.log?.(`[wecom-sdk] WARN: ${message} ${args.join(" ")}`.trim()),
26
+ error: (message, ...args) => logger.error?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
27
+ },
28
+ });
29
+ }
30
+ function splitUtf8Text(text, maxBytes = WECOM_TEXT_CHUNK_BYTES) {
31
+ if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
32
+ return [text];
33
+ }
34
+ const chunks = [];
35
+ let current = "";
36
+ let bytes = 0;
37
+ for (const character of text) {
38
+ const nextBytes = Buffer.byteLength(character, "utf-8");
39
+ if (current && bytes + nextBytes > maxBytes) {
40
+ chunks.push(current);
41
+ current = "";
42
+ bytes = 0;
43
+ }
44
+ current += character;
45
+ bytes += nextBytes;
46
+ }
47
+ if (current) {
48
+ chunks.push(current);
49
+ }
50
+ return chunks;
51
+ }
52
+ function getChatType(target) {
53
+ return target.metadata?.chatType === "group" ? "group" : "direct";
54
+ }
55
+ function getReplyFrame(target) {
56
+ return target.opaqueRef
57
+ ? { headers: { req_id: target.opaqueRef } }
58
+ : null;
59
+ }
60
+ function inferMimeType(filePath) {
61
+ const ext = path.extname(filePath).toLowerCase();
62
+ const known = {
63
+ ".jpg": "image/jpeg",
64
+ ".jpeg": "image/jpeg",
65
+ ".png": "image/png",
66
+ ".gif": "image/gif",
67
+ ".amr": "audio/amr",
68
+ ".mp4": "video/mp4",
69
+ ".pdf": "application/pdf",
70
+ ".txt": "text/plain",
71
+ };
72
+ return known[ext] || "application/octet-stream";
73
+ }
74
+ function resolveOutboundMedia(attachment) {
75
+ const fileName = attachment.fileName || path.basename(attachment.path);
76
+ const size = fs.statSync(attachment.path).size;
77
+ const mimeType = attachment.mimeType || inferMimeType(attachment.path);
78
+ if (attachment.kind === "image" &&
79
+ size <= WECOM_IMAGE_MAX_BYTES &&
80
+ ["image/jpeg", "image/png", "image/gif"].includes(mimeType)) {
81
+ return { type: "image", maxBytes: WECOM_IMAGE_MAX_BYTES, fileName };
82
+ }
83
+ if (attachment.kind === "voice" &&
84
+ size <= WECOM_VOICE_MAX_BYTES &&
85
+ path.extname(fileName).toLowerCase() === ".amr") {
86
+ return { type: "voice", maxBytes: WECOM_VOICE_MAX_BYTES, fileName };
87
+ }
88
+ if (attachment.kind === "video" &&
89
+ size <= WECOM_VIDEO_MAX_BYTES &&
90
+ path.extname(fileName).toLowerCase() === ".mp4") {
91
+ return { type: "video", maxBytes: WECOM_VIDEO_MAX_BYTES, fileName };
92
+ }
93
+ return { type: "file", maxBytes: WECOM_FILE_MAX_BYTES, fileName };
94
+ }
95
+ export class WecomTransport {
96
+ account;
97
+ client;
98
+ logger;
99
+ claimsDir;
100
+ attachmentsDir;
101
+ fetchImpl;
102
+ streamStates = new Map();
103
+ inboundChains = new Map();
104
+ outputChain = Promise.resolve();
105
+ authenticated = false;
106
+ started = false;
107
+ onMessage = null;
108
+ onUnauthorized = null;
109
+ onFatal = null;
110
+ onConnected = null;
111
+ constructor(options) {
112
+ this.account = options.account;
113
+ this.logger = options.logger ?? {};
114
+ this.claimsDir = options.claimsDir ?? WECOM_INBOUND_MESSAGE_CLAIMS_DIR;
115
+ this.attachmentsDir = options.attachmentsDir;
116
+ this.fetchImpl = options.fetchImpl;
117
+ this.client = (options.clientFactory ?? createDefaultClient)(this.account, this.logger);
118
+ this.attachClientEvents();
119
+ }
120
+ attachClientEvents() {
121
+ this.client.on("authenticated", () => {
122
+ this.authenticated = true;
123
+ this.logger.log?.("wecom_authenticated");
124
+ void this.onConnected?.();
125
+ });
126
+ this.client.on("disconnected", (reason) => {
127
+ this.authenticated = false;
128
+ this.logger.log?.(`wecom_disconnected: reason=${reason}`);
129
+ });
130
+ this.client.on("reconnecting", (attempt) => {
131
+ this.logger.log?.(`wecom_reconnecting: attempt=${attempt}`);
132
+ });
133
+ this.client.on("event.disconnected_event", () => {
134
+ const error = new Error("The WeCom bot was connected by another process. This connection was stopped to avoid a reconnect loop.");
135
+ this.authenticated = false;
136
+ this.client.disconnect();
137
+ void this.onFatal?.(error);
138
+ });
139
+ this.client.on("error", (error) => {
140
+ this.logger.error?.(`wecom_transport_error: ${error.message}`);
141
+ if (error instanceof WSAuthFailureError ||
142
+ error instanceof WSReconnectExhaustedError) {
143
+ void this.onFatal?.(error);
144
+ }
145
+ });
146
+ this.client.on("message", (frame) => {
147
+ void this.handleInboundFrame(frame).catch((error) => {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ this.logger.error?.(`wecom_inbound_failed: ${message}`);
150
+ });
151
+ });
152
+ }
153
+ setHandlers(handlers) {
154
+ this.onMessage = handlers.onMessage;
155
+ this.onUnauthorized = handlers.onUnauthorized ?? null;
156
+ this.onFatal = handlers.onFatal ?? null;
157
+ this.onConnected = handlers.onConnected ?? null;
158
+ }
159
+ start() {
160
+ if (this.started) {
161
+ return;
162
+ }
163
+ this.started = true;
164
+ this.client.connect();
165
+ }
166
+ stop() {
167
+ if (!this.started) {
168
+ return;
169
+ }
170
+ this.started = false;
171
+ this.authenticated = false;
172
+ this.streamStates.clear();
173
+ this.client.disconnect();
174
+ }
175
+ get isConnected() {
176
+ return this.authenticated && this.client.isConnected;
177
+ }
178
+ async waitUntilConnected(timeoutMs = 15_000) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (Date.now() < deadline) {
181
+ if (this.isConnected) {
182
+ return;
183
+ }
184
+ await new Promise((resolve) => setTimeout(resolve, 50));
185
+ }
186
+ throw new Error("Timed out waiting for WeCom WebSocket authentication.");
187
+ }
188
+ async handleInboundFrame(frame) {
189
+ const parsed = parseWecomInboundFrame(frame);
190
+ if (!parsed) {
191
+ return;
192
+ }
193
+ if (parsed.senderId !== this.account.operatorUserId) {
194
+ await this.onUnauthorized?.(parsed.senderId, parsed.chatType);
195
+ return;
196
+ }
197
+ if (!tryClaimChannelInboundMessage(`${this.account.botId}|${parsed.id}`, {
198
+ claimsDir: this.claimsDir,
199
+ })) {
200
+ this.logger.log?.(`wecom_duplicate_skipped: msgid=${parsed.id}`);
201
+ return;
202
+ }
203
+ const queueKey = `${this.account.botId}:${parsed.conversationId}`;
204
+ const previous = this.inboundChains.get(queueKey) ?? Promise.resolve();
205
+ const deliver = async () => {
206
+ const downloaded = await downloadWecomInboundAttachments(parsed, {
207
+ attachmentsDir: this.attachmentsDir,
208
+ fetchImpl: this.fetchImpl,
209
+ });
210
+ const text = [parsed.text, ...downloaded.failureNotes]
211
+ .filter(Boolean)
212
+ .join("\n")
213
+ .trim();
214
+ await this.onMessage?.(toWecomChannelInboundMessage({ ...parsed, text }, downloaded.attachments, this.account.botId));
215
+ };
216
+ const next = previous.then(deliver, deliver);
217
+ this.inboundChains.set(queueKey, next);
218
+ try {
219
+ await next;
220
+ }
221
+ finally {
222
+ if (this.inboundChains.get(queueKey) === next) {
223
+ this.inboundChains.delete(queueKey);
224
+ }
225
+ }
226
+ }
227
+ queueOutput(action) {
228
+ const run = this.outputChain.then(action);
229
+ this.outputChain = run.then(() => undefined, () => undefined);
230
+ return run;
231
+ }
232
+ async sendActiveText(target, text) {
233
+ for (const chunk of splitUtf8Text(text)) {
234
+ if (Buffer.byteLength(chunk, "utf-8") > WECOM_TEXT_MAX_BYTES) {
235
+ throw new Error("WeCom text chunk exceeds the 20480-byte platform limit.");
236
+ }
237
+ await this.client.sendMessage(target.recipientId, {
238
+ msgtype: "markdown",
239
+ markdown: { content: chunk },
240
+ });
241
+ }
242
+ }
243
+ async sendText(target, text, kind) {
244
+ const normalized = text.trim();
245
+ if (!normalized) {
246
+ return true;
247
+ }
248
+ return this.queueOutput(async () => {
249
+ if (!this.isConnected) {
250
+ throw new Error("WeCom WebSocket is not connected.");
251
+ }
252
+ const chunks = splitUtf8Text(normalized);
253
+ const replyFrame = getReplyFrame(target);
254
+ const canUseStream = Boolean(replyFrame) && (kind === "thinking" || kind === "final_reply");
255
+ if (!canUseStream) {
256
+ await this.sendActiveText(target, normalized);
257
+ return true;
258
+ }
259
+ const reqId = target.opaqueRef;
260
+ const state = this.streamStates.get(reqId) ?? {
261
+ streamId: generateReqId("stream"),
262
+ finished: false,
263
+ };
264
+ this.streamStates.set(reqId, state);
265
+ try {
266
+ if (kind === "thinking") {
267
+ const result = await this.client.replyStreamNonBlocking(replyFrame, state.streamId, chunks[0], false);
268
+ return result !== "skipped";
269
+ }
270
+ await this.client.replyStream(replyFrame, state.streamId, chunks[0], true);
271
+ state.finished = true;
272
+ this.streamStates.delete(reqId);
273
+ for (const chunk of chunks.slice(1)) {
274
+ await this.sendActiveText(target, chunk);
275
+ }
276
+ return true;
277
+ }
278
+ catch (error) {
279
+ if (kind !== "final_reply") {
280
+ throw error;
281
+ }
282
+ this.streamStates.delete(reqId);
283
+ this.logger.log?.(`wecom_stream_fallback: reqid=${reqId} chat_type=${getChatType(target)}`);
284
+ await this.sendActiveText(target, normalized);
285
+ return true;
286
+ }
287
+ });
288
+ }
289
+ async sendAttachment(target, attachment) {
290
+ await this.queueOutput(async () => {
291
+ if (!this.isConnected) {
292
+ throw new Error("WeCom WebSocket is not connected.");
293
+ }
294
+ const media = resolveOutboundMedia(attachment);
295
+ const buffer = fs.readFileSync(attachment.path);
296
+ if (buffer.length > media.maxBytes) {
297
+ throw new Error(`${media.type} attachment exceeds the ${Math.round(media.maxBytes / 1024 / 1024)} MB WeCom limit`);
298
+ }
299
+ const uploaded = await this.client.uploadMedia(buffer, {
300
+ type: media.type,
301
+ filename: media.fileName,
302
+ });
303
+ await this.client.sendMediaMessage(target.recipientId, media.type, uploaded.media_id, media.type === "video"
304
+ ? { title: media.fileName, description: "" }
305
+ : undefined);
306
+ });
307
+ }
308
+ }
@@ -9,6 +9,7 @@ import { CODEX_VISIBLE_CONTROL_HOST, parseCodexVisibleSwitchRequest, sendCodexVi
9
9
  import { clearLocalCompanionOccupancy, readLocalCompanionEndpoint, updateCodexVisibleControl, updateLocalCompanionOccupancy, } from "./local-companion-link.js";
10
10
  import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
11
11
  import { CODEX_REMOTE_AUTH_TOKEN_ENV } from "../runtime/runtime-types.js";
12
+ import { isDirectModuleRun } from "../core/direct-run.js";
12
13
  const CODEX_VISIBLE_SWITCH_SETTLE_MS = 500;
13
14
  const CODEX_VISIBLE_CHILD_STOP_TIMEOUT_MS = 2_000;
14
15
  function log(message) {
@@ -388,7 +389,7 @@ async function main() {
388
389
  const exitCode = await runCodexRemoteClient(options);
389
390
  process.exit(exitCode);
390
391
  }
391
- const isDirectRun = Boolean(import.meta.main);
392
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
392
393
  if (isDirectRun) {
393
394
  main().catch((error) => {
394
395
  log(error instanceof Error ? error.message : String(error));