@cjhyy/code-shell-chat 0.8.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 +21 -0
- package/README.md +416 -0
- package/dist/adapter-factory.d.ts +30 -0
- package/dist/adapter-factory.js +198 -0
- package/dist/attachments.d.ts +13 -0
- package/dist/attachments.js +74 -0
- package/dist/channel.d.ts +112 -0
- package/dist/channel.js +60 -0
- package/dist/chat-gateway.d.ts +58 -0
- package/dist/chat-gateway.js +232 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +259 -0
- package/dist/codeshell.d.ts +6 -0
- package/dist/codeshell.js +6 -0
- package/dist/config.d.ts +125 -0
- package/dist/config.js +469 -0
- package/dist/delivery-queue.d.ts +93 -0
- package/dist/delivery-queue.js +401 -0
- package/dist/desktop-control-client.d.ts +51 -0
- package/dist/desktop-control-client.js +309 -0
- package/dist/dingtalk.d.ts +19 -0
- package/dist/dingtalk.js +93 -0
- package/dist/discord.d.ts +23 -0
- package/dist/discord.js +134 -0
- package/dist/gateway.d.ts +34 -0
- package/dist/gateway.js +287 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/ingress.d.ts +8 -0
- package/dist/ingress.js +61 -0
- package/dist/instance-lock.d.ts +24 -0
- package/dist/instance-lock.js +100 -0
- package/dist/lark.d.ts +20 -0
- package/dist/lark.js +184 -0
- package/dist/lifecycle.d.ts +7 -0
- package/dist/lifecycle.js +16 -0
- package/dist/line.d.ts +17 -0
- package/dist/line.js +123 -0
- package/dist/matrix.d.ts +21 -0
- package/dist/matrix.js +208 -0
- package/dist/mattermost.d.ts +18 -0
- package/dist/mattermost.js +170 -0
- package/dist/media.d.ts +39 -0
- package/dist/media.js +223 -0
- package/dist/notification-progress.d.ts +46 -0
- package/dist/notification-progress.js +299 -0
- package/dist/notification-relay.d.ts +43 -0
- package/dist/notification-relay.js +285 -0
- package/dist/platform-canary.d.ts +22 -0
- package/dist/platform-canary.js +55 -0
- package/dist/protocol.d.ts +102 -0
- package/dist/protocol.js +1 -0
- package/dist/service-manager.d.ts +47 -0
- package/dist/service-manager.js +222 -0
- package/dist/slack.d.ts +17 -0
- package/dist/slack.js +136 -0
- package/dist/teams.d.ts +26 -0
- package/dist/teams.js +193 -0
- package/dist/telegram.d.ts +33 -0
- package/dist/telegram.js +263 -0
- package/dist/webhook.d.ts +26 -0
- package/dist/webhook.js +91 -0
- package/dist/wechat-login.d.ts +21 -0
- package/dist/wechat-login.js +97 -0
- package/dist/wechat-storage.d.ts +61 -0
- package/dist/wechat-storage.js +251 -0
- package/dist/wechat.d.ts +129 -0
- package/dist/wechat.js +1082 -0
- package/dist/wecom.d.ts +16 -0
- package/dist/wecom.js +149 -0
- package/dist/whatsapp.d.ts +27 -0
- package/dist/whatsapp.js +218 -0
- package/package.json +123 -0
package/dist/wechat.js
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
3
|
+
import { OutgoingDeliveryTracker, outgoingAttachments } from "./media.js";
|
|
4
|
+
import { normalizeWechatAccountId, WechatStateOwnershipError, } from "./wechat-storage.js";
|
|
5
|
+
export * from "./wechat-storage.js";
|
|
6
|
+
export const WECHAT_DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
7
|
+
export const WECHAT_PROTOCOL_VERSION = "2.4.6";
|
|
8
|
+
export const WECHAT_DEFAULT_BOT_AGENT = "CodeShellChat/0.7.1";
|
|
9
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
10
|
+
const DEFAULT_MESSAGE_AGE_MS = 5 * 60_000;
|
|
11
|
+
const DEFAULT_RATE_LIMIT_RETRIES = 2;
|
|
12
|
+
const DEFAULT_RATE_LIMIT_RETRY_BASE_MS = 500;
|
|
13
|
+
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 30_000;
|
|
14
|
+
const WECHAT_CDN_UPLOAD_ATTEMPTS = 3;
|
|
15
|
+
const WECHAT_CDN_UPLOAD_RETRY_BASE_MS = 250;
|
|
16
|
+
const DEFAULT_BOT_TYPE = "3";
|
|
17
|
+
const MESSAGE_TYPE_USER = 1;
|
|
18
|
+
const MESSAGE_TYPE_BOT = 2;
|
|
19
|
+
const MESSAGE_STATE_GENERATING = 1;
|
|
20
|
+
const MESSAGE_STATE_FINISH = 2;
|
|
21
|
+
const ITEM_TYPE_TEXT = 1;
|
|
22
|
+
const ITEM_TYPE_IMAGE = 2;
|
|
23
|
+
const ITEM_TYPE_VOICE = 3;
|
|
24
|
+
const ITEM_TYPE_FILE = 4;
|
|
25
|
+
const ITEM_TYPE_VIDEO = 5;
|
|
26
|
+
const WECHAT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
27
|
+
const WECHAT_CDN_DOWNLOAD_HOSTS = new Set([
|
|
28
|
+
"novac2c.cdn.weixin.qq.com",
|
|
29
|
+
"ilinkai.weixin.qq.com",
|
|
30
|
+
"wx.qlogo.cn",
|
|
31
|
+
"thirdwx.qlogo.cn",
|
|
32
|
+
"res.wx.qq.com",
|
|
33
|
+
"mmbiz.qpic.cn",
|
|
34
|
+
"mmbiz.qlogo.cn",
|
|
35
|
+
]);
|
|
36
|
+
const WECHAT_CDN_MAX_REDIRECTS = 5;
|
|
37
|
+
const accountSendTails = new Map();
|
|
38
|
+
const rateLimitCooldownUntil = new Map();
|
|
39
|
+
const liveWechatAdapters = new Map();
|
|
40
|
+
/** Personal WeChat ClawBot adapter using Tencent's documented iLink Bot HTTP protocol. */
|
|
41
|
+
export class WechatAdapter {
|
|
42
|
+
config;
|
|
43
|
+
channel = "wechat";
|
|
44
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.wechat;
|
|
45
|
+
supportsOutgoingAttachments = true;
|
|
46
|
+
fetchFn;
|
|
47
|
+
sleepFn;
|
|
48
|
+
log;
|
|
49
|
+
stateStore;
|
|
50
|
+
now;
|
|
51
|
+
maxMessageAgeMs;
|
|
52
|
+
rateLimitRetries;
|
|
53
|
+
rateLimitRetryBaseMs;
|
|
54
|
+
rateLimitCooldownMs;
|
|
55
|
+
accountKey;
|
|
56
|
+
credentialKey;
|
|
57
|
+
extraCdnDownloadHosts;
|
|
58
|
+
baseUrl;
|
|
59
|
+
protocolVersion;
|
|
60
|
+
botAgent;
|
|
61
|
+
state = { contextTokens: {} };
|
|
62
|
+
stateReady;
|
|
63
|
+
stateWrite = Promise.resolve();
|
|
64
|
+
/** Set once the state file is owned by a newer QR binding; never cleared. */
|
|
65
|
+
staleCredential = false;
|
|
66
|
+
/** Preserve visible ordering and keep context/media state mutations single-writer. */
|
|
67
|
+
outboundQueue = Promise.resolve();
|
|
68
|
+
seenMessageIds = new Set();
|
|
69
|
+
delivery = new OutgoingDeliveryTracker();
|
|
70
|
+
constructor(config, options = {}) {
|
|
71
|
+
this.config = config;
|
|
72
|
+
if (!config.accountId.trim())
|
|
73
|
+
throw new Error("微信 accountId 不能为空");
|
|
74
|
+
if (!config.token.trim())
|
|
75
|
+
throw new Error("微信 bot token 不能为空");
|
|
76
|
+
this.baseUrl = validateWechatBaseUrl(config.baseUrl ?? WECHAT_DEFAULT_BASE_URL, config.allowUnsafeBaseUrl ?? false);
|
|
77
|
+
this.accountKey = wechatAccountKey(this.baseUrl, config.accountId);
|
|
78
|
+
this.credentialKey = wechatCredentialKey(this.baseUrl, config.accountId, config.token);
|
|
79
|
+
this.extraCdnDownloadHosts = validateExtraWechatCdnDownloadHosts(config.extraCdnDownloadHosts);
|
|
80
|
+
this.protocolVersion = config.protocolVersion ?? WECHAT_PROTOCOL_VERSION;
|
|
81
|
+
this.botAgent = sanitizeBotAgent(config.botAgent ?? WECHAT_DEFAULT_BOT_AGENT);
|
|
82
|
+
this.fetchFn = options.fetch ?? fetch;
|
|
83
|
+
this.sleepFn = options.sleep ?? abortableDelay;
|
|
84
|
+
this.log = options.log ?? (() => undefined);
|
|
85
|
+
this.stateStore = options.stateStore;
|
|
86
|
+
this.now = options.now ?? Date.now;
|
|
87
|
+
this.maxMessageAgeMs = options.maxMessageAgeMs ?? DEFAULT_MESSAGE_AGE_MS;
|
|
88
|
+
this.rateLimitRetries = boundedInteger(options.rateLimitRetries, DEFAULT_RATE_LIMIT_RETRIES, 0, 5, "微信限流重试次数");
|
|
89
|
+
this.rateLimitRetryBaseMs = boundedInteger(options.rateLimitRetryBaseMs, DEFAULT_RATE_LIMIT_RETRY_BASE_MS, 1, 30_000, "微信限流退避时间");
|
|
90
|
+
this.rateLimitCooldownMs = boundedInteger(options.rateLimitCooldownMs, DEFAULT_RATE_LIMIT_COOLDOWN_MS, 0, 5 * 60_000, "微信限流冷却时间");
|
|
91
|
+
}
|
|
92
|
+
async run(handler, signal) {
|
|
93
|
+
await this.ensureState();
|
|
94
|
+
if (liveWechatAdapters.has(this.accountKey)) {
|
|
95
|
+
throw new Error("同一微信账号已有一个长轮询 adapter 在运行");
|
|
96
|
+
}
|
|
97
|
+
liveWechatAdapters.set(this.accountKey, { credentialKey: this.credentialKey, adapter: this });
|
|
98
|
+
let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
99
|
+
let retryMs = 1_000;
|
|
100
|
+
try {
|
|
101
|
+
await withWechatAccountSendLock(this.accountKey, () => this.notifyLifecycle("notifystart"));
|
|
102
|
+
while (!signal.aborted) {
|
|
103
|
+
if (this.staleCredential) {
|
|
104
|
+
// Continuing to poll would consume messages the fresh binding owns
|
|
105
|
+
// and re-offer state this process can no longer persist.
|
|
106
|
+
throw new Error("微信状态文件已被新的扫码绑定接管,长轮询停止;请重启 Gateway 使用新凭据");
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const response = await this.post("ilink/bot/getupdates", {
|
|
110
|
+
get_updates_buf: this.state.cursor ?? "",
|
|
111
|
+
base_info: this.baseInfo(),
|
|
112
|
+
}, nextTimeoutMs, signal);
|
|
113
|
+
retryMs = 1_000;
|
|
114
|
+
if (response.longpolling_timeout_ms && response.longpolling_timeout_ms > 0) {
|
|
115
|
+
nextTimeoutMs = response.longpolling_timeout_ms;
|
|
116
|
+
}
|
|
117
|
+
const errorCode = response.errcode || response.ret;
|
|
118
|
+
if (errorCode && errorCode !== 0) {
|
|
119
|
+
if (errorCode === -14) {
|
|
120
|
+
throw new Error("微信登录已失效,请重新执行 code-shell-chat wechat login");
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`微信 getUpdates 失败:${response.errmsg ?? `ret=${errorCode}`}`);
|
|
123
|
+
}
|
|
124
|
+
let batchAccepted = true;
|
|
125
|
+
for (const raw of response.msgs ?? []) {
|
|
126
|
+
const message = this.normalizeInbound(raw);
|
|
127
|
+
if (!message || this.isDuplicate(message.messageId))
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
if (raw.context_token) {
|
|
131
|
+
this.state.contextTokens ??= {};
|
|
132
|
+
this.state.contextTokens[message.target] = raw.context_token;
|
|
133
|
+
await this.persistState();
|
|
134
|
+
}
|
|
135
|
+
await handler(message);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
batchAccepted = false;
|
|
139
|
+
this.forgetDuplicate(message.messageId);
|
|
140
|
+
this.log(`微信消息处理失败:${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!batchAccepted) {
|
|
144
|
+
throw new Error("微信消息未被上层接收,保留游标等待重投");
|
|
145
|
+
}
|
|
146
|
+
if (response.get_updates_buf) {
|
|
147
|
+
await this.commitCursor(response.get_updates_buf);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (signal.aborted)
|
|
152
|
+
return;
|
|
153
|
+
if (error instanceof WechatRequestTimeoutError)
|
|
154
|
+
continue;
|
|
155
|
+
this.log(`微信长轮询失败,${retryMs}ms 后重试:${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
await this.sleepFn(retryMs, signal);
|
|
157
|
+
retryMs = Math.min(retryMs * 2, 30_000);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
// Stop advertising this adapter before yielding, so new direct sends use
|
|
163
|
+
// the one-shot route. A caller that already observed the live entry has
|
|
164
|
+
// synchronously enqueued its send; drain that queue before notifystop so
|
|
165
|
+
// accepted work cannot race behind the session shutdown.
|
|
166
|
+
if (liveWechatAdapters.get(this.accountKey)?.adapter === this) {
|
|
167
|
+
liveWechatAdapters.delete(this.accountKey);
|
|
168
|
+
}
|
|
169
|
+
await this.outboundQueue.catch(() => undefined);
|
|
170
|
+
await withWechatAccountSendLock(this.accountKey, () => this.notifyLifecycle("notifystop"));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
send(target, message) {
|
|
174
|
+
const operation = this.outboundQueue.then(() => withWechatAccountSendLock(this.accountKey, () => this.sendInOrder(target, message)));
|
|
175
|
+
this.outboundQueue = operation.catch(() => undefined);
|
|
176
|
+
return operation;
|
|
177
|
+
}
|
|
178
|
+
async sendInOrder(target, message) {
|
|
179
|
+
await this.ensureState();
|
|
180
|
+
const text = message.button
|
|
181
|
+
? `${message.text}\n\n${message.button.text}: ${message.button.url}`
|
|
182
|
+
: message.text;
|
|
183
|
+
const attachments = outgoingAttachments(message, this.capabilities.outbound.attachments);
|
|
184
|
+
const textChunks = splitWechatText(text, this.capabilities.outbound.maxTextLength ?? 8_000);
|
|
185
|
+
if (textChunks.length === 0 && attachments.length === 0) {
|
|
186
|
+
throw new Error("微信待发送消息不能为空");
|
|
187
|
+
}
|
|
188
|
+
// iLink accepts exactly one MessageItem per sendmessage call. In
|
|
189
|
+
// particular, a text caption and a media item must be sent separately;
|
|
190
|
+
// combining them is rejected by the service as "invalid arguments".
|
|
191
|
+
await this.delivery.run(message, () => [
|
|
192
|
+
...textChunks.map((chunk) => {
|
|
193
|
+
// Keep the platform id stable across DeliveryQueue retries. A request
|
|
194
|
+
// can reach WeChat successfully while its HTTP acknowledgement is
|
|
195
|
+
// lost; retrying with a fresh id visibly duplicates the reply.
|
|
196
|
+
const clientId = `code-shell-chat-${randomUUID()}`;
|
|
197
|
+
return () => this.sendItem(target, { type: ITEM_TYPE_TEXT, text_item: { text: chunk } }, clientId);
|
|
198
|
+
}),
|
|
199
|
+
...attachments.map((attachment) => {
|
|
200
|
+
const clientId = `code-shell-chat-${randomUUID()}`;
|
|
201
|
+
return async () => {
|
|
202
|
+
const item = attachment.kind === "image"
|
|
203
|
+
? await this.uploadImage(target, attachment)
|
|
204
|
+
: attachment.kind === "video"
|
|
205
|
+
? await this.uploadVideo(target, attachment)
|
|
206
|
+
: // Arbitrary audio lacks the SILK metadata required by native
|
|
207
|
+
// voice bubbles, so deliver it as a playable file attachment.
|
|
208
|
+
await this.uploadFile(target, attachment);
|
|
209
|
+
await this.sendItem(target, item, clientId);
|
|
210
|
+
};
|
|
211
|
+
}),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
async sendItem(target, item, clientId) {
|
|
215
|
+
const contextToken = this.state.contextTokens?.[target];
|
|
216
|
+
const first = await this.postWechatMessageWithRateLimitRetry(target, item, clientId, contextToken);
|
|
217
|
+
if (!wechatResponseFailed(first))
|
|
218
|
+
return;
|
|
219
|
+
// Tencent's client attempts sends without context_token, although the
|
|
220
|
+
// documented contract remains context-bound and some accounts reject it.
|
|
221
|
+
// Clear only the exact stale token and try the compatible-backend fallback
|
|
222
|
+
// once. Reusing client_id lets the platform deduplicate if the first
|
|
223
|
+
// request reached deeper than its error suggests.
|
|
224
|
+
if (contextToken && isStaleWechatContextResponse(first)) {
|
|
225
|
+
try {
|
|
226
|
+
await this.clearContextToken(target, contextToken);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
// Persistence is a cache concern, not a delivery prerequisite. Keep the
|
|
230
|
+
// in-memory token cleared and perform the safe tokenless retry.
|
|
231
|
+
this.log(`微信失效上下文未能写回状态文件:${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
this.log(`微信会话上下文已失效,已清除并尝试无上下文直发:${wechatResponseDetail(first)}`);
|
|
234
|
+
const fallback = await this.postWechatMessageWithRateLimitRetry(target, item, clientId);
|
|
235
|
+
if (!wechatResponseFailed(fallback))
|
|
236
|
+
return;
|
|
237
|
+
throw new Error(wechatSendFailure(fallback, true));
|
|
238
|
+
}
|
|
239
|
+
throw new Error(wechatSendFailure(first, contextToken === undefined));
|
|
240
|
+
}
|
|
241
|
+
async postWechatMessage(target, item, clientId, contextToken) {
|
|
242
|
+
try {
|
|
243
|
+
return await this.post("ilink/bot/sendmessage", {
|
|
244
|
+
msg: {
|
|
245
|
+
from_user_id: "",
|
|
246
|
+
to_user_id: target,
|
|
247
|
+
client_id: clientId,
|
|
248
|
+
message_type: MESSAGE_TYPE_BOT,
|
|
249
|
+
message_state: MESSAGE_STATE_FINISH,
|
|
250
|
+
item_list: [item],
|
|
251
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
252
|
+
},
|
|
253
|
+
base_info: this.baseInfo(),
|
|
254
|
+
}, 15_000);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
// HTTP 429 is an explicit rejection, unlike a socket/timeout failure
|
|
258
|
+
// whose acceptance outcome is unknown. Normalize only that status into
|
|
259
|
+
// the same safe retry path as iLink ret=-2.
|
|
260
|
+
if (error instanceof WechatHttpError && error.status === 429) {
|
|
261
|
+
return { ret: 429, errmsg: error.message };
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async postWechatMessageWithRateLimitRetry(target, item, clientId, contextToken) {
|
|
267
|
+
this.assertRateLimitCircuitClosed();
|
|
268
|
+
let response = await this.postWechatMessage(target, item, clientId, contextToken);
|
|
269
|
+
for (let attempt = 0; attempt < this.rateLimitRetries; attempt += 1) {
|
|
270
|
+
if (!isExplicitWechatRateLimitResponse(response))
|
|
271
|
+
break;
|
|
272
|
+
const delayMs = Math.min(this.rateLimitRetryBaseMs * 2 ** attempt, 30_000);
|
|
273
|
+
this.log(`微信发送被限流,${delayMs}ms 后重试(${attempt + 1}/${this.rateLimitRetries})`);
|
|
274
|
+
await this.sleepFn(delayMs, new AbortController().signal);
|
|
275
|
+
response = await this.postWechatMessage(target, item, clientId, contextToken);
|
|
276
|
+
}
|
|
277
|
+
if (isExplicitWechatRateLimitResponse(response)) {
|
|
278
|
+
if (this.rateLimitCooldownMs > 0) {
|
|
279
|
+
rateLimitCooldownUntil.set(this.accountKey, this.now() + this.rateLimitCooldownMs);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (!wechatResponseFailed(response)) {
|
|
283
|
+
rateLimitCooldownUntil.delete(this.accountKey);
|
|
284
|
+
}
|
|
285
|
+
return response;
|
|
286
|
+
}
|
|
287
|
+
assertRateLimitCircuitClosed() {
|
|
288
|
+
const until = rateLimitCooldownUntil.get(this.accountKey);
|
|
289
|
+
if (until === undefined)
|
|
290
|
+
return;
|
|
291
|
+
const remaining = until - this.now();
|
|
292
|
+
if (remaining <= 0) {
|
|
293
|
+
rateLimitCooldownUntil.delete(this.accountKey);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
throw new Error(`微信发送暂时限流,冷却还剩 ${remaining}ms`);
|
|
297
|
+
}
|
|
298
|
+
async clearContextToken(target, failedToken) {
|
|
299
|
+
// Do not erase a fresher token learned by the long poll while this send was
|
|
300
|
+
// in flight. Only the token proven stale by this response may be removed.
|
|
301
|
+
if (this.state.contextTokens?.[target] !== failedToken)
|
|
302
|
+
return;
|
|
303
|
+
delete this.state.contextTokens[target];
|
|
304
|
+
await this.persistState();
|
|
305
|
+
}
|
|
306
|
+
async uploadImage(target, attachment) {
|
|
307
|
+
if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(attachment.mimeType) ||
|
|
308
|
+
attachment.data.byteLength < 1 ||
|
|
309
|
+
attachment.data.byteLength > 10 * 1024 * 1024) {
|
|
310
|
+
throw new Error("微信待发送图片的类型或大小不受支持");
|
|
311
|
+
}
|
|
312
|
+
const uploaded = await this.uploadMedia(target, attachment, 1);
|
|
313
|
+
return {
|
|
314
|
+
type: ITEM_TYPE_IMAGE,
|
|
315
|
+
image_item: {
|
|
316
|
+
media: {
|
|
317
|
+
encrypt_query_param: uploaded.encryptedQueryParam,
|
|
318
|
+
aes_key: Buffer.from(uploaded.keyHex).toString("base64"),
|
|
319
|
+
encrypt_type: 1,
|
|
320
|
+
},
|
|
321
|
+
mid_size: uploaded.ciphertextSize,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
async uploadFile(target, attachment) {
|
|
326
|
+
if (attachment.data.byteLength < 1 || attachment.data.byteLength > 10 * 1024 * 1024) {
|
|
327
|
+
throw new Error("微信待发送文件的大小不受支持");
|
|
328
|
+
}
|
|
329
|
+
const uploaded = await this.uploadMedia(target, attachment, 3);
|
|
330
|
+
return {
|
|
331
|
+
type: ITEM_TYPE_FILE,
|
|
332
|
+
file_item: {
|
|
333
|
+
media: {
|
|
334
|
+
encrypt_query_param: uploaded.encryptedQueryParam,
|
|
335
|
+
aes_key: Buffer.from(uploaded.keyHex).toString("base64"),
|
|
336
|
+
encrypt_type: 1,
|
|
337
|
+
},
|
|
338
|
+
file_name: safeWechatFileName(attachment.name),
|
|
339
|
+
len: String(uploaded.plaintextSize),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
async uploadVideo(target, attachment) {
|
|
344
|
+
if (!attachment.mimeType.startsWith("video/")) {
|
|
345
|
+
throw new Error("微信待发送视频的 MIME 类型无效");
|
|
346
|
+
}
|
|
347
|
+
const uploaded = await this.uploadMedia(target, attachment, 2);
|
|
348
|
+
return {
|
|
349
|
+
type: ITEM_TYPE_VIDEO,
|
|
350
|
+
video_item: {
|
|
351
|
+
media: {
|
|
352
|
+
encrypt_query_param: uploaded.encryptedQueryParam,
|
|
353
|
+
aes_key: Buffer.from(uploaded.keyHex).toString("base64"),
|
|
354
|
+
encrypt_type: 1,
|
|
355
|
+
},
|
|
356
|
+
video_size: uploaded.ciphertextSize,
|
|
357
|
+
play_length: 0,
|
|
358
|
+
video_md5: createHash("md5").update(attachment.data).digest("hex"),
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async uploadMedia(target, attachment, mediaType) {
|
|
363
|
+
const plaintext = Buffer.from(attachment.data);
|
|
364
|
+
const filekey = randomBytes(16).toString("hex");
|
|
365
|
+
const key = randomBytes(16);
|
|
366
|
+
const keyHex = key.toString("hex");
|
|
367
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
368
|
+
cipher.setAutoPadding(true);
|
|
369
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
370
|
+
const upload = await this.post("ilink/bot/getuploadurl", {
|
|
371
|
+
filekey,
|
|
372
|
+
media_type: mediaType,
|
|
373
|
+
to_user_id: target,
|
|
374
|
+
rawsize: plaintext.byteLength,
|
|
375
|
+
rawfilemd5: createHash("md5").update(plaintext).digest("hex"),
|
|
376
|
+
filesize: ciphertext.byteLength,
|
|
377
|
+
no_need_thumb: true,
|
|
378
|
+
aeskey: keyHex,
|
|
379
|
+
base_info: this.baseInfo(),
|
|
380
|
+
}, 15_000);
|
|
381
|
+
if (wechatResponseFailed(upload)) {
|
|
382
|
+
throw new Error(`微信获取附件上传地址失败:${wechatResponseDetail(upload)}`);
|
|
383
|
+
}
|
|
384
|
+
const uploadUrl = resolveWechatCdnUploadUrl(upload, filekey);
|
|
385
|
+
const encryptedQueryParam = await this.uploadCiphertext(uploadUrl, ciphertext);
|
|
386
|
+
return {
|
|
387
|
+
plaintextSize: plaintext.byteLength,
|
|
388
|
+
ciphertextSize: ciphertext.byteLength,
|
|
389
|
+
keyHex,
|
|
390
|
+
encryptedQueryParam,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async uploadCiphertext(uploadUrl, ciphertext) {
|
|
394
|
+
let lastError;
|
|
395
|
+
for (let attempt = 1; attempt <= WECHAT_CDN_UPLOAD_ATTEMPTS; attempt += 1) {
|
|
396
|
+
try {
|
|
397
|
+
const response = await this.fetchFn(uploadUrl, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: { "content-type": "application/octet-stream" },
|
|
400
|
+
body: new Uint8Array(ciphertext),
|
|
401
|
+
signal: AbortSignal.timeout(30_000),
|
|
402
|
+
// Never let a CDN response turn this trusted upload into an arbitrary
|
|
403
|
+
// follow-up request. Unlike downloads, an upload has no legitimate
|
|
404
|
+
// cross-host redirect flow to preserve.
|
|
405
|
+
redirect: "manual",
|
|
406
|
+
});
|
|
407
|
+
if ((response.status >= 300 && response.status < 400) ||
|
|
408
|
+
(response.status >= 400 && response.status < 500)) {
|
|
409
|
+
throw new WechatCdnUploadClientError(response.status);
|
|
410
|
+
}
|
|
411
|
+
if (!response.ok)
|
|
412
|
+
throw new Error(`微信附件上传失败(HTTP ${response.status})`);
|
|
413
|
+
const encryptedQueryParam = response.headers.get("x-encrypted-param")?.trim();
|
|
414
|
+
if (!encryptedQueryParam)
|
|
415
|
+
throw new Error("微信附件上传响应缺少下载参数");
|
|
416
|
+
return encryptedQueryParam;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
if (error instanceof WechatCdnUploadClientError)
|
|
420
|
+
throw error;
|
|
421
|
+
lastError = error;
|
|
422
|
+
if (attempt >= WECHAT_CDN_UPLOAD_ATTEMPTS)
|
|
423
|
+
break;
|
|
424
|
+
const delayMs = WECHAT_CDN_UPLOAD_RETRY_BASE_MS * attempt;
|
|
425
|
+
this.log(`微信附件上传失败,${delayMs}ms 后重试(${attempt}/${WECHAT_CDN_UPLOAD_ATTEMPTS - 1}):${error instanceof Error ? error.message : String(error)}`);
|
|
426
|
+
await this.sleepFn(delayMs, new AbortController().signal);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
throw lastError instanceof Error ? lastError : new Error("微信附件上传失败");
|
|
430
|
+
}
|
|
431
|
+
normalizeInbound(raw) {
|
|
432
|
+
if (raw.message_type !== undefined && raw.message_type !== MESSAGE_TYPE_USER)
|
|
433
|
+
return undefined;
|
|
434
|
+
if (raw.message_state === MESSAGE_STATE_GENERATING)
|
|
435
|
+
return undefined;
|
|
436
|
+
const senderId = raw.from_user_id?.trim();
|
|
437
|
+
if (!senderId)
|
|
438
|
+
return undefined;
|
|
439
|
+
if (raw.create_time_ms !== undefined &&
|
|
440
|
+
this.maxMessageAgeMs >= 0 &&
|
|
441
|
+
this.now() - raw.create_time_ms > this.maxMessageAgeMs) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
const items = raw.item_list ?? [];
|
|
445
|
+
const text = items
|
|
446
|
+
.flatMap((item) => {
|
|
447
|
+
if (item.type === ITEM_TYPE_TEXT && item.text_item?.text)
|
|
448
|
+
return [item.text_item.text];
|
|
449
|
+
if (item.type === ITEM_TYPE_VOICE && item.voice_item?.text)
|
|
450
|
+
return [item.voice_item.text];
|
|
451
|
+
return [];
|
|
452
|
+
})
|
|
453
|
+
.join("\n")
|
|
454
|
+
.trim();
|
|
455
|
+
const attachments = items.flatMap((item, index) => {
|
|
456
|
+
const attachment = this.toAttachment(raw, item, index);
|
|
457
|
+
return attachment ? [attachment] : [];
|
|
458
|
+
});
|
|
459
|
+
if (!text && attachments.length === 0)
|
|
460
|
+
return undefined;
|
|
461
|
+
return {
|
|
462
|
+
channel: this.channel,
|
|
463
|
+
target: senderId,
|
|
464
|
+
senderId,
|
|
465
|
+
text,
|
|
466
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
467
|
+
messageId: raw.message_id === undefined ? undefined : String(raw.message_id),
|
|
468
|
+
metadata: compactRecord({
|
|
469
|
+
accountId: this.config.accountId,
|
|
470
|
+
toUserId: raw.to_user_id,
|
|
471
|
+
sessionId: raw.session_id,
|
|
472
|
+
groupId: raw.group_id,
|
|
473
|
+
createTimeMs: raw.create_time_ms,
|
|
474
|
+
}),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
toAttachment(message, item, index) {
|
|
478
|
+
const id = item.msg_id ?? `${message.message_id ?? "wechat"}-${index}`;
|
|
479
|
+
if (item.type === ITEM_TYPE_IMAGE && item.image_item?.media) {
|
|
480
|
+
return {
|
|
481
|
+
id,
|
|
482
|
+
kind: "image",
|
|
483
|
+
name: "wechat-image.jpg",
|
|
484
|
+
mimeType: "image/jpeg",
|
|
485
|
+
load: (signal) => this.downloadMedia(item.image_item.media, item.image_item?.aeskey, signal),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (item.type === ITEM_TYPE_VOICE && item.voice_item?.media) {
|
|
489
|
+
return {
|
|
490
|
+
id,
|
|
491
|
+
kind: "audio",
|
|
492
|
+
name: item.voice_item.encode_type === 7 ? "wechat-voice.mp3" : "wechat-voice.silk",
|
|
493
|
+
mimeType: item.voice_item.encode_type === 7 ? "audio/mpeg" : "audio/silk",
|
|
494
|
+
load: (signal) => this.downloadMedia(item.voice_item.media, undefined, signal),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (item.type === ITEM_TYPE_FILE && item.file_item?.media) {
|
|
498
|
+
const declaredSize = Number(item.file_item.len);
|
|
499
|
+
return {
|
|
500
|
+
id,
|
|
501
|
+
kind: "file",
|
|
502
|
+
name: item.file_item.file_name ?? "wechat-file",
|
|
503
|
+
mimeType: "application/octet-stream",
|
|
504
|
+
...(Number.isSafeInteger(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
|
505
|
+
load: (signal) => this.downloadMedia(item.file_item.media, undefined, signal),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (item.type === ITEM_TYPE_VIDEO && item.video_item?.media) {
|
|
509
|
+
return {
|
|
510
|
+
id,
|
|
511
|
+
kind: "video",
|
|
512
|
+
name: "wechat-video.mp4",
|
|
513
|
+
mimeType: "video/mp4",
|
|
514
|
+
load: (signal) => this.downloadMedia(item.video_item.media, undefined, signal),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
async downloadMedia(media, preferredHexKey, signal) {
|
|
520
|
+
const url = resolveWechatCdnUrl(media, this.config.allowUnsafeBaseUrl ?? false, this.extraCdnDownloadHosts);
|
|
521
|
+
const response = await this.fetchWechatCdn(url, signal ?? AbortSignal.timeout(30_000));
|
|
522
|
+
if (!response.ok)
|
|
523
|
+
throw new Error(`微信附件下载失败(HTTP ${response.status})`);
|
|
524
|
+
const encrypted = await readBoundedWechatResponse(response, 10 * 1024 * 1024 + 16);
|
|
525
|
+
const key = decodeWechatAesKey(preferredHexKey, media.aes_key);
|
|
526
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
527
|
+
decipher.setAutoPadding(true);
|
|
528
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
529
|
+
}
|
|
530
|
+
async fetchWechatCdn(initialUrl, signal) {
|
|
531
|
+
let currentUrl = initialUrl;
|
|
532
|
+
for (let redirects = 0; redirects <= WECHAT_CDN_MAX_REDIRECTS; redirects += 1) {
|
|
533
|
+
const response = await this.fetchFn(currentUrl, { signal, redirect: "manual" });
|
|
534
|
+
if (response.status < 300 || response.status >= 400)
|
|
535
|
+
return response;
|
|
536
|
+
const location = response.headers.get("location");
|
|
537
|
+
await response.body?.cancel().catch(() => undefined);
|
|
538
|
+
if (!location)
|
|
539
|
+
throw new Error("微信附件 CDN 重定向缺少地址");
|
|
540
|
+
if (redirects >= WECHAT_CDN_MAX_REDIRECTS) {
|
|
541
|
+
throw new Error("微信附件 CDN 重定向过多");
|
|
542
|
+
}
|
|
543
|
+
currentUrl = validateWechatCdnDownloadUrl(new URL(location, currentUrl).toString(), this.config.allowUnsafeBaseUrl ?? false, this.extraCdnDownloadHosts);
|
|
544
|
+
}
|
|
545
|
+
throw new Error("微信附件 CDN 重定向过多");
|
|
546
|
+
}
|
|
547
|
+
isDuplicate(messageId) {
|
|
548
|
+
if (!messageId)
|
|
549
|
+
return false;
|
|
550
|
+
if (this.seenMessageIds.has(messageId))
|
|
551
|
+
return true;
|
|
552
|
+
this.seenMessageIds.add(messageId);
|
|
553
|
+
if (this.seenMessageIds.size > 1_000) {
|
|
554
|
+
const oldest = this.seenMessageIds.values().next().value;
|
|
555
|
+
if (oldest)
|
|
556
|
+
this.seenMessageIds.delete(oldest);
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
forgetDuplicate(messageId) {
|
|
561
|
+
if (messageId)
|
|
562
|
+
this.seenMessageIds.delete(messageId);
|
|
563
|
+
}
|
|
564
|
+
async commitCursor(cursor) {
|
|
565
|
+
const previous = this.state.cursor;
|
|
566
|
+
this.state.cursor = cursor;
|
|
567
|
+
try {
|
|
568
|
+
await this.persistState();
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
this.state.cursor = previous;
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
ensureState() {
|
|
576
|
+
this.stateReady ??= (async () => {
|
|
577
|
+
const stored = await this.stateStore?.load();
|
|
578
|
+
this.state = {
|
|
579
|
+
cursor: stored?.cursor,
|
|
580
|
+
contextTokens: { ...(stored?.contextTokens ?? {}) },
|
|
581
|
+
};
|
|
582
|
+
})();
|
|
583
|
+
return this.stateReady;
|
|
584
|
+
}
|
|
585
|
+
async persistState() {
|
|
586
|
+
const snapshot = {
|
|
587
|
+
cursor: this.state.cursor,
|
|
588
|
+
contextTokens: { ...(this.state.contextTokens ?? {}) },
|
|
589
|
+
};
|
|
590
|
+
const write = this.stateWrite
|
|
591
|
+
.catch(() => undefined)
|
|
592
|
+
.then(async () => {
|
|
593
|
+
if (this.staleCredential) {
|
|
594
|
+
throw new Error("微信状态文件已被新的扫码绑定接管,本进程已停止持久化");
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
await this.stateStore?.save(snapshot);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (error instanceof WechatStateOwnershipError && !this.staleCredential) {
|
|
601
|
+
this.staleCredential = true;
|
|
602
|
+
this.log(`微信状态文件已被新的扫码绑定接管,本进程停止持久化并停止长轮询:${error.message}`);
|
|
603
|
+
}
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
this.stateWrite = write;
|
|
608
|
+
await write;
|
|
609
|
+
}
|
|
610
|
+
baseInfo() {
|
|
611
|
+
return { channel_version: this.protocolVersion, bot_agent: this.botAgent };
|
|
612
|
+
}
|
|
613
|
+
post(endpoint, body, timeoutMs, signal) {
|
|
614
|
+
return requestWechatJson({
|
|
615
|
+
fetchFn: this.fetchFn,
|
|
616
|
+
baseUrl: this.baseUrl,
|
|
617
|
+
endpoint,
|
|
618
|
+
method: "POST",
|
|
619
|
+
body,
|
|
620
|
+
token: this.config.token,
|
|
621
|
+
timeoutMs,
|
|
622
|
+
signal,
|
|
623
|
+
protocolVersion: this.protocolVersion,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async notifyLifecycle(action) {
|
|
627
|
+
try {
|
|
628
|
+
const response = await this.post(`ilink/bot/msg/${action}`, { base_info: this.baseInfo() }, 10_000);
|
|
629
|
+
if (wechatResponseFailed(response)) {
|
|
630
|
+
this.log(`微信 ${action} 失败:${wechatResponseDetail(response)}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
this.log(`微信 ${action} 失败(已忽略):${error instanceof Error ? error.message : String(error)}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Direct WeChat delivery for schedulers and notification workers. It reuses a
|
|
640
|
+
* live long-poll adapter when available and otherwise creates a one-shot
|
|
641
|
+
* adapter. Both routes share persisted context lookup, stale-context eviction,
|
|
642
|
+
* best-effort tokenless fallback, media upload, and response validation.
|
|
643
|
+
*/
|
|
644
|
+
export async function sendWechatDirect(config, target, message, options = {}) {
|
|
645
|
+
const lockBaseUrl = validateWechatBaseUrl(config.baseUrl ?? WECHAT_DEFAULT_BASE_URL, config.allowUnsafeBaseUrl ?? false);
|
|
646
|
+
const accountKey = wechatAccountKey(lockBaseUrl, config.accountId);
|
|
647
|
+
const credentialKey = wechatCredentialKey(lockBaseUrl, config.accountId, config.token);
|
|
648
|
+
// iLink can acknowledge a competing short-lived session without showing
|
|
649
|
+
// the message on the recipient device. Reuse the authoritative long-poll
|
|
650
|
+
// adapter whenever this process has one, matching Hermes' live-adapter
|
|
651
|
+
// routing; only fall back to a one-shot adapter while the Gateway is down.
|
|
652
|
+
const live = liveWechatAdapters.get(accountKey);
|
|
653
|
+
if (live && live.credentialKey !== credentialKey) {
|
|
654
|
+
throw new Error("微信凭据已更新,请重启 Gateway 后再发送");
|
|
655
|
+
}
|
|
656
|
+
const adapter = live?.adapter ?? new WechatAdapter(config, options);
|
|
657
|
+
await adapter.send(target, message);
|
|
658
|
+
return {
|
|
659
|
+
channel: "wechat",
|
|
660
|
+
target,
|
|
661
|
+
status: "accepted",
|
|
662
|
+
terminalDeliveryConfirmed: false,
|
|
663
|
+
viaLiveAdapter: live !== undefined,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
async function withWechatAccountSendLock(key, operation) {
|
|
667
|
+
const previous = accountSendTails.get(key) ?? Promise.resolve();
|
|
668
|
+
let release = () => undefined;
|
|
669
|
+
const gate = new Promise((resolveGate) => {
|
|
670
|
+
release = resolveGate;
|
|
671
|
+
});
|
|
672
|
+
const tail = previous.then(() => gate, () => gate);
|
|
673
|
+
accountSendTails.set(key, tail);
|
|
674
|
+
await previous.catch(() => undefined);
|
|
675
|
+
try {
|
|
676
|
+
return await operation();
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
release();
|
|
680
|
+
if (accountSendTails.get(key) === tail)
|
|
681
|
+
accountSendTails.delete(key);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function wechatAccountKey(baseUrl, accountId) {
|
|
685
|
+
return `${baseUrl}\0${normalizeWechatAccountId(accountId)}`;
|
|
686
|
+
}
|
|
687
|
+
function wechatCredentialKey(baseUrl, accountId, token) {
|
|
688
|
+
const tokenFingerprint = createHash("sha256").update(token).digest("base64url").slice(0, 24);
|
|
689
|
+
return `${wechatAccountKey(baseUrl, accountId)}\0${tokenFingerprint}`;
|
|
690
|
+
}
|
|
691
|
+
function wechatResponseFailed(response) {
|
|
692
|
+
return [response.ret, response.errcode].some((code) => typeof code === "number" && code !== 0);
|
|
693
|
+
}
|
|
694
|
+
function isStaleWechatContextResponse(response) {
|
|
695
|
+
const message = response.errmsg?.trim() ?? "";
|
|
696
|
+
if (/prepare failed/iu.test(message))
|
|
697
|
+
return true;
|
|
698
|
+
if (response.ret === -14 || response.errcode === -14)
|
|
699
|
+
return true;
|
|
700
|
+
return ((response.ret === -2 || response.errcode === -2) && /unknown(?:\s+|_)error/iu.test(message));
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Retry only an explicit application-level throttle response. Transport
|
|
704
|
+
* failures are intentionally excluded because their delivery outcome is
|
|
705
|
+
* ambiguous, and stale-context responses must take the tokenless fallback path.
|
|
706
|
+
*/
|
|
707
|
+
function isExplicitWechatRateLimitResponse(response) {
|
|
708
|
+
if (!wechatResponseFailed(response) || isStaleWechatContextResponse(response))
|
|
709
|
+
return false;
|
|
710
|
+
return [response.ret, response.errcode].some((code) => code === -2 || code === 429);
|
|
711
|
+
}
|
|
712
|
+
function wechatResponseDetail(response) {
|
|
713
|
+
const message = response.errmsg?.trim();
|
|
714
|
+
if (message)
|
|
715
|
+
return message;
|
|
716
|
+
const codes = [
|
|
717
|
+
...(typeof response.ret === "number" ? [`ret=${response.ret}`] : []),
|
|
718
|
+
...(typeof response.errcode === "number" ? [`errcode=${response.errcode}`] : []),
|
|
719
|
+
];
|
|
720
|
+
return codes.join(", ") || "微信服务返回了未知错误";
|
|
721
|
+
}
|
|
722
|
+
function wechatSendFailure(response, direct) {
|
|
723
|
+
if (response.ret === -14 || response.errcode === -14) {
|
|
724
|
+
return `微信登录或发送授权已失效:${wechatResponseDetail(response)}。请重新扫码连接个人微信。`;
|
|
725
|
+
}
|
|
726
|
+
if (direct && isStaleWechatContextResponse(response)) {
|
|
727
|
+
return `微信直接发送失败:${wechatResponseDetail(response)}。当前微信会话没有可用的 context_token;请先让管理员向 Mimi 发一条消息刷新会话上下文。`;
|
|
728
|
+
}
|
|
729
|
+
return `微信${direct ? "直接" : ""}发送失败:${wechatResponseDetail(response)}`;
|
|
730
|
+
}
|
|
731
|
+
function splitWechatText(text, maximum) {
|
|
732
|
+
if (!text)
|
|
733
|
+
return [];
|
|
734
|
+
const characters = Array.from(text);
|
|
735
|
+
const chunks = [];
|
|
736
|
+
for (let offset = 0; offset < characters.length; offset += maximum) {
|
|
737
|
+
chunks.push(characters.slice(offset, offset + maximum).join(""));
|
|
738
|
+
}
|
|
739
|
+
return chunks;
|
|
740
|
+
}
|
|
741
|
+
function resolveWechatCdnUrl(media, allowUnsafe, extraHosts) {
|
|
742
|
+
const raw = media.full_url?.trim()
|
|
743
|
+
? media.full_url
|
|
744
|
+
: media.encrypt_query_param
|
|
745
|
+
? `${WECHAT_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}`
|
|
746
|
+
: "";
|
|
747
|
+
if (!raw)
|
|
748
|
+
throw new Error("微信附件缺少 CDN 下载参数");
|
|
749
|
+
return validateWechatCdnDownloadUrl(raw, allowUnsafe, extraHosts);
|
|
750
|
+
}
|
|
751
|
+
function validateWechatCdnDownloadUrl(raw, allowUnsafe, extraHosts) {
|
|
752
|
+
const url = new URL(raw);
|
|
753
|
+
if (url.protocol !== "https:")
|
|
754
|
+
throw new Error("微信附件 CDN 地址必须使用 HTTPS");
|
|
755
|
+
if (url.username || url.password)
|
|
756
|
+
throw new Error("微信附件 CDN 地址不能包含登录信息");
|
|
757
|
+
if (!allowUnsafe &&
|
|
758
|
+
!WECHAT_CDN_DOWNLOAD_HOSTS.has(url.hostname) &&
|
|
759
|
+
!extraHosts.has(url.hostname)) {
|
|
760
|
+
throw new Error("微信附件 CDN 地址不在受信任主机列表中");
|
|
761
|
+
}
|
|
762
|
+
return url.toString();
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Extra hosts widen only the download allowlist, so they must be exact
|
|
766
|
+
* hostnames — no scheme, path, port, or credentials. HTTPS enforcement stays
|
|
767
|
+
* unconditional in validateWechatCdnDownloadUrl.
|
|
768
|
+
*/
|
|
769
|
+
function validateExtraWechatCdnDownloadHosts(hosts) {
|
|
770
|
+
const validated = new Set();
|
|
771
|
+
for (const raw of hosts ?? []) {
|
|
772
|
+
const host = raw.trim().toLowerCase();
|
|
773
|
+
let hostname;
|
|
774
|
+
try {
|
|
775
|
+
const parsed = new URL(`https://${host}/`);
|
|
776
|
+
if (!parsed.port && !parsed.username && !parsed.password)
|
|
777
|
+
hostname = parsed.hostname;
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
// Rejected below with the original value for a clear config error.
|
|
781
|
+
}
|
|
782
|
+
if (!host || hostname !== host) {
|
|
783
|
+
throw new Error(`微信附件 CDN 额外主机必须是纯主机名:${JSON.stringify(raw)}`);
|
|
784
|
+
}
|
|
785
|
+
validated.add(host);
|
|
786
|
+
}
|
|
787
|
+
return validated;
|
|
788
|
+
}
|
|
789
|
+
function resolveWechatCdnUploadUrl(upload, filekey) {
|
|
790
|
+
const raw = upload.upload_full_url?.trim()
|
|
791
|
+
? upload.upload_full_url
|
|
792
|
+
: upload.upload_param
|
|
793
|
+
? `${WECHAT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(upload.upload_param)}&filekey=${encodeURIComponent(filekey)}`
|
|
794
|
+
: "";
|
|
795
|
+
if (!raw)
|
|
796
|
+
throw new Error("微信服务未返回附件上传地址");
|
|
797
|
+
const url = new URL(raw);
|
|
798
|
+
if (url.protocol !== "https:" ||
|
|
799
|
+
url.hostname !== "novac2c.cdn.weixin.qq.com" ||
|
|
800
|
+
url.username ||
|
|
801
|
+
url.password) {
|
|
802
|
+
throw new Error("微信附件上传地址不是受信任的 CDN HTTPS 地址");
|
|
803
|
+
}
|
|
804
|
+
return url.toString();
|
|
805
|
+
}
|
|
806
|
+
function safeWechatFileName(value) {
|
|
807
|
+
const normalized = value
|
|
808
|
+
.replace(/[\\/\u0000-\u001f\u007f]+/gu, "_")
|
|
809
|
+
.trim()
|
|
810
|
+
.slice(0, 255);
|
|
811
|
+
return normalized || "attachment";
|
|
812
|
+
}
|
|
813
|
+
function decodeWechatAesKey(preferredHexKey, encoded) {
|
|
814
|
+
if (preferredHexKey && /^[a-f0-9]{32}$/i.test(preferredHexKey)) {
|
|
815
|
+
return Buffer.from(preferredHexKey, "hex");
|
|
816
|
+
}
|
|
817
|
+
if (!encoded)
|
|
818
|
+
throw new Error("微信附件缺少 AES 密钥");
|
|
819
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
820
|
+
if (decoded.byteLength === 16)
|
|
821
|
+
return decoded;
|
|
822
|
+
const asText = decoded.toString("utf-8");
|
|
823
|
+
if (/^[a-f0-9]{32}$/i.test(asText))
|
|
824
|
+
return Buffer.from(asText, "hex");
|
|
825
|
+
throw new Error("微信附件 AES 密钥格式无效");
|
|
826
|
+
}
|
|
827
|
+
async function readBoundedWechatResponse(response, maxBytes) {
|
|
828
|
+
const declared = Number(response.headers.get("content-length"));
|
|
829
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
830
|
+
throw new Error("微信附件超过大小限制");
|
|
831
|
+
if (!response.body) {
|
|
832
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
833
|
+
if (bytes.byteLength > maxBytes)
|
|
834
|
+
throw new Error("微信附件超过大小限制");
|
|
835
|
+
return bytes;
|
|
836
|
+
}
|
|
837
|
+
const reader = response.body.getReader();
|
|
838
|
+
const chunks = [];
|
|
839
|
+
let total = 0;
|
|
840
|
+
while (true) {
|
|
841
|
+
const { done, value } = await reader.read();
|
|
842
|
+
if (done)
|
|
843
|
+
break;
|
|
844
|
+
total += value.byteLength;
|
|
845
|
+
if (total > maxBytes) {
|
|
846
|
+
await reader.cancel();
|
|
847
|
+
throw new Error("微信附件超过大小限制");
|
|
848
|
+
}
|
|
849
|
+
chunks.push(value);
|
|
850
|
+
}
|
|
851
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
852
|
+
}
|
|
853
|
+
/** QR login flow compatible with Tencent's official ClawBot plugin protocol. */
|
|
854
|
+
export async function loginWechatWithQr(options = {}) {
|
|
855
|
+
const fetchFn = options.fetch ?? fetch;
|
|
856
|
+
const sleepFn = options.sleep ?? abortableDelay;
|
|
857
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
858
|
+
const timeoutMs = Math.max(options.timeoutMs ?? 8 * 60_000, 1_000);
|
|
859
|
+
const deadline = Date.now() + timeoutMs;
|
|
860
|
+
const protocolVersion = options.protocolVersion ?? WECHAT_PROTOCOL_VERSION;
|
|
861
|
+
const initialBaseUrl = validateWechatBaseUrl(options.apiBaseUrl ?? WECHAT_DEFAULT_BASE_URL, options.allowUnsafeBaseUrl ?? false);
|
|
862
|
+
let pollingBaseUrl = initialBaseUrl;
|
|
863
|
+
let refreshCount = 0;
|
|
864
|
+
let pendingVerificationCode;
|
|
865
|
+
const fetchQrCode = async () => {
|
|
866
|
+
const response = await requestWechatJson({
|
|
867
|
+
fetchFn,
|
|
868
|
+
baseUrl: initialBaseUrl,
|
|
869
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(options.botType ?? DEFAULT_BOT_TYPE)}`,
|
|
870
|
+
method: "POST",
|
|
871
|
+
body: { local_token_list: options.localTokens ?? [] },
|
|
872
|
+
timeoutMs: 15_000,
|
|
873
|
+
signal,
|
|
874
|
+
protocolVersion,
|
|
875
|
+
});
|
|
876
|
+
if (!response.qrcode || !response.qrcode_img_content) {
|
|
877
|
+
throw new Error("微信服务未返回有效二维码");
|
|
878
|
+
}
|
|
879
|
+
await options.onQrCode?.(response.qrcode_img_content);
|
|
880
|
+
return response;
|
|
881
|
+
};
|
|
882
|
+
let qr = await fetchQrCode();
|
|
883
|
+
while (Date.now() < deadline) {
|
|
884
|
+
if (signal.aborted)
|
|
885
|
+
throw new Error("微信登录已取消");
|
|
886
|
+
try {
|
|
887
|
+
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr.qrcode)}`;
|
|
888
|
+
if (pendingVerificationCode) {
|
|
889
|
+
endpoint += `&verify_code=${encodeURIComponent(pendingVerificationCode)}`;
|
|
890
|
+
}
|
|
891
|
+
const response = await requestWechatJson({
|
|
892
|
+
fetchFn,
|
|
893
|
+
baseUrl: pollingBaseUrl,
|
|
894
|
+
endpoint,
|
|
895
|
+
method: "GET",
|
|
896
|
+
timeoutMs: Math.min(DEFAULT_LONG_POLL_TIMEOUT_MS, Math.max(deadline - Date.now(), 1_000)),
|
|
897
|
+
signal,
|
|
898
|
+
protocolVersion,
|
|
899
|
+
commonHeadersOnly: true,
|
|
900
|
+
});
|
|
901
|
+
options.onStatus?.(response.status);
|
|
902
|
+
if (response.status === "confirmed") {
|
|
903
|
+
if (!response.bot_token || !response.ilink_bot_id) {
|
|
904
|
+
throw new Error("微信登录成功响应缺少 bot token 或 accountId");
|
|
905
|
+
}
|
|
906
|
+
const baseUrl = validateWechatBaseUrl(response.baseurl || pollingBaseUrl, options.allowUnsafeBaseUrl ?? false);
|
|
907
|
+
return {
|
|
908
|
+
connected: true,
|
|
909
|
+
credentials: {
|
|
910
|
+
accountId: response.ilink_bot_id,
|
|
911
|
+
token: response.bot_token,
|
|
912
|
+
baseUrl,
|
|
913
|
+
userId: response.ilink_user_id,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (response.status === "binded_redirect") {
|
|
918
|
+
return { connected: false, alreadyConnected: true };
|
|
919
|
+
}
|
|
920
|
+
if (response.status === "scaned_but_redirect" && response.redirect_host) {
|
|
921
|
+
pollingBaseUrl = validateWechatBaseUrl(`https://${response.redirect_host}`, options.allowUnsafeBaseUrl ?? false);
|
|
922
|
+
}
|
|
923
|
+
else if (response.status === "need_verifycode") {
|
|
924
|
+
if (!options.requestVerificationCode) {
|
|
925
|
+
throw new Error("手机微信要求输入验证数字,但未提供 requestVerificationCode");
|
|
926
|
+
}
|
|
927
|
+
pendingVerificationCode = (await options.requestVerificationCode()).trim();
|
|
928
|
+
if (!pendingVerificationCode)
|
|
929
|
+
throw new Error("微信验证数字不能为空");
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
else if (response.status === "scaned") {
|
|
933
|
+
pendingVerificationCode = undefined;
|
|
934
|
+
}
|
|
935
|
+
else if (response.status === "expired" || response.status === "verify_code_blocked") {
|
|
936
|
+
refreshCount += 1;
|
|
937
|
+
if (refreshCount >= 3)
|
|
938
|
+
throw new Error("微信二维码或验证码多次失效");
|
|
939
|
+
pendingVerificationCode = undefined;
|
|
940
|
+
pollingBaseUrl = initialBaseUrl;
|
|
941
|
+
qr = await fetchQrCode();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
if (signal.aborted)
|
|
946
|
+
throw new Error("微信登录已取消", { cause: error });
|
|
947
|
+
if (!(error instanceof WechatRequestTimeoutError))
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
950
|
+
await sleepFn(1_000, signal);
|
|
951
|
+
}
|
|
952
|
+
throw new Error("微信登录超时,请重试");
|
|
953
|
+
}
|
|
954
|
+
class WechatRequestTimeoutError extends Error {
|
|
955
|
+
}
|
|
956
|
+
class WechatHttpError extends Error {
|
|
957
|
+
status;
|
|
958
|
+
constructor(status) {
|
|
959
|
+
super(`微信 API 返回 HTTP ${status}`);
|
|
960
|
+
this.status = status;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
class WechatCdnUploadClientError extends Error {
|
|
964
|
+
status;
|
|
965
|
+
constructor(status) {
|
|
966
|
+
super(`微信附件上传失败(HTTP ${status})`);
|
|
967
|
+
this.status = status;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async function requestWechatJson(options) {
|
|
971
|
+
const controller = new AbortController();
|
|
972
|
+
let timedOut = false;
|
|
973
|
+
const timeout = setTimeout(() => {
|
|
974
|
+
timedOut = true;
|
|
975
|
+
controller.abort();
|
|
976
|
+
}, options.timeoutMs);
|
|
977
|
+
const abort = () => controller.abort();
|
|
978
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
979
|
+
try {
|
|
980
|
+
const response = await options.fetchFn(new URL(options.endpoint, `${options.baseUrl.replace(/\/$/, "")}/`), {
|
|
981
|
+
method: options.method,
|
|
982
|
+
headers: buildWechatHeaders({
|
|
983
|
+
token: options.token,
|
|
984
|
+
protocolVersion: options.protocolVersion,
|
|
985
|
+
commonOnly: options.commonHeadersOnly,
|
|
986
|
+
}),
|
|
987
|
+
...(options.method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {}),
|
|
988
|
+
signal: controller.signal,
|
|
989
|
+
// An automatic redirect could forward the bearer token away from the
|
|
990
|
+
// already-validated API origin. iLink expresses regional redirects in
|
|
991
|
+
// its JSON protocol, so HTTP redirects are never required here.
|
|
992
|
+
redirect: "manual",
|
|
993
|
+
});
|
|
994
|
+
if (!response.ok)
|
|
995
|
+
throw new WechatHttpError(response.status);
|
|
996
|
+
try {
|
|
997
|
+
const parsed = await response.json();
|
|
998
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
999
|
+
throw new Error("微信 API 返回的 JSON 不是对象");
|
|
1000
|
+
}
|
|
1001
|
+
return parsed;
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
if (error instanceof Error && error.message === "微信 API 返回的 JSON 不是对象") {
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
1007
|
+
throw new Error("微信 API 返回了无效 JSON", { cause: error });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
if (timedOut)
|
|
1012
|
+
throw new WechatRequestTimeoutError("微信 API 请求超时", { cause: error });
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
1016
|
+
clearTimeout(timeout);
|
|
1017
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function buildWechatHeaders(options) {
|
|
1021
|
+
const headers = {
|
|
1022
|
+
"iLink-App-Id": "bot",
|
|
1023
|
+
"iLink-App-ClientVersion": String(encodeClientVersion(options.protocolVersion)),
|
|
1024
|
+
};
|
|
1025
|
+
if (options.commonOnly)
|
|
1026
|
+
return headers;
|
|
1027
|
+
headers["Content-Type"] = "application/json";
|
|
1028
|
+
headers.AuthorizationType = "ilink_bot_token";
|
|
1029
|
+
headers["X-WECHAT-UIN"] = Buffer.from(String(randomBytes(4).readUInt32BE(0)), "utf8").toString("base64");
|
|
1030
|
+
if (options.token?.trim())
|
|
1031
|
+
headers.Authorization = `Bearer ${options.token.trim()}`;
|
|
1032
|
+
return headers;
|
|
1033
|
+
}
|
|
1034
|
+
function encodeClientVersion(version) {
|
|
1035
|
+
const [major = 0, minor = 0, patch = 0] = version
|
|
1036
|
+
.split(".")
|
|
1037
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
1038
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
1039
|
+
}
|
|
1040
|
+
function sanitizeBotAgent(value) {
|
|
1041
|
+
const ascii = value.replace(/[^\x20-\x7e]/g, "").trim();
|
|
1042
|
+
return ascii && Buffer.byteLength(ascii, "utf8") <= 256 ? ascii : WECHAT_DEFAULT_BOT_AGENT;
|
|
1043
|
+
}
|
|
1044
|
+
function boundedInteger(value, fallback, minimum, maximum, label) {
|
|
1045
|
+
const resolved = value ?? fallback;
|
|
1046
|
+
if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
1047
|
+
throw new Error(`${label}必须是 ${minimum} 到 ${maximum} 之间的整数`);
|
|
1048
|
+
}
|
|
1049
|
+
return resolved;
|
|
1050
|
+
}
|
|
1051
|
+
function validateWechatBaseUrl(value, allowUnsafe) {
|
|
1052
|
+
const url = new URL(value);
|
|
1053
|
+
if (url.username || url.password)
|
|
1054
|
+
throw new Error("微信 API baseUrl 不能包含登录信息");
|
|
1055
|
+
if (url.protocol !== "https:" && !allowUnsafe) {
|
|
1056
|
+
throw new Error("微信 API baseUrl 必须使用 HTTPS");
|
|
1057
|
+
}
|
|
1058
|
+
const official = url.hostname === "weixin.qq.com" || url.hostname.endsWith(".weixin.qq.com");
|
|
1059
|
+
if (!official && !allowUnsafe) {
|
|
1060
|
+
throw new Error(`拒绝将微信 bot token 发送到非官方域名:${url.hostname}`);
|
|
1061
|
+
}
|
|
1062
|
+
url.pathname = url.pathname.replace(/\/$/, "");
|
|
1063
|
+
url.search = "";
|
|
1064
|
+
url.hash = "";
|
|
1065
|
+
return url.toString().replace(/\/$/, "");
|
|
1066
|
+
}
|
|
1067
|
+
function compactRecord(value) {
|
|
1068
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
1069
|
+
}
|
|
1070
|
+
async function abortableDelay(ms, signal) {
|
|
1071
|
+
if (signal.aborted)
|
|
1072
|
+
return;
|
|
1073
|
+
await new Promise((resolve) => {
|
|
1074
|
+
const timer = setTimeout(done, ms);
|
|
1075
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1076
|
+
function done() {
|
|
1077
|
+
clearTimeout(timer);
|
|
1078
|
+
signal.removeEventListener("abort", done);
|
|
1079
|
+
resolve();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|