qq-codex-bridge 0.1.2 → 0.1.4
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/.env.example +62 -0
- package/README.md +232 -287
- package/bin/chatgpt-desktop.js +2 -0
- package/bin/qq-codex-weixin-gateway.js +14 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
- package/dist/apps/bridge-daemon/src/cli.js +5 -1
- package/dist/apps/bridge-daemon/src/config.js +168 -37
- package/dist/apps/bridge-daemon/src/http-server.js +23 -11
- package/dist/apps/bridge-daemon/src/main.js +163 -29
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
- package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
- package/dist/apps/weixin-gateway/src/cli.js +446 -0
- package/dist/apps/weixin-gateway/src/config.js +135 -0
- package/dist/apps/weixin-gateway/src/dev.js +2 -0
- package/dist/apps/weixin-gateway/src/message-store.js +50 -0
- package/dist/apps/weixin-gateway/src/server.js +216 -0
- package/dist/apps/weixin-gateway/src/state.js +163 -0
- package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
- package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
- package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
- package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
- package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
- package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
- package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
- package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
- package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
- package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
- package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
- package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
- package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
- package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
- package/dist/packages/ports/src/chat.js +1 -0
- package/dist/packages/store/src/session-repo.js +16 -3
- package/dist/packages/store/src/sqlite.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { MediaArtifactKind } from "../../../packages/domain/src/message.js";
|
|
6
|
+
const WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
7
|
+
export class WeixinClient {
|
|
8
|
+
options;
|
|
9
|
+
fetchFn;
|
|
10
|
+
headersUin = Buffer.from(String(crypto.randomBytes(4).readUInt32BE(0)), "utf8").toString("base64");
|
|
11
|
+
stopped = false;
|
|
12
|
+
runningPromise = null;
|
|
13
|
+
activePollController = null;
|
|
14
|
+
ready = false;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
18
|
+
}
|
|
19
|
+
get accountId() {
|
|
20
|
+
return this.options.accountId;
|
|
21
|
+
}
|
|
22
|
+
get baseUrl() {
|
|
23
|
+
return this.options.baseUrl;
|
|
24
|
+
}
|
|
25
|
+
get token() {
|
|
26
|
+
return this.options.token;
|
|
27
|
+
}
|
|
28
|
+
async connect() {
|
|
29
|
+
if (this.runningPromise) {
|
|
30
|
+
return this.runningPromise;
|
|
31
|
+
}
|
|
32
|
+
this.stopped = false;
|
|
33
|
+
this.runningPromise = (async () => {
|
|
34
|
+
while (!this.stopped) {
|
|
35
|
+
try {
|
|
36
|
+
await this.pollOnce();
|
|
37
|
+
this.ready = true;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.ready = false;
|
|
41
|
+
if (this.stopped) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (error?.name === "AbortError") {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
console.warn("[weixin-gateway] poll failed", {
|
|
48
|
+
error: error instanceof Error ? error.message : String(error)
|
|
49
|
+
});
|
|
50
|
+
await sleep(2000);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})().finally(() => {
|
|
54
|
+
this.runningPromise = null;
|
|
55
|
+
this.ready = false;
|
|
56
|
+
});
|
|
57
|
+
return this.runningPromise;
|
|
58
|
+
}
|
|
59
|
+
async close() {
|
|
60
|
+
this.stopped = true;
|
|
61
|
+
this.ready = false;
|
|
62
|
+
this.activePollController?.abort();
|
|
63
|
+
this.activePollController = null;
|
|
64
|
+
await this.runningPromise?.catch(() => undefined);
|
|
65
|
+
}
|
|
66
|
+
async sendTextMessage(peerId, text, contextToken) {
|
|
67
|
+
await this.sendMessage({
|
|
68
|
+
peerId,
|
|
69
|
+
chatType: "c2c",
|
|
70
|
+
content: text,
|
|
71
|
+
contextToken
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async sendMessage(target) {
|
|
75
|
+
const normalizedPeerId = sanitizeText(target.peerId);
|
|
76
|
+
if (!normalizedPeerId) {
|
|
77
|
+
throw new Error("weixin target user id is missing");
|
|
78
|
+
}
|
|
79
|
+
const content = sanitizeText(target.content);
|
|
80
|
+
const mediaArtifacts = target.mediaArtifacts ?? [];
|
|
81
|
+
if (!content && mediaArtifacts.length === 0) {
|
|
82
|
+
throw new Error("weixin outbound payload requires text or media artifacts");
|
|
83
|
+
}
|
|
84
|
+
const itemList = [];
|
|
85
|
+
if (content) {
|
|
86
|
+
itemList.push({
|
|
87
|
+
type: 1,
|
|
88
|
+
text_item: { text: content }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
for (const item of await this.buildMediaItems(normalizedPeerId, mediaArtifacts)) {
|
|
92
|
+
itemList.push(item);
|
|
93
|
+
}
|
|
94
|
+
const payload = {
|
|
95
|
+
msg: {
|
|
96
|
+
from_user_id: "",
|
|
97
|
+
to_user_id: normalizedPeerId,
|
|
98
|
+
client_id: crypto.randomUUID(),
|
|
99
|
+
message_type: 2,
|
|
100
|
+
message_state: 2,
|
|
101
|
+
...(sanitizeText(target.contextToken)
|
|
102
|
+
? { context_token: sanitizeText(target.contextToken) }
|
|
103
|
+
: {}),
|
|
104
|
+
item_list: itemList
|
|
105
|
+
},
|
|
106
|
+
base_info: {
|
|
107
|
+
channel_version: "qq-codex-bridge"
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const response = await this.request("ilink/bot/sendmessage", payload, this.options.apiTimeoutMs);
|
|
111
|
+
assertWeixinSuccess(response, "sendmessage");
|
|
112
|
+
}
|
|
113
|
+
async buildMediaItems(peerId, mediaArtifacts) {
|
|
114
|
+
const items = [];
|
|
115
|
+
for (let index = 0; index < mediaArtifacts.length; index += 1) {
|
|
116
|
+
const artifact = mediaArtifacts[index];
|
|
117
|
+
if (artifact.kind === MediaArtifactKind.Image
|
|
118
|
+
&& isLikelyVideoThumbnail(artifact)
|
|
119
|
+
&& mediaArtifacts[index + 1]?.kind === MediaArtifactKind.Video) {
|
|
120
|
+
const thumbnail = await this.uploadArtifact(peerId, artifact);
|
|
121
|
+
const video = await this.uploadArtifact(peerId, mediaArtifacts[index + 1]);
|
|
122
|
+
items.push({
|
|
123
|
+
type: 5,
|
|
124
|
+
video_item: {
|
|
125
|
+
media: video.media,
|
|
126
|
+
video_size: video.fileData.length,
|
|
127
|
+
video_md5: video.fileMd5,
|
|
128
|
+
thumb_media: thumbnail.media
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
index += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
items.push(await this.buildStandaloneMediaItem(peerId, artifact));
|
|
135
|
+
}
|
|
136
|
+
return items;
|
|
137
|
+
}
|
|
138
|
+
async buildStandaloneMediaItem(peerId, artifact) {
|
|
139
|
+
const uploaded = await this.uploadArtifact(peerId, artifact);
|
|
140
|
+
const fileName = sanitizeText(artifact.originalName) || inferFileNameFromArtifact(artifact);
|
|
141
|
+
switch (artifact.kind) {
|
|
142
|
+
case MediaArtifactKind.Image:
|
|
143
|
+
return {
|
|
144
|
+
type: 2,
|
|
145
|
+
image_item: {
|
|
146
|
+
media: uploaded.media,
|
|
147
|
+
mid_size: uploaded.fileData.length
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
case MediaArtifactKind.Video:
|
|
151
|
+
return {
|
|
152
|
+
type: 5,
|
|
153
|
+
video_item: {
|
|
154
|
+
media: uploaded.media,
|
|
155
|
+
video_size: uploaded.fileData.length,
|
|
156
|
+
video_md5: uploaded.fileMd5
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
case MediaArtifactKind.File:
|
|
160
|
+
default:
|
|
161
|
+
return {
|
|
162
|
+
type: 4,
|
|
163
|
+
file_item: {
|
|
164
|
+
media: uploaded.media,
|
|
165
|
+
file_name: fileName,
|
|
166
|
+
md5: uploaded.fileMd5,
|
|
167
|
+
len: String(uploaded.fileData.length)
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async uploadArtifact(peerId, artifact) {
|
|
173
|
+
const fileData = await readArtifactData(this.fetchFn, artifact);
|
|
174
|
+
const fileMd5 = createHash("md5").update(fileData).digest("hex");
|
|
175
|
+
const upload = await this.uploadMedia(peerId, artifact, fileData, fileMd5);
|
|
176
|
+
return {
|
|
177
|
+
artifact,
|
|
178
|
+
fileData,
|
|
179
|
+
fileMd5,
|
|
180
|
+
media: upload.media
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async uploadMedia(peerId, artifact, fileData, fileMd5) {
|
|
184
|
+
const aesKey = randomBytes(16);
|
|
185
|
+
const encryptedData = encryptAesEcb(fileData, aesKey);
|
|
186
|
+
const filekey = randomBytes(16).toString("hex");
|
|
187
|
+
const mediaType = mapArtifactKindToMediaType(artifact.kind);
|
|
188
|
+
const uploadResponse = (await this.request("ilink/bot/getuploadurl", {
|
|
189
|
+
filekey,
|
|
190
|
+
media_type: mediaType,
|
|
191
|
+
to_user_id: peerId,
|
|
192
|
+
rawsize: fileData.length,
|
|
193
|
+
rawfilemd5: fileMd5,
|
|
194
|
+
filesize: encryptedData.length,
|
|
195
|
+
no_need_thumb: true,
|
|
196
|
+
aeskey: aesKey.toString("hex"),
|
|
197
|
+
base_info: {
|
|
198
|
+
channel_version: "qq-codex-bridge"
|
|
199
|
+
}
|
|
200
|
+
}, this.options.apiTimeoutMs));
|
|
201
|
+
assertWeixinSuccess(uploadResponse, "getuploadurl");
|
|
202
|
+
const uploadUrl = sanitizeText(uploadResponse.upload_full_url)
|
|
203
|
+
|| `${WEIXIN_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(sanitizeText(uploadResponse.upload_param))}&filekey=${encodeURIComponent(filekey)}`;
|
|
204
|
+
if (!uploadUrl.includes("upload")) {
|
|
205
|
+
throw new Error("weixin getuploadurl returned no usable upload url");
|
|
206
|
+
}
|
|
207
|
+
const cdnResponse = await this.fetchFn(uploadUrl, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: {
|
|
210
|
+
"content-type": "application/octet-stream"
|
|
211
|
+
},
|
|
212
|
+
body: new Uint8Array(encryptedData),
|
|
213
|
+
signal: AbortSignal.timeout(60_000)
|
|
214
|
+
});
|
|
215
|
+
if (!cdnResponse.ok) {
|
|
216
|
+
const cdnText = await cdnResponse.text().catch(() => "");
|
|
217
|
+
throw new Error(`weixin cdn upload failed: ${cdnResponse.status}${cdnText ? ` ${cdnText}` : ""}`);
|
|
218
|
+
}
|
|
219
|
+
const encryptQueryParam = sanitizeText(cdnResponse.headers.get("x-encrypted-param"));
|
|
220
|
+
if (!encryptQueryParam) {
|
|
221
|
+
throw new Error("weixin cdn upload response missing x-encrypted-param");
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
media: {
|
|
225
|
+
encrypt_query_param: encryptQueryParam,
|
|
226
|
+
aes_key: Buffer.from(aesKey.toString("hex"), "utf8").toString("base64"),
|
|
227
|
+
encrypt_type: 1
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async pollOnce() {
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
this.activePollController = controller;
|
|
234
|
+
try {
|
|
235
|
+
const response = await this.request("ilink/bot/getupdates", {
|
|
236
|
+
get_updates_buf: this.options.stateStore.getSyncCursor(),
|
|
237
|
+
base_info: {
|
|
238
|
+
channel_version: "qq-codex-bridge"
|
|
239
|
+
}
|
|
240
|
+
}, this.options.longPollTimeoutMs, controller);
|
|
241
|
+
assertWeixinSuccess(response, "getupdates");
|
|
242
|
+
const nextCursor = sanitizeText(response.get_updates_buf);
|
|
243
|
+
if (nextCursor) {
|
|
244
|
+
this.options.stateStore.setSyncCursor(nextCursor);
|
|
245
|
+
}
|
|
246
|
+
const messages = Array.isArray(response.msgs)
|
|
247
|
+
? (response.msgs ?? [])
|
|
248
|
+
: [];
|
|
249
|
+
for (const message of messages) {
|
|
250
|
+
if (!shouldProcessInboundMessage(message)) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (sanitizeText(message.context_token) && sanitizeText(message.from_user_id)) {
|
|
254
|
+
this.options.stateStore.setContextToken(this.options.accountId, sanitizeText(message.from_user_id), sanitizeText(message.context_token));
|
|
255
|
+
}
|
|
256
|
+
await this.options.onInboundMessage(message);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
if (this.activePollController === controller) {
|
|
261
|
+
this.activePollController = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async request(pathname, body, timeoutMs, controller) {
|
|
266
|
+
const url = new URL(pathname, ensureTrailingSlash(this.options.baseUrl)).toString();
|
|
267
|
+
const response = await requestJsonWithTimeout(this.fetchFn, "POST", url, {
|
|
268
|
+
headers: {
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
AuthorizationType: "ilink_bot_token",
|
|
271
|
+
"X-WECHAT-UIN": this.headersUin,
|
|
272
|
+
...(sanitizeText(this.options.token)
|
|
273
|
+
? { Authorization: `Bearer ${sanitizeText(this.options.token)}` }
|
|
274
|
+
: {})
|
|
275
|
+
},
|
|
276
|
+
body,
|
|
277
|
+
timeoutMs,
|
|
278
|
+
signal: controller?.signal
|
|
279
|
+
});
|
|
280
|
+
return response;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function mapArtifactKindToMediaType(kind) {
|
|
284
|
+
switch (kind) {
|
|
285
|
+
case MediaArtifactKind.Image:
|
|
286
|
+
return 1;
|
|
287
|
+
case MediaArtifactKind.Video:
|
|
288
|
+
return 2;
|
|
289
|
+
case MediaArtifactKind.File:
|
|
290
|
+
default:
|
|
291
|
+
return 3;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function readArtifactData(fetchFn, artifact) {
|
|
295
|
+
const localPath = sanitizeText(artifact.localPath);
|
|
296
|
+
if (localPath) {
|
|
297
|
+
try {
|
|
298
|
+
return await fs.readFile(localPath);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error?.code !== "ENOENT") {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const sourceUrl = sanitizeText(artifact.sourceUrl);
|
|
307
|
+
if (!/^https?:\/\//.test(sourceUrl)) {
|
|
308
|
+
throw new Error(`weixin media file not found: ${localPath || sourceUrl || "unknown"}`);
|
|
309
|
+
}
|
|
310
|
+
const response = await fetchFn(sourceUrl);
|
|
311
|
+
if (!response.ok) {
|
|
312
|
+
const body = await response.text().catch(() => "");
|
|
313
|
+
throw new Error(`weixin media download failed: ${response.status}${body ? ` ${body}` : ""}`);
|
|
314
|
+
}
|
|
315
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
316
|
+
return Buffer.from(arrayBuffer);
|
|
317
|
+
}
|
|
318
|
+
function encryptAesEcb(plaintext, key) {
|
|
319
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
320
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
321
|
+
}
|
|
322
|
+
function inferFileNameFromArtifact(artifact) {
|
|
323
|
+
const source = sanitizeText(artifact.originalName)
|
|
324
|
+
|| path.basename(sanitizeText(artifact.localPath))
|
|
325
|
+
|| path.basename(sanitizeText(artifact.sourceUrl));
|
|
326
|
+
return source || "weixin-media";
|
|
327
|
+
}
|
|
328
|
+
function isLikelyVideoThumbnail(artifact) {
|
|
329
|
+
const filename = inferFileNameFromArtifact(artifact).toLowerCase();
|
|
330
|
+
return (artifact.kind === MediaArtifactKind.Image
|
|
331
|
+
&& (filename.includes("thumbnail")
|
|
332
|
+
|| filename.includes("thumb")
|
|
333
|
+
|| filename.includes("poster")
|
|
334
|
+
|| filename.includes("cover")));
|
|
335
|
+
}
|
|
336
|
+
export async function runWeixinLoginFlow(options) {
|
|
337
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
338
|
+
const existing = options.stateStore.resolveRuntimeAccount(options.accountId, {
|
|
339
|
+
token: null,
|
|
340
|
+
baseUrl: null
|
|
341
|
+
});
|
|
342
|
+
if (existing && !options.force) {
|
|
343
|
+
return {
|
|
344
|
+
accountId: existing.accountId,
|
|
345
|
+
baseUrl: existing.baseUrl,
|
|
346
|
+
qrcodeUrl: ""
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const qr = await fetchWeixinQrCode(fetchFn, options.config);
|
|
350
|
+
const qrcode = sanitizeText(qr.qrcode);
|
|
351
|
+
const qrcodeUrl = sanitizeText(qr.qrcode_img_content);
|
|
352
|
+
if (!qrcode || !qrcodeUrl) {
|
|
353
|
+
throw new Error("weixin qr login failed: qrcode response is incomplete");
|
|
354
|
+
}
|
|
355
|
+
options.onQrCode?.(qrcodeUrl);
|
|
356
|
+
let currentBaseUrl = options.config.loginBaseUrl;
|
|
357
|
+
const deadline = Date.now() + options.config.qrTotalTimeoutMs;
|
|
358
|
+
while (Date.now() < deadline) {
|
|
359
|
+
const status = await pollWeixinQrStatus(fetchFn, qrcode, currentBaseUrl, options.config.qrPollTimeoutMs);
|
|
360
|
+
const currentStatus = sanitizeText(status.status);
|
|
361
|
+
if (currentStatus === "scaned_but_redirect" && sanitizeText(status.redirect_host)) {
|
|
362
|
+
currentBaseUrl = `https://${sanitizeText(status.redirect_host)}`;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (currentStatus === "wait" || currentStatus === "scaned") {
|
|
366
|
+
await sleep(1000);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (currentStatus === "expired") {
|
|
370
|
+
throw new Error("weixin qr code expired before confirmation");
|
|
371
|
+
}
|
|
372
|
+
if (currentStatus === "confirmed") {
|
|
373
|
+
const botToken = sanitizeText(status.bot_token);
|
|
374
|
+
const accountId = sanitizeText(status.ilink_bot_id)
|
|
375
|
+
|| sanitizeText(options.accountId)
|
|
376
|
+
|| "default";
|
|
377
|
+
const baseUrl = sanitizeText(status.baseurl)
|
|
378
|
+
|| currentBaseUrl
|
|
379
|
+
|| options.config.loginBaseUrl;
|
|
380
|
+
if (!botToken) {
|
|
381
|
+
throw new Error("weixin login confirmed but bot token is missing");
|
|
382
|
+
}
|
|
383
|
+
options.stateStore.setStoredAccount({
|
|
384
|
+
accountId,
|
|
385
|
+
token: botToken,
|
|
386
|
+
baseUrl,
|
|
387
|
+
...(sanitizeText(status.ilink_user_id) ? { userId: sanitizeText(status.ilink_user_id) } : {})
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
accountId,
|
|
391
|
+
baseUrl,
|
|
392
|
+
qrcodeUrl
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`unexpected weixin qr status: ${currentStatus || "unknown"}`);
|
|
396
|
+
}
|
|
397
|
+
throw new Error("weixin login timed out");
|
|
398
|
+
}
|
|
399
|
+
export async function forwardWeixinInboundToBridge(fetchFn, target, message) {
|
|
400
|
+
const senderId = sanitizeText(message.from_user_id);
|
|
401
|
+
const text = extractWeixinText(message);
|
|
402
|
+
if (!senderId || !text) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const payload = {
|
|
406
|
+
accountKey: target.accountKey,
|
|
407
|
+
chatType: "c2c",
|
|
408
|
+
senderId,
|
|
409
|
+
peerId: senderId,
|
|
410
|
+
messageId: String(message.message_id || message.seq || message.session_id || Date.now()),
|
|
411
|
+
text,
|
|
412
|
+
receivedAt: new Date().toISOString()
|
|
413
|
+
};
|
|
414
|
+
const response = await fetchFn(`${target.bridgeBaseUrl}${target.bridgeWebhookPath}`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: {
|
|
417
|
+
"content-type": "application/json"
|
|
418
|
+
},
|
|
419
|
+
body: JSON.stringify(payload)
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
const body = await response.text().catch(() => "");
|
|
423
|
+
throw new Error(`bridge webhook failed: ${response.status}${body ? ` ${body}` : ""}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
export function extractWeixinText(message) {
|
|
427
|
+
if (!message || !Array.isArray(message.item_list)) {
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
430
|
+
for (const item of message.item_list) {
|
|
431
|
+
if (Number(item?.type) === 1 && typeof item.text_item?.text === "string") {
|
|
432
|
+
return sanitizeText(item.text_item.text);
|
|
433
|
+
}
|
|
434
|
+
if (Number(item?.type) === 3 && typeof item.voice_item?.text === "string") {
|
|
435
|
+
return sanitizeText(item.voice_item.text);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
function shouldProcessInboundMessage(message) {
|
|
441
|
+
if (Number(message.message_type || 0) === 2) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
return Boolean(sanitizeText(message.from_user_id) && extractWeixinText(message));
|
|
445
|
+
}
|
|
446
|
+
async function fetchWeixinQrCode(fetchFn, config) {
|
|
447
|
+
const url = new URL("ilink/bot/get_bot_qrcode", ensureTrailingSlash(config.loginBaseUrl));
|
|
448
|
+
url.searchParams.set("bot_type", config.loginBotType);
|
|
449
|
+
return requestJsonByText(fetchFn, url.toString(), config.qrFetchTimeoutMs);
|
|
450
|
+
}
|
|
451
|
+
async function pollWeixinQrStatus(fetchFn, qrcode, baseUrl, timeoutMs) {
|
|
452
|
+
const url = new URL("ilink/bot/get_qrcode_status", ensureTrailingSlash(baseUrl));
|
|
453
|
+
url.searchParams.set("qrcode", qrcode);
|
|
454
|
+
try {
|
|
455
|
+
return await requestJsonByText(fetchFn, url.toString(), timeoutMs);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (error?.name === "AbortError") {
|
|
459
|
+
return { status: "wait" };
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function requestJsonByText(fetchFn, url, timeoutMs) {
|
|
465
|
+
const controller = new AbortController();
|
|
466
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
467
|
+
try {
|
|
468
|
+
const response = await fetchFn(url, {
|
|
469
|
+
method: "GET",
|
|
470
|
+
signal: controller.signal
|
|
471
|
+
});
|
|
472
|
+
const text = await response.text();
|
|
473
|
+
if (!response.ok) {
|
|
474
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}: ${text}`);
|
|
475
|
+
}
|
|
476
|
+
return JSON.parse(text);
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function requestJsonWithTimeout(fetchFn, method, url, options) {
|
|
483
|
+
const controller = new AbortController();
|
|
484
|
+
const signal = options.signal
|
|
485
|
+
? AbortSignal.any([controller.signal, options.signal])
|
|
486
|
+
: controller.signal;
|
|
487
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
488
|
+
try {
|
|
489
|
+
const response = await fetchFn(url, {
|
|
490
|
+
method,
|
|
491
|
+
headers: options.headers,
|
|
492
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
493
|
+
signal
|
|
494
|
+
});
|
|
495
|
+
const text = await response.text();
|
|
496
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
497
|
+
if (!response.ok) {
|
|
498
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}: ${text || "request failed"}`);
|
|
499
|
+
}
|
|
500
|
+
return parsed;
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
clearTimeout(timer);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function assertWeixinSuccess(response, action) {
|
|
507
|
+
const payload = response;
|
|
508
|
+
if ((Number(payload?.ret) || 0) !== 0 || (Number(payload?.errcode) || 0) !== 0) {
|
|
509
|
+
throw new Error(`weixin ${action} failed: ret=${Number(payload?.ret) || 0} errcode=${Number(payload?.errcode) || 0} errmsg=${sanitizeText(payload?.errmsg) || "unknown error"}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function ensureTrailingSlash(url) {
|
|
513
|
+
return String(url).replace(/\/+$/, "") + "/";
|
|
514
|
+
}
|
|
515
|
+
function sanitizeText(value) {
|
|
516
|
+
return String(value ?? "").trim();
|
|
517
|
+
}
|
|
518
|
+
function sleep(ms) {
|
|
519
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
520
|
+
}
|