@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.
- package/LICENSE.txt +661 -661
- package/README.md +434 -427
- package/bin/_run-entry.mjs +47 -9
- package/bin/wechat-check-update.mjs +6 -1
- package/bin/wechat-claude.mjs +6 -1
- package/bin/wechat-codex.mjs +6 -1
- package/bin/wechat-daemon.mjs +6 -1
- package/bin/wechat-opencode.mjs +6 -1
- package/bin/wechat-pi.mjs +6 -1
- package/bin/wechat-setup.mjs +6 -1
- package/bin/wecom-check-update.mjs +10 -0
- package/bin/wecom-claude.mjs +10 -0
- package/bin/wecom-codex.mjs +10 -0
- package/bin/wecom-daemon.mjs +10 -0
- package/bin/wecom-opencode.mjs +10 -0
- package/bin/wecom-pi.mjs +10 -0
- package/bin/wecom-setup.mjs +10 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +163 -5
- package/dist/bridge/bridge-adapters.codex.js +331 -29
- package/dist/bridge/bridge-adapters.core.js +27 -2
- package/dist/bridge/bridge-adapters.opencode.js +156 -1
- package/dist/bridge/bridge-adapters.pi.js +46 -1
- package/dist/bridge/bridge-adapters.shared.js +8 -1
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +8 -65
- package/dist/bridge/bridge-process-reaper.js +41 -2
- package/dist/bridge/bridge-state.js +17 -4
- package/dist/bridge/bridge-utils.js +10 -127
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +259 -0
- package/dist/bridge/wechat-bridge.js +520 -274
- package/dist/bridge/wechat-forwarding.js +8 -58
- package/dist/bridge/wechat-outbound-queue.js +8 -0
- package/dist/channels/wechat/bridge-final-reply.js +56 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +11 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +97 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
- package/dist/channels/wecom/bridge-final-reply.js +32 -0
- package/dist/channels/wecom/setup-cli.js +12 -0
- package/dist/channels/wecom/setup.js +182 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +72 -0
- package/dist/channels/wecom/wecom-driver.js +106 -0
- package/dist/channels/wecom/wecom-media.js +147 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +314 -0
- package/dist/commands/check-update.js +6 -0
- package/dist/companion/codex-remote-client.js +30 -3
- package/dist/companion/codex-visible-client-link.js +107 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +62 -11
- package/dist/companion/local-companion.js +25 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
- package/dist/companion/pi-tui-bridge-extension.js +62 -3
- package/dist/core/bridge-control.js +42 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/channel-types.js +3 -0
- package/dist/core/conversation-routing.js +58 -0
- package/dist/core/direct-run.js +23 -0
- package/dist/core/inbound-message-claims.js +67 -0
- package/dist/core/outbound-target.js +32 -0
- package/dist/core/text-utils.js +69 -0
- package/dist/core/turn-coordinator.js +85 -0
- package/dist/daemon/daemon-link.js +9 -0
- package/dist/daemon/emoji-bindings.js +6 -0
- package/dist/daemon/wechat-daemon.js +827 -209
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +11 -3
- package/dist/i18n/messages-zh.js +11 -3
- package/dist/runtime/create-runtime-host.js +6 -0
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/atomic-file.js +6 -0
- package/dist/utils/doctor.js +36 -10
- package/dist/utils/version-checker.js +17 -11
- package/dist/wechat/channel-config.js +6 -0
- package/dist/wechat/setup.js +119 -13
- package/dist/wechat/wechat-channel.js +6 -0
- package/dist/wechat/wechat-transport.js +169 -58
- package/package.json +14 -4
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -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,314 @@
|
|
|
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 { generateReqId, WSAuthFailureError, WSClient, WSReconnectExhaustedError, } from "@wecom/aibot-node-sdk";
|
|
10
|
+
import { tryClaimChannelInboundMessage } from "../../core/inbound-message-claims.js";
|
|
11
|
+
import { downloadWecomInboundAttachments, } from "./wecom-media.js";
|
|
12
|
+
import { parseWecomInboundFrame, toWecomChannelInboundMessage, } from "./wecom-message.js";
|
|
13
|
+
import { WECOM_INBOUND_MESSAGE_CLAIMS_DIR, } from "./wecom-config.js";
|
|
14
|
+
const WECOM_TEXT_MAX_BYTES = 20_480;
|
|
15
|
+
const WECOM_TEXT_CHUNK_BYTES = 18_000;
|
|
16
|
+
const WECOM_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
const WECOM_VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
18
|
+
const WECOM_VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const WECOM_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
20
|
+
function createDefaultClient(account, logger) {
|
|
21
|
+
return new WSClient({
|
|
22
|
+
botId: account.botId,
|
|
23
|
+
secret: account.secret,
|
|
24
|
+
maxReconnectAttempts: 10,
|
|
25
|
+
maxAuthFailureAttempts: 5,
|
|
26
|
+
heartbeatInterval: 30_000,
|
|
27
|
+
maxReplyQueueSize: 500,
|
|
28
|
+
logger: {
|
|
29
|
+
debug: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
30
|
+
info: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
31
|
+
warn: (message, ...args) => logger.log?.(`[wecom-sdk] WARN: ${message} ${args.join(" ")}`.trim()),
|
|
32
|
+
error: (message, ...args) => logger.error?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function splitUtf8Text(text, maxBytes = WECOM_TEXT_CHUNK_BYTES) {
|
|
37
|
+
if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
|
|
38
|
+
return [text];
|
|
39
|
+
}
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let current = "";
|
|
42
|
+
let bytes = 0;
|
|
43
|
+
for (const character of text) {
|
|
44
|
+
const nextBytes = Buffer.byteLength(character, "utf-8");
|
|
45
|
+
if (current && bytes + nextBytes > maxBytes) {
|
|
46
|
+
chunks.push(current);
|
|
47
|
+
current = "";
|
|
48
|
+
bytes = 0;
|
|
49
|
+
}
|
|
50
|
+
current += character;
|
|
51
|
+
bytes += nextBytes;
|
|
52
|
+
}
|
|
53
|
+
if (current) {
|
|
54
|
+
chunks.push(current);
|
|
55
|
+
}
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
58
|
+
function getChatType(target) {
|
|
59
|
+
return target.metadata?.chatType === "group" ? "group" : "direct";
|
|
60
|
+
}
|
|
61
|
+
function getReplyFrame(target) {
|
|
62
|
+
return target.opaqueRef
|
|
63
|
+
? { headers: { req_id: target.opaqueRef } }
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
function inferMimeType(filePath) {
|
|
67
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
68
|
+
const known = {
|
|
69
|
+
".jpg": "image/jpeg",
|
|
70
|
+
".jpeg": "image/jpeg",
|
|
71
|
+
".png": "image/png",
|
|
72
|
+
".gif": "image/gif",
|
|
73
|
+
".amr": "audio/amr",
|
|
74
|
+
".mp4": "video/mp4",
|
|
75
|
+
".pdf": "application/pdf",
|
|
76
|
+
".txt": "text/plain",
|
|
77
|
+
};
|
|
78
|
+
return known[ext] || "application/octet-stream";
|
|
79
|
+
}
|
|
80
|
+
function resolveOutboundMedia(attachment) {
|
|
81
|
+
const fileName = attachment.fileName || path.basename(attachment.path);
|
|
82
|
+
const size = fs.statSync(attachment.path).size;
|
|
83
|
+
const mimeType = attachment.mimeType || inferMimeType(attachment.path);
|
|
84
|
+
if (attachment.kind === "image" &&
|
|
85
|
+
size <= WECOM_IMAGE_MAX_BYTES &&
|
|
86
|
+
["image/jpeg", "image/png", "image/gif"].includes(mimeType)) {
|
|
87
|
+
return { type: "image", maxBytes: WECOM_IMAGE_MAX_BYTES, fileName };
|
|
88
|
+
}
|
|
89
|
+
if (attachment.kind === "voice" &&
|
|
90
|
+
size <= WECOM_VOICE_MAX_BYTES &&
|
|
91
|
+
path.extname(fileName).toLowerCase() === ".amr") {
|
|
92
|
+
return { type: "voice", maxBytes: WECOM_VOICE_MAX_BYTES, fileName };
|
|
93
|
+
}
|
|
94
|
+
if (attachment.kind === "video" &&
|
|
95
|
+
size <= WECOM_VIDEO_MAX_BYTES &&
|
|
96
|
+
path.extname(fileName).toLowerCase() === ".mp4") {
|
|
97
|
+
return { type: "video", maxBytes: WECOM_VIDEO_MAX_BYTES, fileName };
|
|
98
|
+
}
|
|
99
|
+
return { type: "file", maxBytes: WECOM_FILE_MAX_BYTES, fileName };
|
|
100
|
+
}
|
|
101
|
+
export class WecomTransport {
|
|
102
|
+
account;
|
|
103
|
+
client;
|
|
104
|
+
logger;
|
|
105
|
+
claimsDir;
|
|
106
|
+
attachmentsDir;
|
|
107
|
+
fetchImpl;
|
|
108
|
+
streamStates = new Map();
|
|
109
|
+
inboundChains = new Map();
|
|
110
|
+
outputChain = Promise.resolve();
|
|
111
|
+
authenticated = false;
|
|
112
|
+
started = false;
|
|
113
|
+
onMessage = null;
|
|
114
|
+
onUnauthorized = null;
|
|
115
|
+
onFatal = null;
|
|
116
|
+
onConnected = null;
|
|
117
|
+
constructor(options) {
|
|
118
|
+
this.account = options.account;
|
|
119
|
+
this.logger = options.logger ?? {};
|
|
120
|
+
this.claimsDir = options.claimsDir ?? WECOM_INBOUND_MESSAGE_CLAIMS_DIR;
|
|
121
|
+
this.attachmentsDir = options.attachmentsDir;
|
|
122
|
+
this.fetchImpl = options.fetchImpl;
|
|
123
|
+
this.client = (options.clientFactory ?? createDefaultClient)(this.account, this.logger);
|
|
124
|
+
this.attachClientEvents();
|
|
125
|
+
}
|
|
126
|
+
attachClientEvents() {
|
|
127
|
+
this.client.on("authenticated", () => {
|
|
128
|
+
this.authenticated = true;
|
|
129
|
+
this.logger.log?.("wecom_authenticated");
|
|
130
|
+
void this.onConnected?.();
|
|
131
|
+
});
|
|
132
|
+
this.client.on("disconnected", (reason) => {
|
|
133
|
+
this.authenticated = false;
|
|
134
|
+
this.logger.log?.(`wecom_disconnected: reason=${reason}`);
|
|
135
|
+
});
|
|
136
|
+
this.client.on("reconnecting", (attempt) => {
|
|
137
|
+
this.logger.log?.(`wecom_reconnecting: attempt=${attempt}`);
|
|
138
|
+
});
|
|
139
|
+
this.client.on("event.disconnected_event", () => {
|
|
140
|
+
const error = new Error("The WeCom bot was connected by another process. This connection was stopped to avoid a reconnect loop.");
|
|
141
|
+
this.authenticated = false;
|
|
142
|
+
this.client.disconnect();
|
|
143
|
+
void this.onFatal?.(error);
|
|
144
|
+
});
|
|
145
|
+
this.client.on("error", (error) => {
|
|
146
|
+
this.logger.error?.(`wecom_transport_error: ${error.message}`);
|
|
147
|
+
if (error instanceof WSAuthFailureError ||
|
|
148
|
+
error instanceof WSReconnectExhaustedError) {
|
|
149
|
+
void this.onFatal?.(error);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
this.client.on("message", (frame) => {
|
|
153
|
+
void this.handleInboundFrame(frame).catch((error) => {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
this.logger.error?.(`wecom_inbound_failed: ${message}`);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
setHandlers(handlers) {
|
|
160
|
+
this.onMessage = handlers.onMessage;
|
|
161
|
+
this.onUnauthorized = handlers.onUnauthorized ?? null;
|
|
162
|
+
this.onFatal = handlers.onFatal ?? null;
|
|
163
|
+
this.onConnected = handlers.onConnected ?? null;
|
|
164
|
+
}
|
|
165
|
+
start() {
|
|
166
|
+
if (this.started) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.started = true;
|
|
170
|
+
this.client.connect();
|
|
171
|
+
}
|
|
172
|
+
stop() {
|
|
173
|
+
if (!this.started) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.started = false;
|
|
177
|
+
this.authenticated = false;
|
|
178
|
+
this.streamStates.clear();
|
|
179
|
+
this.client.disconnect();
|
|
180
|
+
}
|
|
181
|
+
get isConnected() {
|
|
182
|
+
return this.authenticated && this.client.isConnected;
|
|
183
|
+
}
|
|
184
|
+
async waitUntilConnected(timeoutMs = 15_000) {
|
|
185
|
+
const deadline = Date.now() + timeoutMs;
|
|
186
|
+
while (Date.now() < deadline) {
|
|
187
|
+
if (this.isConnected) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
191
|
+
}
|
|
192
|
+
throw new Error("Timed out waiting for WeCom WebSocket authentication.");
|
|
193
|
+
}
|
|
194
|
+
async handleInboundFrame(frame) {
|
|
195
|
+
const parsed = parseWecomInboundFrame(frame);
|
|
196
|
+
if (!parsed) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (parsed.senderId !== this.account.operatorUserId) {
|
|
200
|
+
await this.onUnauthorized?.(parsed.senderId, parsed.chatType);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!tryClaimChannelInboundMessage(`${this.account.botId}|${parsed.id}`, {
|
|
204
|
+
claimsDir: this.claimsDir,
|
|
205
|
+
})) {
|
|
206
|
+
this.logger.log?.(`wecom_duplicate_skipped: msgid=${parsed.id}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const queueKey = `${this.account.botId}:${parsed.conversationId}`;
|
|
210
|
+
const previous = this.inboundChains.get(queueKey) ?? Promise.resolve();
|
|
211
|
+
const deliver = async () => {
|
|
212
|
+
const downloaded = await downloadWecomInboundAttachments(parsed, {
|
|
213
|
+
attachmentsDir: this.attachmentsDir,
|
|
214
|
+
fetchImpl: this.fetchImpl,
|
|
215
|
+
});
|
|
216
|
+
const text = [parsed.text, ...downloaded.failureNotes]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join("\n")
|
|
219
|
+
.trim();
|
|
220
|
+
await this.onMessage?.(toWecomChannelInboundMessage({ ...parsed, text }, downloaded.attachments, this.account.botId));
|
|
221
|
+
};
|
|
222
|
+
const next = previous.then(deliver, deliver);
|
|
223
|
+
this.inboundChains.set(queueKey, next);
|
|
224
|
+
try {
|
|
225
|
+
await next;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (this.inboundChains.get(queueKey) === next) {
|
|
229
|
+
this.inboundChains.delete(queueKey);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
queueOutput(action) {
|
|
234
|
+
const run = this.outputChain.then(action);
|
|
235
|
+
this.outputChain = run.then(() => undefined, () => undefined);
|
|
236
|
+
return run;
|
|
237
|
+
}
|
|
238
|
+
async sendActiveText(target, text) {
|
|
239
|
+
for (const chunk of splitUtf8Text(text)) {
|
|
240
|
+
if (Buffer.byteLength(chunk, "utf-8") > WECOM_TEXT_MAX_BYTES) {
|
|
241
|
+
throw new Error("WeCom text chunk exceeds the 20480-byte platform limit.");
|
|
242
|
+
}
|
|
243
|
+
await this.client.sendMessage(target.recipientId, {
|
|
244
|
+
msgtype: "markdown",
|
|
245
|
+
markdown: { content: chunk },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async sendText(target, text, kind) {
|
|
250
|
+
const normalized = text.trim();
|
|
251
|
+
if (!normalized) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return this.queueOutput(async () => {
|
|
255
|
+
if (!this.isConnected) {
|
|
256
|
+
throw new Error("WeCom WebSocket is not connected.");
|
|
257
|
+
}
|
|
258
|
+
const chunks = splitUtf8Text(normalized);
|
|
259
|
+
const replyFrame = getReplyFrame(target);
|
|
260
|
+
const canUseStream = Boolean(replyFrame) && (kind === "thinking" || kind === "final_reply");
|
|
261
|
+
if (!canUseStream) {
|
|
262
|
+
await this.sendActiveText(target, normalized);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
const reqId = target.opaqueRef;
|
|
266
|
+
const state = this.streamStates.get(reqId) ?? {
|
|
267
|
+
streamId: generateReqId("stream"),
|
|
268
|
+
finished: false,
|
|
269
|
+
};
|
|
270
|
+
this.streamStates.set(reqId, state);
|
|
271
|
+
try {
|
|
272
|
+
if (kind === "thinking") {
|
|
273
|
+
const result = await this.client.replyStreamNonBlocking(replyFrame, state.streamId, chunks[0], false);
|
|
274
|
+
return result !== "skipped";
|
|
275
|
+
}
|
|
276
|
+
await this.client.replyStream(replyFrame, state.streamId, chunks[0], true);
|
|
277
|
+
state.finished = true;
|
|
278
|
+
this.streamStates.delete(reqId);
|
|
279
|
+
for (const chunk of chunks.slice(1)) {
|
|
280
|
+
await this.sendActiveText(target, chunk);
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (kind !== "final_reply") {
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
this.streamStates.delete(reqId);
|
|
289
|
+
this.logger.log?.(`wecom_stream_fallback: reqid=${reqId} chat_type=${getChatType(target)}`);
|
|
290
|
+
await this.sendActiveText(target, normalized);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async sendAttachment(target, attachment) {
|
|
296
|
+
await this.queueOutput(async () => {
|
|
297
|
+
if (!this.isConnected) {
|
|
298
|
+
throw new Error("WeCom WebSocket is not connected.");
|
|
299
|
+
}
|
|
300
|
+
const media = resolveOutboundMedia(attachment);
|
|
301
|
+
const buffer = fs.readFileSync(attachment.path);
|
|
302
|
+
if (buffer.length > media.maxBytes) {
|
|
303
|
+
throw new Error(`${media.type} attachment exceeds the ${Math.round(media.maxBytes / 1024 / 1024)} MB WeCom limit`);
|
|
304
|
+
}
|
|
305
|
+
const uploaded = await this.client.uploadMedia(buffer, {
|
|
306
|
+
type: media.type,
|
|
307
|
+
filename: media.fileName,
|
|
308
|
+
});
|
|
309
|
+
await this.client.sendMediaMessage(target.recipientId, media.type, uploaded.media_id, media.type === "video"
|
|
310
|
+
? { title: media.fileName, description: "" }
|
|
311
|
+
: undefined);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
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.
|
|
2
8
|
import { checkForUpdate, getCurrentVersion, } from "../utils/version-checker.js";
|
|
3
9
|
async function main() {
|
|
4
10
|
const currentVersion = await getCurrentVersion();
|
|
@@ -1,14 +1,21 @@
|
|
|
1
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.
|
|
2
8
|
import crypto from "node:crypto";
|
|
3
9
|
import { spawn } from "node:child_process";
|
|
4
10
|
import net from "node:net";
|
|
5
11
|
import path from "node:path";
|
|
6
12
|
import { assertNoReservedExtraCliArgs, buildCliEnvironment, buildCodexCliArgs, resolveSpawnTarget, } from "../bridge/bridge-adapters.shared.js";
|
|
7
13
|
import { killProcessTreeSync } from "../bridge/bridge-process-reaper.js";
|
|
8
|
-
import { CODEX_VISIBLE_CONTROL_HOST,
|
|
14
|
+
import { CODEX_VISIBLE_CONTROL_HOST, parseCodexVisibleControlRequest, sendCodexVisibleShutdownResponse, sendCodexVisibleSwitchResponse, } from "./codex-visible-client-link.js";
|
|
9
15
|
import { clearLocalCompanionOccupancy, readLocalCompanionEndpoint, updateCodexVisibleControl, updateLocalCompanionOccupancy, } from "./local-companion-link.js";
|
|
10
16
|
import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
|
|
11
17
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV } from "../runtime/runtime-types.js";
|
|
18
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
12
19
|
const CODEX_VISIBLE_SWITCH_SETTLE_MS = 500;
|
|
13
20
|
const CODEX_VISIBLE_CHILD_STOP_TIMEOUT_MS = 2_000;
|
|
14
21
|
function log(message) {
|
|
@@ -301,11 +308,20 @@ export class CodexVisibleClientSupervisor {
|
|
|
301
308
|
socket.destroy();
|
|
302
309
|
return;
|
|
303
310
|
}
|
|
304
|
-
const request =
|
|
311
|
+
const request = parseCodexVisibleControlRequest(parsed);
|
|
305
312
|
if (!request || request.token !== this.controlToken) {
|
|
306
313
|
socket.destroy();
|
|
307
314
|
return;
|
|
308
315
|
}
|
|
316
|
+
if (request.type === "shutdown") {
|
|
317
|
+
sendCodexVisibleShutdownResponse(socket, {
|
|
318
|
+
type: "shutdown_result",
|
|
319
|
+
id: request.id,
|
|
320
|
+
ok: true,
|
|
321
|
+
});
|
|
322
|
+
void this.shutdownFromControl();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
309
325
|
void this.switchThread(request.threadId).then(() => {
|
|
310
326
|
sendCodexVisibleSwitchResponse(socket, {
|
|
311
327
|
type: "switch_thread_result",
|
|
@@ -339,6 +355,17 @@ export class CodexVisibleClientSupervisor {
|
|
|
339
355
|
this.controlServer = server;
|
|
340
356
|
this.controlPort = address.port;
|
|
341
357
|
}
|
|
358
|
+
async shutdownFromControl() {
|
|
359
|
+
await this.switchChain.catch(() => undefined);
|
|
360
|
+
if (this.runSettled) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const child = this.child;
|
|
364
|
+
if (child) {
|
|
365
|
+
await this.stopChild(child);
|
|
366
|
+
}
|
|
367
|
+
this.finishRun(undefined, 0);
|
|
368
|
+
}
|
|
342
369
|
async closeControlServer() {
|
|
343
370
|
const server = this.controlServer;
|
|
344
371
|
this.controlServer = null;
|
|
@@ -388,7 +415,7 @@ async function main() {
|
|
|
388
415
|
const exitCode = await runCodexRemoteClient(options);
|
|
389
416
|
process.exit(exitCode);
|
|
390
417
|
}
|
|
391
|
-
const isDirectRun =
|
|
418
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
392
419
|
if (isDirectRun) {
|
|
393
420
|
main().catch((error) => {
|
|
394
421
|
log(error instanceof Error ? error.message : String(error));
|
|
@@ -1,3 +1,9 @@
|
|
|
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.
|
|
1
7
|
import crypto from "node:crypto";
|
|
2
8
|
import net from "node:net";
|
|
3
9
|
import { readLocalCompanionEndpoint, } from "./local-companion-link.js";
|
|
@@ -23,12 +29,37 @@ export function parseCodexVisibleSwitchRequest(value) {
|
|
|
23
29
|
threadId: value.threadId,
|
|
24
30
|
};
|
|
25
31
|
}
|
|
32
|
+
export function parseCodexVisibleControlRequest(value) {
|
|
33
|
+
const switchRequest = parseCodexVisibleSwitchRequest(value);
|
|
34
|
+
if (switchRequest) {
|
|
35
|
+
return switchRequest;
|
|
36
|
+
}
|
|
37
|
+
if (!isRecord(value)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (value.type !== "shutdown" ||
|
|
41
|
+
typeof value.id !== "string" ||
|
|
42
|
+
typeof value.token !== "string") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
type: "shutdown",
|
|
47
|
+
id: value.id,
|
|
48
|
+
token: value.token,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
26
51
|
export function sendCodexVisibleSwitchResponse(socket, response) {
|
|
27
52
|
if (socket.destroyed || socket.writableEnded) {
|
|
28
53
|
return;
|
|
29
54
|
}
|
|
30
55
|
socket.end(`${JSON.stringify(response)}\n`);
|
|
31
56
|
}
|
|
57
|
+
export function sendCodexVisibleShutdownResponse(socket, response) {
|
|
58
|
+
if (socket.destroyed || socket.writableEnded) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
62
|
+
}
|
|
32
63
|
function resolveCodexControlEndpoint(cwd, instanceId) {
|
|
33
64
|
const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
|
|
34
65
|
if (!endpoint || endpoint.kind !== "codex" || endpoint.instanceId !== instanceId) {
|
|
@@ -120,3 +151,79 @@ export async function requestCodexVisibleThreadSwitch(params) {
|
|
|
120
151
|
});
|
|
121
152
|
});
|
|
122
153
|
}
|
|
154
|
+
export async function requestCodexVisibleClientShutdown(params) {
|
|
155
|
+
const endpoint = resolveCodexControlEndpoint(params.cwd, params.instanceId);
|
|
156
|
+
const requestId = crypto.randomUUID();
|
|
157
|
+
const timeoutMs = params.timeoutMs ?? 5_000;
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
const socket = net.connect({
|
|
160
|
+
host: CODEX_VISIBLE_CONTROL_HOST,
|
|
161
|
+
port: endpoint.codexControlPort,
|
|
162
|
+
});
|
|
163
|
+
let buffer = "";
|
|
164
|
+
let settled = false;
|
|
165
|
+
const finish = (error) => {
|
|
166
|
+
if (settled) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
settled = true;
|
|
170
|
+
socket.destroy();
|
|
171
|
+
if (error) {
|
|
172
|
+
reject(error);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
socket.setEncoding("utf8");
|
|
179
|
+
socket.setTimeout(timeoutMs);
|
|
180
|
+
socket.once("connect", () => {
|
|
181
|
+
const request = {
|
|
182
|
+
type: "shutdown",
|
|
183
|
+
id: requestId,
|
|
184
|
+
token: endpoint.codexControlToken,
|
|
185
|
+
};
|
|
186
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
187
|
+
});
|
|
188
|
+
socket.on("data", (chunk) => {
|
|
189
|
+
buffer += chunk;
|
|
190
|
+
if (buffer.length > 65_536) {
|
|
191
|
+
finish(new Error("The visible Codex client returned an oversized shutdown response."));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
195
|
+
if (newlineIndex < 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(line);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
finish(new Error("The visible Codex client returned an invalid shutdown response."));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!isRecord(parsed) || parsed.type !== "shutdown_result" || parsed.id !== requestId) {
|
|
208
|
+
finish(new Error("The visible Codex client returned an unexpected shutdown response."));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (parsed.ok !== true) {
|
|
212
|
+
finish(new Error(typeof parsed.error === "string"
|
|
213
|
+
? parsed.error
|
|
214
|
+
: "The visible Codex client could not shut down."));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
finish();
|
|
218
|
+
});
|
|
219
|
+
socket.once("timeout", () => {
|
|
220
|
+
finish(new Error("Timed out waiting for the visible Codex client to shut down."));
|
|
221
|
+
});
|
|
222
|
+
socket.once("error", (error) => finish(error));
|
|
223
|
+
socket.once("close", () => {
|
|
224
|
+
if (!settled) {
|
|
225
|
+
finish(new Error("The visible Codex client closed the shutdown connection."));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
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.
|
|
1
7
|
import crypto from "node:crypto";
|
|
2
8
|
import fs from "node:fs";
|
|
3
9
|
import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
|