@unlinearity/cli-wechat-bridge 1.0.0 → 1.0.2
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/bin/_run-entry.mjs +2 -8
- package/bin/wechat-bridge-claude.mjs +2 -2
- package/bin/wechat-bridge-codex.mjs +2 -2
- package/bin/wechat-bridge-opencode.mjs +2 -2
- package/bin/wechat-bridge-shell.mjs +2 -2
- package/bin/wechat-bridge.mjs +2 -2
- package/bin/wechat-check-update.mjs +2 -2
- package/bin/wechat-claude-start.mjs +2 -2
- package/bin/wechat-claude.mjs +2 -2
- package/bin/wechat-codex-start.mjs +2 -2
- package/bin/wechat-codex.mjs +2 -2
- package/bin/wechat-opencode-start.mjs +2 -2
- package/bin/wechat-opencode.mjs +2 -2
- package/bin/wechat-setup.mjs +5 -0
- package/dist/bridge/bridge-adapters.claude.js +829 -0
- package/dist/bridge/bridge-adapters.codex.js +2206 -0
- package/dist/bridge/bridge-adapters.core.js +668 -0
- package/dist/bridge/bridge-adapters.js +26 -0
- package/dist/bridge/bridge-adapters.opencode.js +1777 -0
- package/dist/bridge/bridge-adapters.shared.js +1098 -0
- package/dist/bridge/bridge-adapters.shell.js +363 -0
- package/dist/bridge/bridge-controller.js +44 -0
- package/dist/bridge/bridge-final-reply.js +31 -0
- package/dist/bridge/bridge-process-reaper.js +348 -0
- package/dist/bridge/bridge-state.js +356 -0
- package/dist/bridge/bridge-types.js +1 -0
- package/dist/bridge/bridge-utils.js +1150 -0
- package/dist/bridge/claude-hook.js +82 -0
- package/dist/bridge/claude-hooks.js +267 -0
- package/dist/bridge/wechat-bridge.js +904 -0
- package/dist/commands/check-update.js +30 -0
- package/dist/companion/codex-panel-link.js +72 -0
- package/dist/companion/codex-panel.js +179 -0
- package/dist/companion/codex-remote-client.js +117 -0
- package/dist/companion/local-companion-link.js +210 -0
- package/dist/companion/local-companion-start.js +372 -0
- package/dist/companion/local-companion.js +411 -0
- package/dist/media/media-types.js +1 -0
- package/dist/runtime/create-runtime-host.js +12 -0
- package/dist/runtime/legacy-adapter-runtime.js +40 -0
- package/dist/runtime/runtime-types.js +5 -0
- package/dist/utils/version-checker.js +161 -0
- package/dist/wechat/channel-config.js +103 -0
- package/dist/wechat/setup.js +283 -0
- package/dist/wechat/standalone-bot.js +356 -0
- package/dist/wechat/wechat-channel.js +481 -0
- package/dist/wechat/wechat-transport.js +887 -0
- package/package.json +6 -2
- package/src/bridge/bridge-adapters.claude.ts +0 -1047
- package/src/bridge/bridge-adapters.codex.ts +0 -2733
- package/src/bridge/bridge-adapters.core.ts +0 -877
- package/src/bridge/bridge-adapters.opencode.ts +0 -2390
- package/src/bridge/bridge-adapters.shared.ts +0 -1647
- package/src/bridge/bridge-adapters.shell.ts +0 -456
- package/src/bridge/bridge-adapters.ts +0 -30
- package/src/bridge/bridge-controller.ts +0 -58
- package/src/bridge/bridge-final-reply.ts +0 -56
- package/src/bridge/bridge-process-reaper.ts +0 -417
- package/src/bridge/bridge-state.ts +0 -520
- package/src/bridge/bridge-types.ts +0 -218
- package/src/bridge/bridge-utils.ts +0 -1524
- package/src/bridge/claude-hook.ts +0 -92
- package/src/bridge/claude-hooks.ts +0 -376
- package/src/bridge/wechat-bridge.ts +0 -1305
- package/src/commands/check-update.ts +0 -46
- package/src/companion/codex-panel-link.ts +0 -151
- package/src/companion/codex-panel.ts +0 -225
- package/src/companion/codex-remote-client.ts +0 -178
- package/src/companion/local-companion-link.ts +0 -338
- package/src/companion/local-companion-start.ts +0 -520
- package/src/companion/local-companion.ts +0 -532
- package/src/media/media-types.ts +0 -11
- package/src/runtime/create-runtime-host.ts +0 -16
- package/src/runtime/legacy-adapter-runtime.ts +0 -55
- package/src/runtime/runtime-types.ts +0 -51
- package/src/utils/version-checker.ts +0 -197
- package/src/wechat/channel-config.ts +0 -141
- package/src/wechat/setup.ts +0 -232
- package/src/wechat/standalone-bot.ts +0 -483
- package/src/wechat/wechat-channel.ts +0 -608
- package/src/wechat/wechat-transport.ts +0 -1290
|
@@ -1,1290 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { createCipheriv } from "node:crypto";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
CONTEXT_CACHE_FILE,
|
|
8
|
-
CREDENTIALS_FILE,
|
|
9
|
-
ensureChannelDataDir,
|
|
10
|
-
INBOUND_MESSAGE_CLAIMS_DIR,
|
|
11
|
-
migrateLegacyChannelFiles,
|
|
12
|
-
SYNC_BUF_FILE,
|
|
13
|
-
} from "./channel-config.ts";
|
|
14
|
-
|
|
15
|
-
export const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
16
|
-
|
|
17
|
-
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
18
|
-
const CHANNEL_VERSION = "0.3.0";
|
|
19
|
-
const RECENT_MESSAGE_CACHE_SIZE = 500;
|
|
20
|
-
const BYTES_PER_MB = 1024 * 1024;
|
|
21
|
-
const SEND_TIMEOUT_MS = 15_000;
|
|
22
|
-
const CDN_MAX_RETRIES = 3;
|
|
23
|
-
const ERROR_CAUSE_DEPTH_LIMIT = 4;
|
|
24
|
-
const INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
25
|
-
|
|
26
|
-
const MSG_TYPE_USER = 1;
|
|
27
|
-
const MSG_TYPE_BOT = 2;
|
|
28
|
-
|
|
29
|
-
const MSG_ITEM_TEXT = 1;
|
|
30
|
-
const MSG_ITEM_IMAGE = 2;
|
|
31
|
-
const MSG_ITEM_VOICE = 3;
|
|
32
|
-
const MSG_ITEM_FILE = 4;
|
|
33
|
-
const MSG_ITEM_VIDEO = 5;
|
|
34
|
-
|
|
35
|
-
const MSG_STATE_FINISH = 2;
|
|
36
|
-
|
|
37
|
-
const UPLOAD_MEDIA_TYPE_IMAGE = 1;
|
|
38
|
-
const UPLOAD_MEDIA_TYPE_VIDEO = 2;
|
|
39
|
-
const UPLOAD_MEDIA_TYPE_FILE = 3;
|
|
40
|
-
const UPLOAD_MEDIA_TYPE_VOICE = 4;
|
|
41
|
-
|
|
42
|
-
export type AccountData = {
|
|
43
|
-
token: string;
|
|
44
|
-
baseUrl: string;
|
|
45
|
-
accountId: string;
|
|
46
|
-
userId?: string;
|
|
47
|
-
savedAt: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
type ContextTokenState = Record<string, string>;
|
|
51
|
-
|
|
52
|
-
interface TextItem {
|
|
53
|
-
text?: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface RefMessage {
|
|
57
|
-
message_item?: MessageItem;
|
|
58
|
-
title?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface MessageItem {
|
|
62
|
-
type?: number;
|
|
63
|
-
text_item?: TextItem;
|
|
64
|
-
voice_item?: { text?: string };
|
|
65
|
-
ref_msg?: RefMessage;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface WeixinMessage {
|
|
69
|
-
from_user_id?: string;
|
|
70
|
-
to_user_id?: string;
|
|
71
|
-
client_id?: string;
|
|
72
|
-
session_id?: string;
|
|
73
|
-
message_type?: number;
|
|
74
|
-
message_state?: number;
|
|
75
|
-
item_list?: MessageItem[];
|
|
76
|
-
context_token?: string;
|
|
77
|
-
create_time_ms?: number;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface GetUpdatesResp {
|
|
81
|
-
ret?: number;
|
|
82
|
-
errcode?: number;
|
|
83
|
-
errmsg?: string;
|
|
84
|
-
msgs?: WeixinMessage[];
|
|
85
|
-
get_updates_buf?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export type InboundWechatMessage = {
|
|
89
|
-
senderId: string;
|
|
90
|
-
sender: string;
|
|
91
|
-
sessionId: string;
|
|
92
|
-
text: string;
|
|
93
|
-
contextToken?: string;
|
|
94
|
-
createdAt: string;
|
|
95
|
-
createdAtMs?: number;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
type PollMessagesOptions = {
|
|
99
|
-
timeoutMs?: number;
|
|
100
|
-
minCreatedAtMs?: number;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
type PollMessagesResult = {
|
|
104
|
-
messages: InboundWechatMessage[];
|
|
105
|
-
ignoredBacklogCount: number;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
type TransportLogger = {
|
|
109
|
-
log: (message: string) => void;
|
|
110
|
-
logError: (message: string) => void;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
type ResetSyncOptions = {
|
|
114
|
-
clearContextCache?: boolean;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
type SendImageOptions = {
|
|
118
|
-
recipientId?: string;
|
|
119
|
-
caption?: string;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
type SendFileOptions = {
|
|
123
|
-
recipientId?: string;
|
|
124
|
-
title?: string;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
type SendVideoOptions = {
|
|
128
|
-
recipientId?: string;
|
|
129
|
-
title?: string;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
type UploadLabel = "image" | "file" | "voice" | "video";
|
|
133
|
-
|
|
134
|
-
type ResolvedRecipient = {
|
|
135
|
-
account: AccountData;
|
|
136
|
-
recipientId: string;
|
|
137
|
-
contextToken: string;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
type UploadPreparation = {
|
|
141
|
-
rawsize: number;
|
|
142
|
-
filesize: number;
|
|
143
|
-
aeskey: Buffer;
|
|
144
|
-
downloadParam: string;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export type WechatTransportErrorKind =
|
|
148
|
-
| "timeout"
|
|
149
|
-
| "network"
|
|
150
|
-
| "http"
|
|
151
|
-
| "auth"
|
|
152
|
-
| "unknown";
|
|
153
|
-
|
|
154
|
-
export type WechatTransportErrorClassification = {
|
|
155
|
-
kind: WechatTransportErrorKind;
|
|
156
|
-
retryable: boolean;
|
|
157
|
-
statusCode?: number;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const DEFAULT_MEDIA_UPLOAD_LIMIT_MB: Record<UploadLabel, number> = {
|
|
161
|
-
image: 20,
|
|
162
|
-
file: 50,
|
|
163
|
-
voice: 20,
|
|
164
|
-
video: 100,
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const MEDIA_UPLOAD_LIMIT_ENV_KEYS: Record<UploadLabel, string> = {
|
|
168
|
-
image: "WECHAT_MAX_IMAGE_MB",
|
|
169
|
-
file: "WECHAT_MAX_FILE_MB",
|
|
170
|
-
voice: "WECHAT_MAX_VOICE_MB",
|
|
171
|
-
video: "WECHAT_MAX_VIDEO_MB",
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429]);
|
|
175
|
-
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
|
176
|
-
"ECONNABORTED",
|
|
177
|
-
"ECONNREFUSED",
|
|
178
|
-
"ECONNRESET",
|
|
179
|
-
"EHOSTUNREACH",
|
|
180
|
-
"EPIPE",
|
|
181
|
-
"ETIMEDOUT",
|
|
182
|
-
"ENETUNREACH",
|
|
183
|
-
"ENOTFOUND",
|
|
184
|
-
"EAI_AGAIN",
|
|
185
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
186
|
-
"UND_ERR_HEADERS_TIMEOUT",
|
|
187
|
-
"UND_ERR_SOCKET",
|
|
188
|
-
]);
|
|
189
|
-
const RETRYABLE_NETWORK_ERROR_HINTS = [
|
|
190
|
-
"connection closed",
|
|
191
|
-
"connection reset",
|
|
192
|
-
"connection refused",
|
|
193
|
-
"econnaborted",
|
|
194
|
-
"econnrefused",
|
|
195
|
-
"econnreset",
|
|
196
|
-
"ehostunreach",
|
|
197
|
-
"enetunreach",
|
|
198
|
-
"enotfound",
|
|
199
|
-
"eai_again",
|
|
200
|
-
"fetch failed",
|
|
201
|
-
"network error",
|
|
202
|
-
"request timeout",
|
|
203
|
-
"socket hang up",
|
|
204
|
-
"timed out",
|
|
205
|
-
"timeout",
|
|
206
|
-
];
|
|
207
|
-
|
|
208
|
-
type ErrorWithCause = Error & {
|
|
209
|
-
cause?: unknown;
|
|
210
|
-
code?: unknown;
|
|
211
|
-
errno?: unknown;
|
|
212
|
-
syscall?: unknown;
|
|
213
|
-
hostname?: unknown;
|
|
214
|
-
address?: unknown;
|
|
215
|
-
port?: unknown;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
function readJsonFile<T>(filePath: string): T | null {
|
|
219
|
-
try {
|
|
220
|
-
if (!fs.existsSync(filePath)) {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
224
|
-
} catch {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
230
|
-
return typeof value === "object" && value !== null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function describeErrorNode(value: unknown): string {
|
|
234
|
-
if (value instanceof Error) {
|
|
235
|
-
const error = value as ErrorWithCause;
|
|
236
|
-
const parts: string[] = [];
|
|
237
|
-
if (error.name && error.message) {
|
|
238
|
-
parts.push(`${error.name}: ${error.message}`);
|
|
239
|
-
} else if (error.message) {
|
|
240
|
-
parts.push(error.message);
|
|
241
|
-
} else if (error.name) {
|
|
242
|
-
parts.push(error.name);
|
|
243
|
-
}
|
|
244
|
-
if (typeof error.code === "string" && error.code.trim()) {
|
|
245
|
-
parts.push(`code=${error.code}`);
|
|
246
|
-
}
|
|
247
|
-
if (
|
|
248
|
-
(typeof error.errno === "string" && error.errno.trim()) ||
|
|
249
|
-
typeof error.errno === "number"
|
|
250
|
-
) {
|
|
251
|
-
parts.push(`errno=${error.errno}`);
|
|
252
|
-
}
|
|
253
|
-
if (typeof error.syscall === "string" && error.syscall.trim()) {
|
|
254
|
-
parts.push(`syscall=${error.syscall}`);
|
|
255
|
-
}
|
|
256
|
-
if (typeof error.hostname === "string" && error.hostname.trim()) {
|
|
257
|
-
parts.push(`host=${error.hostname}`);
|
|
258
|
-
}
|
|
259
|
-
if (typeof error.address === "string" && error.address.trim()) {
|
|
260
|
-
parts.push(`address=${error.address}`);
|
|
261
|
-
}
|
|
262
|
-
if (
|
|
263
|
-
(typeof error.port === "string" && error.port.trim()) ||
|
|
264
|
-
typeof error.port === "number"
|
|
265
|
-
) {
|
|
266
|
-
parts.push(`port=${error.port}`);
|
|
267
|
-
}
|
|
268
|
-
return parts.filter(Boolean).join(" ");
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (isRecord(value)) {
|
|
272
|
-
const parts: string[] = [];
|
|
273
|
-
if (typeof value.message === "string" && value.message.trim()) {
|
|
274
|
-
parts.push(value.message);
|
|
275
|
-
}
|
|
276
|
-
if (typeof value.code === "string" && value.code.trim()) {
|
|
277
|
-
parts.push(`code=${value.code}`);
|
|
278
|
-
}
|
|
279
|
-
if (
|
|
280
|
-
(typeof value.errno === "string" && value.errno.trim()) ||
|
|
281
|
-
typeof value.errno === "number"
|
|
282
|
-
) {
|
|
283
|
-
parts.push(`errno=${value.errno}`);
|
|
284
|
-
}
|
|
285
|
-
if (typeof value.syscall === "string" && value.syscall.trim()) {
|
|
286
|
-
parts.push(`syscall=${value.syscall}`);
|
|
287
|
-
}
|
|
288
|
-
if (typeof value.hostname === "string" && value.hostname.trim()) {
|
|
289
|
-
parts.push(`host=${value.hostname}`);
|
|
290
|
-
}
|
|
291
|
-
if (typeof value.address === "string" && value.address.trim()) {
|
|
292
|
-
parts.push(`address=${value.address}`);
|
|
293
|
-
}
|
|
294
|
-
if (
|
|
295
|
-
(typeof value.port === "string" && value.port.trim()) ||
|
|
296
|
-
typeof value.port === "number"
|
|
297
|
-
) {
|
|
298
|
-
parts.push(`port=${value.port}`);
|
|
299
|
-
}
|
|
300
|
-
if (parts.length > 0) {
|
|
301
|
-
return parts.join(" ");
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return String(value);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function getErrorCause(value: unknown): unknown {
|
|
309
|
-
if (value instanceof Error) {
|
|
310
|
-
return (value as ErrorWithCause).cause;
|
|
311
|
-
}
|
|
312
|
-
if (isRecord(value) && "cause" in value) {
|
|
313
|
-
return value.cause;
|
|
314
|
-
}
|
|
315
|
-
return undefined;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function collectErrorCodes(value: unknown): string[] {
|
|
319
|
-
const seen = new Set<unknown>();
|
|
320
|
-
const codes = new Set<string>();
|
|
321
|
-
let current: unknown = value;
|
|
322
|
-
let depth = 0;
|
|
323
|
-
|
|
324
|
-
while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
|
|
325
|
-
seen.add(current);
|
|
326
|
-
if (isRecord(current) && typeof current.code === "string" && current.code.trim()) {
|
|
327
|
-
codes.add(current.code.toUpperCase());
|
|
328
|
-
}
|
|
329
|
-
current = getErrorCause(current);
|
|
330
|
-
depth += 1;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return [...codes];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function extractHttpStatusCode(error: Error): number | null {
|
|
337
|
-
const match = /^HTTP (\d{3}):/.exec(error.message);
|
|
338
|
-
if (!match) {
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
const parsed = Number(match[1]);
|
|
342
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function describeWechatTransportError(error: unknown): string {
|
|
346
|
-
const seen = new Set<unknown>();
|
|
347
|
-
const parts: string[] = [];
|
|
348
|
-
let current: unknown = error;
|
|
349
|
-
let depth = 0;
|
|
350
|
-
|
|
351
|
-
while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
|
|
352
|
-
seen.add(current);
|
|
353
|
-
const description = describeErrorNode(current);
|
|
354
|
-
if (description) {
|
|
355
|
-
parts.push(depth === 0 ? description : `cause: ${description}`);
|
|
356
|
-
}
|
|
357
|
-
current = getErrorCause(current);
|
|
358
|
-
depth += 1;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return parts.length > 0 ? parts.join(" | ") : String(error);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export function classifyWechatTransportError(
|
|
365
|
-
error: unknown,
|
|
366
|
-
): WechatTransportErrorClassification {
|
|
367
|
-
if (error instanceof Error) {
|
|
368
|
-
if (error.name === "AbortError") {
|
|
369
|
-
return { kind: "timeout", retryable: true };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const statusCode = extractHttpStatusCode(error);
|
|
373
|
-
if (statusCode !== null) {
|
|
374
|
-
if (statusCode === 401 || statusCode === 403) {
|
|
375
|
-
return { kind: "auth", retryable: false, statusCode };
|
|
376
|
-
}
|
|
377
|
-
if (statusCode >= 500 || RETRYABLE_HTTP_STATUS_CODES.has(statusCode)) {
|
|
378
|
-
return { kind: "http", retryable: true, statusCode };
|
|
379
|
-
}
|
|
380
|
-
return { kind: "http", retryable: false, statusCode };
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const errorCodes = collectErrorCodes(error);
|
|
385
|
-
if (errorCodes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
|
|
386
|
-
return { kind: "network", retryable: true };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const details = describeWechatTransportError(error).toLowerCase();
|
|
390
|
-
if (RETRYABLE_NETWORK_ERROR_HINTS.some((hint) => details.includes(hint))) {
|
|
391
|
-
return { kind: "network", retryable: true };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return { kind: "unknown", retryable: false };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function writeJsonFile(filePath: string, value: unknown): void {
|
|
398
|
-
ensureChannelDataDir();
|
|
399
|
-
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function randomWechatUin(): string {
|
|
403
|
-
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
404
|
-
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function buildHeaders(token?: string, body?: string): Record<string, string> {
|
|
408
|
-
const headers: Record<string, string> = {
|
|
409
|
-
"Content-Type": "application/json",
|
|
410
|
-
AuthorizationType: "ilink_bot_token",
|
|
411
|
-
"X-WECHAT-UIN": randomWechatUin(),
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
if (body) {
|
|
415
|
-
headers["Content-Length"] = String(Buffer.byteLength(body, "utf-8"));
|
|
416
|
-
}
|
|
417
|
-
if (token?.trim()) {
|
|
418
|
-
headers.Authorization = `Bearer ${token.trim()}`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return headers;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function apiFetch(params: {
|
|
425
|
-
baseUrl: string;
|
|
426
|
-
endpoint: string;
|
|
427
|
-
body: string;
|
|
428
|
-
token?: string;
|
|
429
|
-
timeoutMs: number;
|
|
430
|
-
}): Promise<string> {
|
|
431
|
-
const base = params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`;
|
|
432
|
-
const url = new URL(params.endpoint, base).toString();
|
|
433
|
-
const controller = new AbortController();
|
|
434
|
-
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const res = await fetch(url, {
|
|
438
|
-
method: "POST",
|
|
439
|
-
headers: buildHeaders(params.token, params.body),
|
|
440
|
-
body: params.body,
|
|
441
|
-
signal: controller.signal,
|
|
442
|
-
});
|
|
443
|
-
clearTimeout(timer);
|
|
444
|
-
|
|
445
|
-
const text = await res.text();
|
|
446
|
-
if (!res.ok) {
|
|
447
|
-
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return text;
|
|
451
|
-
} catch (err) {
|
|
452
|
-
clearTimeout(timer);
|
|
453
|
-
throw err;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
458
|
-
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
459
|
-
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function aesEcbPaddedSize(plaintextSize: number): number {
|
|
463
|
-
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
export function formatByteSize(bytes: number): string {
|
|
467
|
-
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
468
|
-
return "0 B";
|
|
469
|
-
}
|
|
470
|
-
if (bytes >= BYTES_PER_MB) {
|
|
471
|
-
const value = bytes / BYTES_PER_MB;
|
|
472
|
-
return `${value.toFixed(value >= 100 ? 0 : 1)} MB`;
|
|
473
|
-
}
|
|
474
|
-
if (bytes >= 1024) {
|
|
475
|
-
const value = bytes / 1024;
|
|
476
|
-
return `${value.toFixed(value >= 100 ? 0 : 1)} KB`;
|
|
477
|
-
}
|
|
478
|
-
return `${bytes} B`;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function resolveMediaUploadLimitBytes(
|
|
482
|
-
label: UploadLabel,
|
|
483
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
484
|
-
): number {
|
|
485
|
-
const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
|
|
486
|
-
const raw = env[envKey];
|
|
487
|
-
const fallbackMb = DEFAULT_MEDIA_UPLOAD_LIMIT_MB[label];
|
|
488
|
-
const parsedMb = raw ? Number(raw) : Number.NaN;
|
|
489
|
-
const limitMb =
|
|
490
|
-
Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
|
|
491
|
-
return Math.floor(limitMb * BYTES_PER_MB);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
export function assertMediaUploadSizeAllowed(
|
|
495
|
-
label: UploadLabel,
|
|
496
|
-
rawsize: number,
|
|
497
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
498
|
-
): void {
|
|
499
|
-
const limitBytes = resolveMediaUploadLimitBytes(label, env);
|
|
500
|
-
if (rawsize <= limitBytes) {
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
|
|
505
|
-
const labelName = label.charAt(0).toUpperCase() + label.slice(1);
|
|
506
|
-
throw new Error(
|
|
507
|
-
`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} limit. Set ${envKey} to override.`,
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function encodeMessageAesKey(aeskey: Buffer): string {
|
|
512
|
-
return Buffer.from(aeskey.toString("hex")).toString("base64");
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async function getUploadUrl(
|
|
516
|
-
account: AccountData,
|
|
517
|
-
params: {
|
|
518
|
-
filekey: string;
|
|
519
|
-
media_type: number;
|
|
520
|
-
to_user_id: string;
|
|
521
|
-
rawsize: number;
|
|
522
|
-
rawfilemd5: string;
|
|
523
|
-
filesize: number;
|
|
524
|
-
aeskey: string;
|
|
525
|
-
},
|
|
526
|
-
): Promise<{ upload_param?: string }> {
|
|
527
|
-
const raw = await apiFetch({
|
|
528
|
-
baseUrl: account.baseUrl,
|
|
529
|
-
endpoint: "ilink/bot/getuploadurl",
|
|
530
|
-
body: JSON.stringify({
|
|
531
|
-
...params,
|
|
532
|
-
no_need_thumb: true,
|
|
533
|
-
base_info: { channel_version: CHANNEL_VERSION },
|
|
534
|
-
}),
|
|
535
|
-
token: account.token,
|
|
536
|
-
timeoutMs: SEND_TIMEOUT_MS,
|
|
537
|
-
});
|
|
538
|
-
return JSON.parse(raw) as { upload_param?: string };
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function buildCdnUploadUrl(
|
|
542
|
-
cdnBaseUrl: string,
|
|
543
|
-
uploadParam: string,
|
|
544
|
-
filekey: string,
|
|
545
|
-
): string {
|
|
546
|
-
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async function uploadBufferToCdn(params: {
|
|
550
|
-
buf: Buffer;
|
|
551
|
-
uploadParam: string;
|
|
552
|
-
filekey: string;
|
|
553
|
-
aeskey: Buffer;
|
|
554
|
-
onRetry?: (attempt: number) => void;
|
|
555
|
-
}): Promise<{ downloadParam: string }> {
|
|
556
|
-
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
|
|
557
|
-
const cdnUrl = buildCdnUploadUrl(CDN_BASE_URL, params.uploadParam, params.filekey);
|
|
558
|
-
|
|
559
|
-
let downloadParam: string | undefined;
|
|
560
|
-
let lastError: unknown;
|
|
561
|
-
|
|
562
|
-
for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt += 1) {
|
|
563
|
-
try {
|
|
564
|
-
const res = await fetch(cdnUrl, {
|
|
565
|
-
method: "POST",
|
|
566
|
-
headers: { "Content-Type": "application/octet-stream" },
|
|
567
|
-
body: new Uint8Array(ciphertext),
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
if (res.status >= 400 && res.status < 500) {
|
|
571
|
-
const errMsg = res.headers.get("x-error-message") ?? (await res.text());
|
|
572
|
-
throw new Error(`CDN client error ${res.status}: ${errMsg}`);
|
|
573
|
-
}
|
|
574
|
-
if (res.status !== 200) {
|
|
575
|
-
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
|
576
|
-
throw new Error(`CDN server error: ${errMsg}`);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
|
|
580
|
-
if (!downloadParam) {
|
|
581
|
-
throw new Error("CDN response missing x-encrypted-param header");
|
|
582
|
-
}
|
|
583
|
-
break;
|
|
584
|
-
} catch (err) {
|
|
585
|
-
lastError = err;
|
|
586
|
-
if (err instanceof Error && err.message.includes("client error")) {
|
|
587
|
-
throw err;
|
|
588
|
-
}
|
|
589
|
-
if (attempt >= CDN_MAX_RETRIES) {
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
params.onRetry?.(attempt);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (!downloadParam) {
|
|
597
|
-
throw lastError instanceof Error ? lastError : new Error("CDN upload failed");
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
return { downloadParam };
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function extractReferenceLabel(item: MessageItem): string | null {
|
|
604
|
-
const ref = item.ref_msg;
|
|
605
|
-
if (!ref) {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
const parts: string[] = [];
|
|
610
|
-
if (ref.title?.trim()) {
|
|
611
|
-
parts.push(ref.title.trim());
|
|
612
|
-
}
|
|
613
|
-
const quotedText = ref.message_item?.text_item?.text?.trim();
|
|
614
|
-
if (quotedText) {
|
|
615
|
-
parts.push(quotedText);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return parts.length ? `Quoted: ${parts.join(" | ")}` : null;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function extractTextFromMessage(message: WeixinMessage): string {
|
|
622
|
-
if (!message.item_list?.length) {
|
|
623
|
-
return "";
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const lines: string[] = [];
|
|
627
|
-
for (const item of message.item_list) {
|
|
628
|
-
const reference = extractReferenceLabel(item);
|
|
629
|
-
if (reference && !lines.includes(reference)) {
|
|
630
|
-
lines.push(reference);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
if (item.type === MSG_ITEM_TEXT) {
|
|
634
|
-
const text = item.text_item?.text?.trim();
|
|
635
|
-
if (text) {
|
|
636
|
-
lines.push(text);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (item.type === MSG_ITEM_VOICE) {
|
|
641
|
-
const transcript = item.voice_item?.text?.trim();
|
|
642
|
-
if (transcript) {
|
|
643
|
-
lines.push(transcript);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
return lines.join("\n").trim();
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
function buildMessageKey(message: WeixinMessage): string {
|
|
652
|
-
return [
|
|
653
|
-
message.from_user_id ?? "",
|
|
654
|
-
message.client_id ?? "",
|
|
655
|
-
String(message.create_time_ms ?? ""),
|
|
656
|
-
message.context_token ?? "",
|
|
657
|
-
].join("|");
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function buildScopedMessageClaimKey(accountId: string, messageKey: string): string {
|
|
661
|
-
return `${accountId}|${messageKey}`;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
export function buildInboundMessageClaimPath(
|
|
665
|
-
messageKey: string,
|
|
666
|
-
claimsDir = INBOUND_MESSAGE_CLAIMS_DIR,
|
|
667
|
-
): string {
|
|
668
|
-
const fileName = `${crypto.createHash("sha1").update(messageKey).digest("hex")}.json`;
|
|
669
|
-
return path.join(claimsDir, fileName);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
export function clearInboundMessageClaims(
|
|
673
|
-
claimsDir = INBOUND_MESSAGE_CLAIMS_DIR,
|
|
674
|
-
): void {
|
|
675
|
-
try {
|
|
676
|
-
fs.rmSync(claimsDir, { recursive: true, force: true });
|
|
677
|
-
} catch {
|
|
678
|
-
// Best effort cleanup.
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
export function tryClaimInboundMessage(
|
|
683
|
-
messageKey: string,
|
|
684
|
-
options: {
|
|
685
|
-
claimsDir?: string;
|
|
686
|
-
nowMs?: number;
|
|
687
|
-
ttlMs?: number;
|
|
688
|
-
} = {},
|
|
689
|
-
): boolean {
|
|
690
|
-
if (!messageKey) {
|
|
691
|
-
return false;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const claimsDir = options.claimsDir ?? INBOUND_MESSAGE_CLAIMS_DIR;
|
|
695
|
-
const nowMs = options.nowMs ?? Date.now();
|
|
696
|
-
const ttlMs = options.ttlMs ?? INBOUND_MESSAGE_CLAIM_TTL_MS;
|
|
697
|
-
const claimPath = buildInboundMessageClaimPath(messageKey, claimsDir);
|
|
698
|
-
|
|
699
|
-
const attemptClaim = (): boolean => {
|
|
700
|
-
fs.mkdirSync(claimsDir, { recursive: true });
|
|
701
|
-
const handle = fs.openSync(claimPath, "wx");
|
|
702
|
-
try {
|
|
703
|
-
fs.writeFileSync(
|
|
704
|
-
handle,
|
|
705
|
-
JSON.stringify(
|
|
706
|
-
{
|
|
707
|
-
key: messageKey,
|
|
708
|
-
claimedAt: new Date(nowMs).toISOString(),
|
|
709
|
-
pid: process.pid,
|
|
710
|
-
},
|
|
711
|
-
null,
|
|
712
|
-
2,
|
|
713
|
-
),
|
|
714
|
-
"utf-8",
|
|
715
|
-
);
|
|
716
|
-
} finally {
|
|
717
|
-
fs.closeSync(handle);
|
|
718
|
-
}
|
|
719
|
-
return true;
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
try {
|
|
723
|
-
return attemptClaim();
|
|
724
|
-
} catch (error) {
|
|
725
|
-
const code =
|
|
726
|
-
typeof error === "object" &&
|
|
727
|
-
error !== null &&
|
|
728
|
-
"code" in error &&
|
|
729
|
-
typeof (error as { code?: unknown }).code === "string"
|
|
730
|
-
? (error as { code: string }).code
|
|
731
|
-
: "";
|
|
732
|
-
if (code !== "EEXIST") {
|
|
733
|
-
return true;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
try {
|
|
738
|
-
const stat = fs.statSync(claimPath);
|
|
739
|
-
if (Number.isFinite(stat.mtimeMs) && nowMs - stat.mtimeMs > ttlMs) {
|
|
740
|
-
fs.rmSync(claimPath, { force: true });
|
|
741
|
-
return attemptClaim();
|
|
742
|
-
}
|
|
743
|
-
} catch {
|
|
744
|
-
return attemptClaim();
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
return false;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
function normalizeSender(senderId: string): string {
|
|
751
|
-
return senderId.split("@")[0] || senderId;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
function formatTimestamp(timestampMs?: number): string {
|
|
755
|
-
if (!timestampMs) {
|
|
756
|
-
return new Date().toISOString();
|
|
757
|
-
}
|
|
758
|
-
return new Date(timestampMs).toISOString();
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
export class WeChatTransport {
|
|
762
|
-
private readonly logger: TransportLogger;
|
|
763
|
-
private readonly recentMessageKeys = new Set<string>();
|
|
764
|
-
private readonly recentMessageOrder: string[] = [];
|
|
765
|
-
private readonly contextTokenCache = new Map<string, string>(
|
|
766
|
-
Object.entries(readJsonFile<ContextTokenState>(CONTEXT_CACHE_FILE) ?? {}),
|
|
767
|
-
);
|
|
768
|
-
private syncBuffer = "";
|
|
769
|
-
|
|
770
|
-
constructor(logger: TransportLogger) {
|
|
771
|
-
this.logger = logger;
|
|
772
|
-
migrateLegacyChannelFiles((message) => this.logger.log(message));
|
|
773
|
-
this.syncBuffer = this.readSyncBuffer();
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
getCredentials(): AccountData | null {
|
|
777
|
-
return readJsonFile<AccountData>(CREDENTIALS_FILE);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
getStatusText(): string {
|
|
781
|
-
const account = this.getCredentials();
|
|
782
|
-
const syncExists = fs.existsSync(SYNC_BUF_FILE);
|
|
783
|
-
const contextExists = fs.existsSync(CONTEXT_CACHE_FILE);
|
|
784
|
-
|
|
785
|
-
return [
|
|
786
|
-
`credentials_file: ${CREDENTIALS_FILE}`,
|
|
787
|
-
`credentials_present: ${account ? "yes" : "no"}`,
|
|
788
|
-
`sync_state_file: ${SYNC_BUF_FILE}`,
|
|
789
|
-
`sync_state_present: ${syncExists ? "yes" : "no"}`,
|
|
790
|
-
`context_cache_file: ${CONTEXT_CACHE_FILE}`,
|
|
791
|
-
`context_cache_present: ${contextExists ? "yes" : "no"}`,
|
|
792
|
-
`cached_context_count: ${this.contextTokenCache.size}`,
|
|
793
|
-
`max_image_mb: ${resolveMediaUploadLimitBytes("image") / BYTES_PER_MB}`,
|
|
794
|
-
`max_file_mb: ${resolveMediaUploadLimitBytes("file") / BYTES_PER_MB}`,
|
|
795
|
-
`max_voice_mb: ${resolveMediaUploadLimitBytes("voice") / BYTES_PER_MB}`,
|
|
796
|
-
`max_video_mb: ${resolveMediaUploadLimitBytes("video") / BYTES_PER_MB}`,
|
|
797
|
-
`account_id: ${account?.accountId ?? "(none)"}`,
|
|
798
|
-
`user_id: ${account?.userId ?? "(none)"}`,
|
|
799
|
-
`saved_at: ${account?.savedAt ?? "(none)"}`,
|
|
800
|
-
].join("\n");
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
resetSyncState(options: ResetSyncOptions = {}): string {
|
|
804
|
-
this.clearSyncBuffer();
|
|
805
|
-
this.clearRecentMessages();
|
|
806
|
-
clearInboundMessageClaims();
|
|
807
|
-
|
|
808
|
-
if (options.clearContextCache) {
|
|
809
|
-
this.clearContextTokenCache();
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
return options.clearContextCache
|
|
813
|
-
? "Reset sync state and cleared cached context tokens."
|
|
814
|
-
: "Reset sync state.";
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
async pollMessages(
|
|
818
|
-
options: PollMessagesOptions = {},
|
|
819
|
-
): Promise<PollMessagesResult> {
|
|
820
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
821
|
-
const account = this.requireAccount();
|
|
822
|
-
|
|
823
|
-
const response = await this.getUpdates(account, timeoutMs);
|
|
824
|
-
const isError =
|
|
825
|
-
(response.ret !== undefined && response.ret !== 0) ||
|
|
826
|
-
(response.errcode !== undefined && response.errcode !== 0);
|
|
827
|
-
|
|
828
|
-
if (isError) {
|
|
829
|
-
throw new Error(
|
|
830
|
-
`getUpdates failed: ret=${response.ret} errcode=${response.errcode} errmsg=${response.errmsg ?? ""}`,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (response.get_updates_buf) {
|
|
835
|
-
this.syncBuffer = response.get_updates_buf;
|
|
836
|
-
this.saveSyncBuffer(this.syncBuffer);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const messages: InboundWechatMessage[] = [];
|
|
840
|
-
let ignoredBacklogCount = 0;
|
|
841
|
-
|
|
842
|
-
for (const rawMessage of response.msgs ?? []) {
|
|
843
|
-
if (rawMessage.message_type !== MSG_TYPE_USER) {
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
const text = extractTextFromMessage(rawMessage);
|
|
848
|
-
if (!text) {
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const messageKey = buildMessageKey(rawMessage);
|
|
853
|
-
if (!this.rememberMessage(messageKey)) {
|
|
854
|
-
continue;
|
|
855
|
-
}
|
|
856
|
-
if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
|
|
857
|
-
continue;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const senderId = rawMessage.from_user_id ?? "unknown";
|
|
861
|
-
if (rawMessage.context_token) {
|
|
862
|
-
this.cacheContextToken(senderId, rawMessage.context_token);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
const createdAtMs = rawMessage.create_time_ms;
|
|
866
|
-
if (
|
|
867
|
-
typeof options.minCreatedAtMs === "number" &&
|
|
868
|
-
(!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)
|
|
869
|
-
) {
|
|
870
|
-
ignoredBacklogCount += 1;
|
|
871
|
-
continue;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
messages.push({
|
|
875
|
-
senderId,
|
|
876
|
-
sender: normalizeSender(senderId),
|
|
877
|
-
sessionId: rawMessage.session_id ?? "",
|
|
878
|
-
text,
|
|
879
|
-
contextToken: rawMessage.context_token,
|
|
880
|
-
createdAt: formatTimestamp(rawMessage.create_time_ms),
|
|
881
|
-
createdAtMs,
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
return { messages, ignoredBacklogCount };
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
async sendText(senderId: string, text: string): Promise<void> {
|
|
889
|
-
const trimmed = text.trim();
|
|
890
|
-
if (!trimmed) {
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const resolved = this.resolveRecipient(senderId);
|
|
895
|
-
await this.sendTextWithContextToken(
|
|
896
|
-
resolved.account,
|
|
897
|
-
resolved.recipientId,
|
|
898
|
-
trimmed,
|
|
899
|
-
resolved.contextToken,
|
|
900
|
-
);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
async sendNotification(message: string, recipientId?: string): Promise<string> {
|
|
904
|
-
const trimmed = message.trim();
|
|
905
|
-
if (!trimmed) {
|
|
906
|
-
throw new Error("Notification text cannot be empty.");
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
const resolved = this.resolveRecipient(recipientId);
|
|
910
|
-
await this.sendTextWithContextToken(
|
|
911
|
-
resolved.account,
|
|
912
|
-
resolved.recipientId,
|
|
913
|
-
trimmed,
|
|
914
|
-
resolved.contextToken,
|
|
915
|
-
);
|
|
916
|
-
return resolved.recipientId;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
async sendImage(imagePath: string, options: SendImageOptions = {}): Promise<string> {
|
|
920
|
-
const resolved = this.resolveRecipient(options.recipientId);
|
|
921
|
-
const caption = options.caption?.trim();
|
|
922
|
-
|
|
923
|
-
if (caption) {
|
|
924
|
-
await this.sendTextWithContextToken(
|
|
925
|
-
resolved.account,
|
|
926
|
-
resolved.recipientId,
|
|
927
|
-
caption,
|
|
928
|
-
resolved.contextToken,
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const upload = await this.prepareUpload(
|
|
933
|
-
resolved.account,
|
|
934
|
-
resolved.recipientId,
|
|
935
|
-
imagePath,
|
|
936
|
-
UPLOAD_MEDIA_TYPE_IMAGE,
|
|
937
|
-
"image",
|
|
938
|
-
);
|
|
939
|
-
|
|
940
|
-
await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
|
|
941
|
-
{
|
|
942
|
-
type: MSG_ITEM_IMAGE,
|
|
943
|
-
image_item: {
|
|
944
|
-
media: {
|
|
945
|
-
encrypt_query_param: upload.downloadParam,
|
|
946
|
-
aes_key: encodeMessageAesKey(upload.aeskey),
|
|
947
|
-
encrypt_type: 1,
|
|
948
|
-
},
|
|
949
|
-
mid_size: upload.filesize,
|
|
950
|
-
},
|
|
951
|
-
},
|
|
952
|
-
]);
|
|
953
|
-
|
|
954
|
-
return resolved.recipientId;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
async sendFile(filePath: string, options: SendFileOptions = {}): Promise<string> {
|
|
958
|
-
const resolved = this.resolveRecipient(options.recipientId);
|
|
959
|
-
const upload = await this.prepareUpload(
|
|
960
|
-
resolved.account,
|
|
961
|
-
resolved.recipientId,
|
|
962
|
-
filePath,
|
|
963
|
-
UPLOAD_MEDIA_TYPE_FILE,
|
|
964
|
-
"file",
|
|
965
|
-
);
|
|
966
|
-
const fileName = options.title?.trim() || path.basename(filePath);
|
|
967
|
-
|
|
968
|
-
await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
|
|
969
|
-
{
|
|
970
|
-
type: MSG_ITEM_FILE,
|
|
971
|
-
file_item: {
|
|
972
|
-
file_name: fileName,
|
|
973
|
-
len: String(upload.rawsize),
|
|
974
|
-
media: {
|
|
975
|
-
encrypt_query_param: upload.downloadParam,
|
|
976
|
-
aes_key: encodeMessageAesKey(upload.aeskey),
|
|
977
|
-
encrypt_type: 1,
|
|
978
|
-
},
|
|
979
|
-
},
|
|
980
|
-
},
|
|
981
|
-
]);
|
|
982
|
-
|
|
983
|
-
return resolved.recipientId;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
async sendVoice(voicePath: string, recipientId?: string): Promise<string> {
|
|
987
|
-
const resolved = this.resolveRecipient(recipientId);
|
|
988
|
-
const upload = await this.prepareUpload(
|
|
989
|
-
resolved.account,
|
|
990
|
-
resolved.recipientId,
|
|
991
|
-
voicePath,
|
|
992
|
-
UPLOAD_MEDIA_TYPE_VOICE,
|
|
993
|
-
"voice",
|
|
994
|
-
);
|
|
995
|
-
|
|
996
|
-
await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
|
|
997
|
-
{
|
|
998
|
-
type: MSG_ITEM_VOICE,
|
|
999
|
-
voice_item: {
|
|
1000
|
-
media: {
|
|
1001
|
-
encrypt_query_param: upload.downloadParam,
|
|
1002
|
-
aes_key: encodeMessageAesKey(upload.aeskey),
|
|
1003
|
-
encrypt_type: 1,
|
|
1004
|
-
},
|
|
1005
|
-
},
|
|
1006
|
-
},
|
|
1007
|
-
]);
|
|
1008
|
-
|
|
1009
|
-
return resolved.recipientId;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
async sendVideo(videoPath: string, options: SendVideoOptions = {}): Promise<string> {
|
|
1013
|
-
const resolved = this.resolveRecipient(options.recipientId);
|
|
1014
|
-
const title = options.title?.trim();
|
|
1015
|
-
|
|
1016
|
-
if (title) {
|
|
1017
|
-
await this.sendTextWithContextToken(
|
|
1018
|
-
resolved.account,
|
|
1019
|
-
resolved.recipientId,
|
|
1020
|
-
title,
|
|
1021
|
-
resolved.contextToken,
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const upload = await this.prepareUpload(
|
|
1026
|
-
resolved.account,
|
|
1027
|
-
resolved.recipientId,
|
|
1028
|
-
videoPath,
|
|
1029
|
-
UPLOAD_MEDIA_TYPE_VIDEO,
|
|
1030
|
-
"video",
|
|
1031
|
-
);
|
|
1032
|
-
|
|
1033
|
-
await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
|
|
1034
|
-
{
|
|
1035
|
-
type: MSG_ITEM_VIDEO,
|
|
1036
|
-
video_item: {
|
|
1037
|
-
media: {
|
|
1038
|
-
encrypt_query_param: upload.downloadParam,
|
|
1039
|
-
aes_key: encodeMessageAesKey(upload.aeskey),
|
|
1040
|
-
encrypt_type: 1,
|
|
1041
|
-
},
|
|
1042
|
-
video_size: upload.filesize,
|
|
1043
|
-
},
|
|
1044
|
-
},
|
|
1045
|
-
]);
|
|
1046
|
-
|
|
1047
|
-
return resolved.recipientId;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
private requireAccount(): AccountData {
|
|
1051
|
-
const account = this.getCredentials();
|
|
1052
|
-
if (!account) {
|
|
1053
|
-
throw new Error(
|
|
1054
|
-
`No saved WeChat credentials found. Run "bun run setup" first. Expected file: ${CREDENTIALS_FILE}`,
|
|
1055
|
-
);
|
|
1056
|
-
}
|
|
1057
|
-
return account;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
private resolveRecipient(recipientId?: string): ResolvedRecipient {
|
|
1061
|
-
const account = this.requireAccount();
|
|
1062
|
-
|
|
1063
|
-
let resolvedRecipientId = recipientId?.trim();
|
|
1064
|
-
if (!resolvedRecipientId) {
|
|
1065
|
-
const recipients = [...this.contextTokenCache.keys()];
|
|
1066
|
-
resolvedRecipientId = recipients[recipients.length - 1];
|
|
1067
|
-
if (!resolvedRecipientId) {
|
|
1068
|
-
throw new Error(
|
|
1069
|
-
"No cached context token is available. Fetch messages first or ask the user to send a new WeChat message.",
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const contextToken = this.contextTokenCache.get(resolvedRecipientId);
|
|
1075
|
-
if (!contextToken) {
|
|
1076
|
-
throw new Error(
|
|
1077
|
-
`No cached context token for ${resolvedRecipientId}. Fetch messages first or ask the user to send a new WeChat message.`,
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
return { account, recipientId: resolvedRecipientId, contextToken };
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
private async sendTextWithContextToken(
|
|
1085
|
-
account: AccountData,
|
|
1086
|
-
recipientId: string,
|
|
1087
|
-
text: string,
|
|
1088
|
-
contextToken: string,
|
|
1089
|
-
): Promise<void> {
|
|
1090
|
-
const trimmed = text.trim();
|
|
1091
|
-
if (!trimmed) {
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
await this.sendMessage(account, recipientId, contextToken, [
|
|
1096
|
-
{ type: MSG_ITEM_TEXT, text_item: { text: trimmed } },
|
|
1097
|
-
]);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
private async sendMessage(
|
|
1101
|
-
account: AccountData,
|
|
1102
|
-
recipientId: string,
|
|
1103
|
-
contextToken: string,
|
|
1104
|
-
itemList: unknown[],
|
|
1105
|
-
): Promise<void> {
|
|
1106
|
-
await apiFetch({
|
|
1107
|
-
baseUrl: account.baseUrl,
|
|
1108
|
-
endpoint: "ilink/bot/sendmessage",
|
|
1109
|
-
body: JSON.stringify({
|
|
1110
|
-
msg: {
|
|
1111
|
-
from_user_id: "",
|
|
1112
|
-
to_user_id: recipientId,
|
|
1113
|
-
client_id: this.generateClientId(),
|
|
1114
|
-
message_type: MSG_TYPE_BOT,
|
|
1115
|
-
message_state: MSG_STATE_FINISH,
|
|
1116
|
-
item_list: itemList,
|
|
1117
|
-
context_token: contextToken,
|
|
1118
|
-
},
|
|
1119
|
-
base_info: { channel_version: CHANNEL_VERSION },
|
|
1120
|
-
}),
|
|
1121
|
-
token: account.token,
|
|
1122
|
-
timeoutMs: SEND_TIMEOUT_MS,
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
private async prepareUpload(
|
|
1127
|
-
account: AccountData,
|
|
1128
|
-
recipientId: string,
|
|
1129
|
-
filePath: string,
|
|
1130
|
-
mediaType: number,
|
|
1131
|
-
label: UploadLabel,
|
|
1132
|
-
): Promise<UploadPreparation> {
|
|
1133
|
-
const stat = this.requireExistingFile(filePath);
|
|
1134
|
-
assertMediaUploadSizeAllowed(label, stat.size);
|
|
1135
|
-
|
|
1136
|
-
const plaintext = fs.readFileSync(filePath);
|
|
1137
|
-
const rawsize = plaintext.length;
|
|
1138
|
-
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
1139
|
-
const filesize = aesEcbPaddedSize(rawsize);
|
|
1140
|
-
const filekey = crypto.randomBytes(16).toString("hex");
|
|
1141
|
-
const aeskey = crypto.randomBytes(16);
|
|
1142
|
-
|
|
1143
|
-
this.logger.log(
|
|
1144
|
-
`Uploading ${label}: ${filePath} (${rawsize} bytes, md5=${rawfilemd5})`,
|
|
1145
|
-
);
|
|
1146
|
-
|
|
1147
|
-
const uploadResp = await getUploadUrl(account, {
|
|
1148
|
-
filekey,
|
|
1149
|
-
media_type: mediaType,
|
|
1150
|
-
to_user_id: recipientId,
|
|
1151
|
-
rawsize,
|
|
1152
|
-
rawfilemd5,
|
|
1153
|
-
filesize,
|
|
1154
|
-
aeskey: aeskey.toString("hex"),
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
if (!uploadResp.upload_param) {
|
|
1158
|
-
throw new Error("getUploadUrl returned no upload_param");
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const { downloadParam } = await uploadBufferToCdn({
|
|
1162
|
-
buf: plaintext,
|
|
1163
|
-
uploadParam: uploadResp.upload_param,
|
|
1164
|
-
filekey,
|
|
1165
|
-
aeskey,
|
|
1166
|
-
onRetry: (attempt) => {
|
|
1167
|
-
this.logger.log(
|
|
1168
|
-
`CDN upload attempt ${attempt} failed for ${label}, retrying...`,
|
|
1169
|
-
);
|
|
1170
|
-
},
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
this.logger.log(
|
|
1174
|
-
`${label} upload complete, downloadParam length=${downloadParam.length}`,
|
|
1175
|
-
);
|
|
1176
|
-
|
|
1177
|
-
return {
|
|
1178
|
-
rawsize,
|
|
1179
|
-
filesize,
|
|
1180
|
-
aeskey,
|
|
1181
|
-
downloadParam,
|
|
1182
|
-
};
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
private requireExistingFile(filePath: string): fs.Stats {
|
|
1186
|
-
let stat: fs.Stats;
|
|
1187
|
-
try {
|
|
1188
|
-
stat = fs.statSync(filePath);
|
|
1189
|
-
} catch {
|
|
1190
|
-
throw new Error(`File not found: ${filePath}`);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
if (!stat.isFile()) {
|
|
1194
|
-
throw new Error(`Not a file: ${filePath}`);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
return stat;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
private async getUpdates(
|
|
1201
|
-
account: AccountData,
|
|
1202
|
-
timeoutMs: number,
|
|
1203
|
-
): Promise<GetUpdatesResp> {
|
|
1204
|
-
try {
|
|
1205
|
-
const raw = await apiFetch({
|
|
1206
|
-
baseUrl: account.baseUrl,
|
|
1207
|
-
endpoint: "ilink/bot/getupdates",
|
|
1208
|
-
body: JSON.stringify({
|
|
1209
|
-
get_updates_buf: this.syncBuffer,
|
|
1210
|
-
base_info: { channel_version: CHANNEL_VERSION },
|
|
1211
|
-
}),
|
|
1212
|
-
token: account.token,
|
|
1213
|
-
timeoutMs,
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
return JSON.parse(raw) as GetUpdatesResp;
|
|
1217
|
-
} catch (err) {
|
|
1218
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
1219
|
-
return { ret: 0, msgs: [], get_updates_buf: this.syncBuffer };
|
|
1220
|
-
}
|
|
1221
|
-
throw err;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
private rememberMessage(key: string): boolean {
|
|
1226
|
-
if (!key || this.recentMessageKeys.has(key)) {
|
|
1227
|
-
return false;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
this.recentMessageKeys.add(key);
|
|
1231
|
-
this.recentMessageOrder.push(key);
|
|
1232
|
-
|
|
1233
|
-
while (this.recentMessageOrder.length > RECENT_MESSAGE_CACHE_SIZE) {
|
|
1234
|
-
const oldest = this.recentMessageOrder.shift();
|
|
1235
|
-
if (oldest) {
|
|
1236
|
-
this.recentMessageKeys.delete(oldest);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
return true;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
private clearRecentMessages(): void {
|
|
1244
|
-
this.recentMessageKeys.clear();
|
|
1245
|
-
this.recentMessageOrder.length = 0;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
private readSyncBuffer(): string {
|
|
1249
|
-
try {
|
|
1250
|
-
if (!fs.existsSync(SYNC_BUF_FILE)) {
|
|
1251
|
-
return "";
|
|
1252
|
-
}
|
|
1253
|
-
return fs.readFileSync(SYNC_BUF_FILE, "utf-8");
|
|
1254
|
-
} catch (err) {
|
|
1255
|
-
this.logger.logError(`Failed to read sync state: ${String(err)}`);
|
|
1256
|
-
return "";
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
private saveSyncBuffer(syncBuffer: string): void {
|
|
1261
|
-
ensureChannelDataDir();
|
|
1262
|
-
fs.writeFileSync(SYNC_BUF_FILE, syncBuffer, "utf-8");
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
private clearSyncBuffer(): void {
|
|
1266
|
-
this.syncBuffer = "";
|
|
1267
|
-
if (fs.existsSync(SYNC_BUF_FILE)) {
|
|
1268
|
-
fs.rmSync(SYNC_BUF_FILE, { force: true });
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
private cacheContextToken(senderId: string, token: string): void {
|
|
1273
|
-
if (this.contextTokenCache.has(senderId)) {
|
|
1274
|
-
this.contextTokenCache.delete(senderId);
|
|
1275
|
-
}
|
|
1276
|
-
this.contextTokenCache.set(senderId, token);
|
|
1277
|
-
writeJsonFile(CONTEXT_CACHE_FILE, Object.fromEntries(this.contextTokenCache));
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
private clearContextTokenCache(): void {
|
|
1281
|
-
this.contextTokenCache.clear();
|
|
1282
|
-
if (fs.existsSync(CONTEXT_CACHE_FILE)) {
|
|
1283
|
-
fs.rmSync(CONTEXT_CACHE_FILE, { force: true });
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
private generateClientId(): string {
|
|
1288
|
-
return `wechat-bridge:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1289
|
-
}
|
|
1290
|
-
}
|