@nanhara/hara 0.124.1 → 0.124.3
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/CHANGELOG.md +44 -0
- package/dist/agent/loop.js +4 -2
- package/dist/checkpoints.js +4 -4
- package/dist/context/workspace-scope.js +46 -9
- package/dist/gateway/wecom.js +512 -223
- package/dist/index.js +151 -30
- package/dist/runtime.js +13 -0
- package/dist/sandbox.js +2 -2
- package/dist/search/semindex.js +5 -5
- package/dist/security/permissions.js +2 -2
- package/dist/session/resume.js +52 -0
- package/dist/session/store.js +51 -4
- package/dist/session/task.js +6 -4
- package/dist/tools/cron.js +2 -2
- package/dist/tools/registry.js +2 -2
- package/package.json +1 -1
package/dist/gateway/wecom.js
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
// WeCom (企业微信 / Enterprise WeChat) adapter for `hara gateway
|
|
2
|
-
// (wss://openws.work.weixin.qq.com) over Node's native global WebSocket (zero new dep on Node ≥ 22) so the LOCAL
|
|
3
|
-
// daemon dials OUT: NO public webhook / callback endpoint is required, exactly like Discord/DingTalk/Feishu's
|
|
4
|
-
// long-connection mode. Creds from HARA_WECOM_BOT_ID / HARA_WECOM_SECRET (the AI Bot's id + secret from the WeCom
|
|
5
|
-
// admin console). Same ChatAdapter shape as Telegram/Discord, so all the cross-platform gateway plumbing (system
|
|
6
|
-
// context, stuck-guard, allowlist, send_file, voice) works unchanged.
|
|
1
|
+
// WeCom (企业微信 / Enterprise WeChat) adapter for `hara gateway`.
|
|
7
2
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
// at the bottom of this file (the public spec is thin — fields are best-effort and tolerant of shape drift).
|
|
3
|
+
// Hara connects out to WeCom's AI Bot WebSocket gateway, so no public webhook is required. The transport
|
|
4
|
+
// deliberately stays small, but follows the same lifecycle as WeCom's official Node SDK: subscribe and wait
|
|
5
|
+
// for the auth ACK, monitor heartbeat ACKs, reconnect with bounded backoff, correlate every request, and stop
|
|
6
|
+
// reconnecting when `disconnected_event` says another connection has replaced this one.
|
|
13
7
|
import { basename, extname } from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
import { chunkText } from "./telegram.js";
|
|
16
|
-
import { InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes } from "./media.js";
|
|
8
|
+
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { chunkText, outboundTransferTimeoutMs, PerChatOutboundLane, withOutboundDeadline, } from "./telegram.js";
|
|
10
|
+
import { InboundMediaBudget, cleanupTransientMedia, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
|
|
17
11
|
const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
|
|
18
12
|
const WSImpl = globalThis.WebSocket;
|
|
19
|
-
|
|
13
|
+
const WS_CONNECTING = 0;
|
|
14
|
+
const WS_OPEN = 1;
|
|
20
15
|
const CMD_SUBSCRIBE = "aibot_subscribe";
|
|
21
16
|
const CMD_CALLBACK = "aibot_msg_callback";
|
|
22
17
|
const CMD_LEGACY_CALLBACK = "aibot_callback";
|
|
@@ -28,18 +23,66 @@ const CMD_UPLOAD_CHUNK = "aibot_upload_media_chunk";
|
|
|
28
23
|
const CMD_UPLOAD_FINISH = "aibot_upload_media_finish";
|
|
29
24
|
const CALLBACK_CMDS = new Set([CMD_CALLBACK, CMD_LEGACY_CALLBACK]);
|
|
30
25
|
const MAX_MESSAGE_LENGTH = 4000;
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
26
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
27
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
28
|
+
const DEFAULT_RECONNECT_BASE_MS = 1_000;
|
|
29
|
+
const DEFAULT_RECONNECT_MAX_MS = 30_000;
|
|
30
|
+
const DEFAULT_MAX_AUTH_FAILURES = 5;
|
|
31
|
+
const MAX_MISSED_HEARTBEATS = 2;
|
|
32
|
+
const MAX_FRAME_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
const UPLOAD_CHUNK_SIZE = 512 * 1024;
|
|
34
|
+
const MAX_UPLOAD_CHUNK_RETRIES = 2;
|
|
35
|
+
const ABSOLUTE_MAX_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
function boundedInteger(value, fallback, min, max) {
|
|
37
|
+
if (value === undefined || !Number.isFinite(value))
|
|
38
|
+
return fallback;
|
|
39
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
40
|
+
}
|
|
41
|
+
function resolveTransportOptions(options = {}) {
|
|
42
|
+
const reconnectBaseMs = boundedInteger(options.reconnectBaseMs, DEFAULT_RECONNECT_BASE_MS, 10, 60_000);
|
|
43
|
+
return {
|
|
44
|
+
requestTimeoutMs: boundedInteger(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 50, 60_000),
|
|
45
|
+
heartbeatMs: boundedInteger(options.heartbeatMs, DEFAULT_HEARTBEAT_MS, 50, 300_000),
|
|
46
|
+
reconnectBaseMs,
|
|
47
|
+
reconnectMaxMs: Math.max(reconnectBaseMs, boundedInteger(options.reconnectMaxMs, DEFAULT_RECONNECT_MAX_MS, 10, 300_000)),
|
|
48
|
+
maxAuthFailures: boundedInteger(options.maxAuthFailures, DEFAULT_MAX_AUTH_FAILURES, 1, 20),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function signalError(signal, fallback) {
|
|
52
|
+
return signal?.reason instanceof Error ? signal.reason : new Error(fallback);
|
|
53
|
+
}
|
|
54
|
+
function throwIfAborted(signal, label) {
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
throw signalError(signal, `${label} cancelled`);
|
|
57
|
+
}
|
|
58
|
+
function waitForDelay(ms, signal) {
|
|
36
59
|
if (signal?.aborted)
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
return Promise.reject(signalError(signal, "WeCom operation cancelled"));
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const finish = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
signal?.removeEventListener("abort", onAbort);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
const onAbort = () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
signal?.removeEventListener("abort", onAbort);
|
|
70
|
+
reject(signalError(signal, "WeCom operation cancelled"));
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(finish, ms);
|
|
73
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function reconnectDelay(attempt, options) {
|
|
77
|
+
return Math.min(options.reconnectMaxMs, options.reconnectBaseMs * (2 ** Math.max(0, attempt - 1)));
|
|
78
|
+
}
|
|
79
|
+
function protocolText(value, secret) {
|
|
80
|
+
let text = String(value ?? "unknown error").replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 240);
|
|
81
|
+
if (secret)
|
|
82
|
+
text = text.split(secret).join("[redacted]");
|
|
83
|
+
return text || "unknown error";
|
|
84
|
+
}
|
|
41
85
|
const isImageName = (name) => /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
42
|
-
/** Detect an image extension from magic bytes (pure) — used when WeCom doesn't give us a filename. */
|
|
43
86
|
function imageExtFromBytes(data) {
|
|
44
87
|
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
|
45
88
|
return ".png";
|
|
@@ -51,99 +94,114 @@ function imageExtFromBytes(data) {
|
|
|
51
94
|
return ".webp";
|
|
52
95
|
return ".jpg";
|
|
53
96
|
}
|
|
54
|
-
/** AES-256-CBC
|
|
55
|
-
* first 16 bytes of that key; padding is PKCS#7 (validated leniently, like the Hermes reference). */
|
|
97
|
+
/** AES-256-CBC + WeCom's PKCS#7 (block size 32) media decryption. Invalid padding fails closed. */
|
|
56
98
|
export function decryptWecomMedia(ciphertext, aesKeyB64) {
|
|
57
|
-
|
|
99
|
+
if (!ciphertext.length)
|
|
100
|
+
throw new Error("WeCom encrypted media is empty");
|
|
101
|
+
if (ciphertext.length % 16 !== 0)
|
|
102
|
+
throw new Error("invalid WeCom encrypted media length");
|
|
103
|
+
const key = Buffer.from(String(aesKeyB64 ?? ""), "base64");
|
|
58
104
|
if (key.length !== 32)
|
|
59
105
|
throw new Error(`unexpected WeCom aes_key length: ${key.length} (expected 32)`);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const padded = Buffer.concat([
|
|
63
|
-
if (!padded.length)
|
|
64
|
-
return padded;
|
|
106
|
+
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
|
107
|
+
decipher.setAutoPadding(false);
|
|
108
|
+
const padded = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
65
109
|
const pad = padded[padded.length - 1];
|
|
66
|
-
if (pad
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (ok)
|
|
72
|
-
return padded.subarray(0, padded.length - pad);
|
|
110
|
+
if (pad < 1 || pad > 32 || pad > padded.length)
|
|
111
|
+
throw new Error("invalid WeCom media padding");
|
|
112
|
+
for (let i = padded.length - pad; i < padded.length; i++) {
|
|
113
|
+
if (padded[i] !== pad)
|
|
114
|
+
throw new Error("invalid WeCom media padding");
|
|
73
115
|
}
|
|
74
|
-
return padded;
|
|
116
|
+
return padded.subarray(0, padded.length - pad);
|
|
75
117
|
}
|
|
76
|
-
/** Pull the plain text out of a WeCom callback body (pure). Handles `text`, `voice` (transcription), and `mixed`
|
|
77
|
-
* (text + image runs) message types — joining all text runs with newlines. */
|
|
78
118
|
function extractWecomText(body) {
|
|
79
119
|
const parts = [];
|
|
80
120
|
const msgtype = String(body?.msgtype ?? "").toLowerCase();
|
|
81
121
|
if (msgtype === "mixed") {
|
|
82
122
|
const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
|
|
83
|
-
for (const
|
|
84
|
-
if (String(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
for (const item of items) {
|
|
124
|
+
if (String(item?.msgtype ?? "").toLowerCase() !== "text")
|
|
125
|
+
continue;
|
|
126
|
+
const content = String(item?.text?.content ?? "").trim();
|
|
127
|
+
if (content)
|
|
128
|
+
parts.push(content);
|
|
89
129
|
}
|
|
90
130
|
}
|
|
91
131
|
else {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
parts.push(
|
|
132
|
+
const content = String(body?.text?.content ?? "").trim();
|
|
133
|
+
if (content)
|
|
134
|
+
parts.push(content);
|
|
95
135
|
if (msgtype === "voice") {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
parts.push(
|
|
136
|
+
const transcript = String(body?.voice?.content ?? "").trim();
|
|
137
|
+
if (transcript)
|
|
138
|
+
parts.push(transcript);
|
|
99
139
|
}
|
|
100
140
|
}
|
|
101
141
|
return parts.join("\n").trim();
|
|
102
142
|
}
|
|
103
|
-
/** Collect downloadable media refs from a WeCom callback body (pure). Covers top-level `image`/`file` and the image
|
|
104
|
-
* runs inside a `mixed` message. */
|
|
105
143
|
function extractWecomMedia(body) {
|
|
106
144
|
const refs = [];
|
|
107
|
-
const pushMedia = (kind,
|
|
108
|
-
if (!
|
|
145
|
+
const pushMedia = (kind, media) => {
|
|
146
|
+
if (!media || typeof media !== "object")
|
|
109
147
|
return;
|
|
110
|
-
|
|
148
|
+
const ref = {
|
|
111
149
|
kind,
|
|
112
|
-
url: typeof
|
|
113
|
-
base64: typeof
|
|
114
|
-
aesKeyB64: typeof
|
|
115
|
-
fileName: typeof
|
|
116
|
-
|
|
150
|
+
url: typeof media.url === "string" ? media.url : undefined,
|
|
151
|
+
base64: typeof media.base64 === "string" ? media.base64 : undefined,
|
|
152
|
+
aesKeyB64: typeof media.aeskey === "string" ? media.aeskey : undefined,
|
|
153
|
+
fileName: typeof media.filename === "string"
|
|
154
|
+
? media.filename
|
|
155
|
+
: typeof media.name === "string"
|
|
156
|
+
? media.name
|
|
157
|
+
: undefined,
|
|
158
|
+
};
|
|
159
|
+
// Do not admit metadata-only placeholders into the download budget.
|
|
160
|
+
if (ref.url || ref.base64)
|
|
161
|
+
refs.push(ref);
|
|
117
162
|
};
|
|
118
163
|
const msgtype = String(body?.msgtype ?? "").toLowerCase();
|
|
119
164
|
if (msgtype === "mixed") {
|
|
120
165
|
const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
|
|
121
|
-
for (const
|
|
122
|
-
if (String(
|
|
123
|
-
pushMedia("image",
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
if (String(item?.msgtype ?? "").toLowerCase() === "image")
|
|
168
|
+
pushMedia("image", item.image);
|
|
169
|
+
}
|
|
124
170
|
}
|
|
125
171
|
else {
|
|
126
|
-
if (
|
|
127
|
-
pushMedia("image", body
|
|
128
|
-
if (msgtype === "file"
|
|
129
|
-
pushMedia("file", body
|
|
172
|
+
if (msgtype === "image")
|
|
173
|
+
pushMedia("image", body?.image);
|
|
174
|
+
if (msgtype === "file")
|
|
175
|
+
pushMedia("file", body?.file);
|
|
176
|
+
if (msgtype === "video")
|
|
177
|
+
pushMedia("video", body?.video);
|
|
130
178
|
}
|
|
131
179
|
return refs;
|
|
132
180
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
181
|
+
/** WeCom transports `create_time` as seconds today; tolerate millisecond fixtures and future protocol drift. */
|
|
182
|
+
export function wecomTimestampMs(value) {
|
|
183
|
+
const parsed = Number(value);
|
|
184
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
185
|
+
return undefined;
|
|
186
|
+
return Math.trunc(parsed < 1_000_000_000_000 ? parsed * 1_000 : parsed);
|
|
187
|
+
}
|
|
188
|
+
function stableWecomId(value) {
|
|
189
|
+
if (typeof value === "string")
|
|
190
|
+
return value.trim() || undefined;
|
|
191
|
+
if (Number.isSafeInteger(value))
|
|
192
|
+
return String(value);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
/** Parse a WeCom callback into the gateway's platform-neutral inbound message. */
|
|
137
196
|
export function parseWecomMessage(payload, selfBotId) {
|
|
138
197
|
const body = payload?.body;
|
|
139
198
|
if (!body || typeof body !== "object")
|
|
140
199
|
return null;
|
|
141
200
|
const sender = typeof body.from === "object" && body.from ? body.from : {};
|
|
142
201
|
const senderId = String(sender.userid ?? "").trim();
|
|
143
|
-
// ignore our own messages (the bot speaking) so we never loop on our replies
|
|
144
202
|
if (selfBotId && senderId && senderId === selfBotId)
|
|
145
203
|
return null;
|
|
146
|
-
if (selfBotId && String(body.bot_id ?? body.botid ?? "").trim() === selfBotId && !senderId)
|
|
204
|
+
if (selfBotId && String(body.aibotid ?? body.bot_id ?? body.botid ?? "").trim() === selfBotId && !senderId)
|
|
147
205
|
return null;
|
|
148
206
|
const chatId = String(body.chatid ?? senderId).trim();
|
|
149
207
|
if (!chatId)
|
|
@@ -151,23 +209,27 @@ export function parseWecomMessage(payload, selfBotId) {
|
|
|
151
209
|
const text = extractWecomText(body);
|
|
152
210
|
const media = extractWecomMedia(body);
|
|
153
211
|
if (!text && !media.length)
|
|
154
|
-
return null;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
212
|
+
return null;
|
|
213
|
+
const messageId = stableWecomId(body.msgid) ?? stableWecomId(payload?.headers?.req_id);
|
|
214
|
+
const createdAtMs = wecomTimestampMs(body.create_time);
|
|
215
|
+
const marker = media.some((ref) => ref.kind === "image")
|
|
216
|
+
? "[图片]"
|
|
217
|
+
: media.some((ref) => ref.kind === "video")
|
|
218
|
+
? "[视频]"
|
|
219
|
+
: "[附件]";
|
|
158
220
|
return {
|
|
159
221
|
msg: {
|
|
160
222
|
chatId,
|
|
161
223
|
userId: senderId || chatId,
|
|
162
224
|
userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
|
|
163
|
-
text: text ||
|
|
164
|
-
|
|
225
|
+
text: text || marker,
|
|
226
|
+
...(messageId ? { messageId } : {}),
|
|
227
|
+
...(createdAtMs === undefined ? {} : { createdAtMs }),
|
|
228
|
+
chatType: String(body.chattype ?? "").trim().toLowerCase() === "single" ? "p2p" : "group",
|
|
165
229
|
},
|
|
166
230
|
media,
|
|
167
231
|
};
|
|
168
232
|
}
|
|
169
|
-
/** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
|
|
170
|
-
* failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
|
|
171
233
|
async function downloadWecomMedia(ref, options) {
|
|
172
234
|
try {
|
|
173
235
|
let data = null;
|
|
@@ -175,23 +237,25 @@ async function downloadWecomMedia(ref, options) {
|
|
|
175
237
|
data = decodeBase64Media(ref.base64, options.maxBytes);
|
|
176
238
|
}
|
|
177
239
|
else if (ref.url) {
|
|
178
|
-
const
|
|
179
|
-
if (!
|
|
240
|
+
const response = await fetch(ref.url, { signal: options.signal });
|
|
241
|
+
if (!response.ok)
|
|
180
242
|
return null;
|
|
181
|
-
data = await readResponseBytesLimited(
|
|
243
|
+
data = await readResponseBytesLimited(response, options.maxBytes, options.signal);
|
|
182
244
|
}
|
|
183
|
-
if (!data
|
|
245
|
+
if (!data?.length)
|
|
184
246
|
return null;
|
|
185
247
|
if (ref.aesKeyB64)
|
|
186
248
|
data = decryptWecomMedia(data, ref.aesKeyB64);
|
|
187
249
|
if (!data.length || data.length > options.maxBytes)
|
|
188
250
|
return null;
|
|
189
251
|
let name = ref.fileName ? basename(ref.fileName) : "";
|
|
190
|
-
if (
|
|
252
|
+
if (ref.kind === "image" && (!name || !extname(name)))
|
|
191
253
|
name = `image${imageExtFromBytes(data)}`;
|
|
254
|
+
if (!name)
|
|
255
|
+
name = ref.kind === "video" ? "video.mp4" : "file.bin";
|
|
192
256
|
return await savePrivateMediaBytes(data, {
|
|
193
257
|
platform: "wecom",
|
|
194
|
-
filenameHint: name
|
|
258
|
+
filenameHint: name,
|
|
195
259
|
...options,
|
|
196
260
|
});
|
|
197
261
|
}
|
|
@@ -199,192 +263,417 @@ async function downloadWecomMedia(ref, options) {
|
|
|
199
263
|
return null;
|
|
200
264
|
}
|
|
201
265
|
}
|
|
202
|
-
export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
203
|
-
|
|
204
|
-
|
|
266
|
+
export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL, transportOptions = {}) {
|
|
267
|
+
if (!botId.trim() || !secret)
|
|
268
|
+
throw new Error("WeCom bot id and secret are required");
|
|
269
|
+
const options = resolveTransportOptions(transportOptions);
|
|
205
270
|
const conn = { send: null };
|
|
206
|
-
|
|
207
|
-
async function uploadMedia(file, mediaType) {
|
|
208
|
-
if (!conn.send)
|
|
209
|
-
throw new Error("WeCom socket not connected");
|
|
271
|
+
const outbound = new PerChatOutboundLane("wecom", botId);
|
|
272
|
+
async function uploadMedia(request, file, mediaType, signal) {
|
|
210
273
|
const data = file.bytes;
|
|
274
|
+
if (!data.length)
|
|
275
|
+
throw new Error("cannot upload an empty file to WeCom");
|
|
211
276
|
if (data.length > ABSOLUTE_MAX_BYTES)
|
|
212
277
|
throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
|
|
213
|
-
const totalChunks = Math.
|
|
214
|
-
const init = await
|
|
278
|
+
const totalChunks = Math.ceil(data.length / UPLOAD_CHUNK_SIZE);
|
|
279
|
+
const init = await request(CMD_UPLOAD_INIT, {
|
|
215
280
|
type: mediaType,
|
|
216
|
-
filename: file.safeName,
|
|
281
|
+
filename: basename(file.safeName) || "file.bin",
|
|
217
282
|
total_size: data.length,
|
|
218
283
|
total_chunks: totalChunks,
|
|
219
284
|
md5: createHash("md5").update(data).digest("hex"),
|
|
220
|
-
});
|
|
285
|
+
}, signal);
|
|
221
286
|
const uploadId = String(init?.body?.upload_id ?? "").trim();
|
|
222
287
|
if (!uploadId)
|
|
223
|
-
throw new Error(
|
|
224
|
-
for (let
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
288
|
+
throw new Error("WeCom media upload init returned no upload_id");
|
|
289
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
290
|
+
const start = index * UPLOAD_CHUNK_SIZE;
|
|
291
|
+
const chunk = data.subarray(start, Math.min(start + UPLOAD_CHUNK_SIZE, data.length));
|
|
292
|
+
let uploaded = false;
|
|
293
|
+
let lastError;
|
|
294
|
+
for (let attempt = 0; attempt <= MAX_UPLOAD_CHUNK_RETRIES; attempt++) {
|
|
295
|
+
throwIfAborted(signal, "WeCom upload");
|
|
296
|
+
try {
|
|
297
|
+
await request(CMD_UPLOAD_CHUNK, {
|
|
298
|
+
upload_id: uploadId,
|
|
299
|
+
chunk_index: index,
|
|
300
|
+
base64_data: chunk.toString("base64"),
|
|
301
|
+
}, signal);
|
|
302
|
+
uploaded = true;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
lastError = error;
|
|
307
|
+
if (attempt < MAX_UPLOAD_CHUNK_RETRIES)
|
|
308
|
+
await waitForDelay(250 * (attempt + 1), signal);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!uploaded) {
|
|
312
|
+
const reason = lastError instanceof Error ? lastError.message : "unknown transport error";
|
|
313
|
+
throw new Error(`WeCom media chunk ${index} failed after ${MAX_UPLOAD_CHUNK_RETRIES + 1} attempts: ${reason}`);
|
|
314
|
+
}
|
|
229
315
|
}
|
|
230
|
-
const
|
|
231
|
-
const mediaId = String(
|
|
316
|
+
const finish = await request(CMD_UPLOAD_FINISH, { upload_id: uploadId }, signal);
|
|
317
|
+
const mediaId = String(finish?.body?.media_id ?? "").trim();
|
|
232
318
|
if (!mediaId)
|
|
233
|
-
throw new Error(
|
|
319
|
+
throw new Error("WeCom media upload finish returned no media_id");
|
|
234
320
|
return mediaId;
|
|
235
321
|
}
|
|
236
322
|
return {
|
|
237
323
|
name: "wecom",
|
|
238
|
-
async send(chatId, text) {
|
|
239
|
-
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
324
|
+
async send(chatId, text, signal) {
|
|
325
|
+
await withOutboundDeadline("WeCom send", signal, outboundTransferTimeoutMs("text"), async (transferSignal) => {
|
|
326
|
+
return outbound.run(chatId, async () => {
|
|
327
|
+
const request = conn.send;
|
|
328
|
+
if (!request)
|
|
329
|
+
throw new Error("WeCom socket is not authenticated");
|
|
330
|
+
for (const part of chunkText(text || "(empty)", MAX_MESSAGE_LENGTH)) {
|
|
331
|
+
await request(CMD_SEND, {
|
|
332
|
+
chatid: String(chatId),
|
|
333
|
+
msgtype: "markdown",
|
|
334
|
+
markdown: { content: part },
|
|
335
|
+
}, transferSignal);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
246
339
|
},
|
|
247
|
-
async sendFile(chatId, file) {
|
|
248
|
-
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
340
|
+
async sendFile(chatId, file, signal) {
|
|
341
|
+
await withOutboundDeadline("WeCom upload", signal, outboundTransferTimeoutMs("file"), async (transferSignal) => {
|
|
342
|
+
return outbound.run(chatId, async () => {
|
|
343
|
+
const request = conn.send;
|
|
344
|
+
if (!request)
|
|
345
|
+
throw new Error("WeCom socket is not authenticated");
|
|
346
|
+
const mediaType = isImageName(file.safeName) ? "image" : "file";
|
|
347
|
+
const mediaId = await uploadMedia(request, file, mediaType, transferSignal);
|
|
348
|
+
await request(CMD_SEND, {
|
|
349
|
+
chatid: String(chatId),
|
|
350
|
+
msgtype: mediaType,
|
|
351
|
+
[mediaType]: { media_id: mediaId },
|
|
352
|
+
}, transferSignal);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
258
355
|
},
|
|
259
356
|
async start(onMessage, signal, shouldDownload) {
|
|
260
|
-
if (!WSImpl)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
357
|
+
if (!WSImpl)
|
|
358
|
+
throw new Error("WeCom gateway requires Node 22.12 or newer; upgrade Node and restart Hara");
|
|
359
|
+
let authFailures = 0;
|
|
360
|
+
let reconnectAttempts = 0;
|
|
264
361
|
while (!signal.aborted) {
|
|
265
|
-
await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload);
|
|
266
|
-
if (
|
|
267
|
-
|
|
362
|
+
const outcome = await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload, options);
|
|
363
|
+
if (signal.aborted || outcome.reason === "aborted")
|
|
364
|
+
return;
|
|
365
|
+
if (outcome.reason === "superseded") {
|
|
366
|
+
throw new Error("WeCom connection was replaced by another active bot connection; stopped to avoid a reconnect loop");
|
|
367
|
+
}
|
|
368
|
+
let attempt;
|
|
369
|
+
if (outcome.reason === "auth-failed") {
|
|
370
|
+
authFailures++;
|
|
371
|
+
reconnectAttempts = 0;
|
|
372
|
+
if (authFailures >= options.maxAuthFailures) {
|
|
373
|
+
throw new Error(`WeCom authentication failed ${authFailures} time(s); check HARA_WECOM_BOT_ID and HARA_WECOM_SECRET`);
|
|
374
|
+
}
|
|
375
|
+
attempt = authFailures;
|
|
376
|
+
console.error(`hara wecom: authentication failed (attempt ${authFailures}/${options.maxAuthFailures}); reconnecting.`);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
authFailures = 0;
|
|
380
|
+
reconnectAttempts = outcome.authenticated ? 1 : reconnectAttempts + 1;
|
|
381
|
+
attempt = reconnectAttempts;
|
|
382
|
+
console.error(`hara wecom: ${outcome.detail ?? "connection closed"}; reconnecting.`);
|
|
383
|
+
}
|
|
384
|
+
await waitForDelay(reconnectDelay(attempt, options), signal).catch(() => undefined);
|
|
268
385
|
}
|
|
269
386
|
},
|
|
270
387
|
};
|
|
271
388
|
}
|
|
272
|
-
|
|
273
|
-
* `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
|
|
274
|
-
* subscribe each time — v1 keeps it simple, no resume). */
|
|
275
|
-
function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload) {
|
|
389
|
+
function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload, options) {
|
|
276
390
|
return new Promise((resolve) => {
|
|
277
391
|
const ws = new WSImpl(wsUrl);
|
|
278
|
-
let hb = null;
|
|
279
392
|
const pending = new Map();
|
|
280
|
-
const
|
|
281
|
-
|
|
393
|
+
const heartbeatIds = new Set();
|
|
394
|
+
let heartbeatTimer = null;
|
|
395
|
+
let authTimer = null;
|
|
396
|
+
let authReqId = "";
|
|
397
|
+
let missedHeartbeats = 0;
|
|
398
|
+
let authenticated = false;
|
|
399
|
+
let settled = false;
|
|
400
|
+
const closeSocket = () => {
|
|
282
401
|
try {
|
|
283
|
-
ws.
|
|
402
|
+
if (ws.readyState === WS_CONNECTING || ws.readyState === WS_OPEN)
|
|
403
|
+
ws.close();
|
|
284
404
|
}
|
|
285
405
|
catch {
|
|
286
|
-
/*
|
|
406
|
+
/* already closed */
|
|
287
407
|
}
|
|
288
408
|
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
409
|
+
const detachPending = (entry) => {
|
|
410
|
+
clearTimeout(entry.timer);
|
|
411
|
+
if (entry.signal && entry.onAbort)
|
|
412
|
+
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
413
|
+
};
|
|
414
|
+
const cleanup = (reason) => {
|
|
415
|
+
if (heartbeatTimer)
|
|
416
|
+
clearInterval(heartbeatTimer);
|
|
417
|
+
if (authTimer)
|
|
418
|
+
clearTimeout(authTimer);
|
|
419
|
+
heartbeatTimer = null;
|
|
420
|
+
authTimer = null;
|
|
421
|
+
heartbeatIds.clear();
|
|
302
422
|
signal.removeEventListener("abort", stop);
|
|
303
|
-
for (const
|
|
304
|
-
|
|
305
|
-
|
|
423
|
+
for (const entry of pending.values()) {
|
|
424
|
+
detachPending(entry);
|
|
425
|
+
entry.reject(reason);
|
|
306
426
|
}
|
|
307
427
|
pending.clear();
|
|
308
428
|
if (conn.send === request)
|
|
309
429
|
conn.send = null;
|
|
310
430
|
};
|
|
431
|
+
const finish = (reason, detail) => {
|
|
432
|
+
if (settled)
|
|
433
|
+
return;
|
|
434
|
+
settled = true;
|
|
435
|
+
cleanup(new Error(detail ?? "WeCom connection closed"));
|
|
436
|
+
if (reason !== "closed")
|
|
437
|
+
closeSocket();
|
|
438
|
+
resolve({ reason, authenticated, ...(detail ? { detail } : {}) });
|
|
439
|
+
};
|
|
311
440
|
const stop = () => {
|
|
312
|
-
|
|
441
|
+
finish("aborted", "WeCom gateway stopped");
|
|
442
|
+
closeSocket();
|
|
443
|
+
};
|
|
444
|
+
const sendRaw = (frame) => {
|
|
445
|
+
if (settled || ws.readyState !== WS_OPEN)
|
|
446
|
+
throw new Error("WeCom socket is not open");
|
|
447
|
+
ws.send(JSON.stringify(frame));
|
|
448
|
+
};
|
|
449
|
+
const request = (command, body, requestSignal) => {
|
|
450
|
+
if (settled || !authenticated || ws.readyState !== WS_OPEN) {
|
|
451
|
+
return Promise.reject(new Error("WeCom socket is not authenticated"));
|
|
452
|
+
}
|
|
453
|
+
if (requestSignal?.aborted)
|
|
454
|
+
return Promise.reject(signalError(requestSignal, `WeCom ${command} cancelled`));
|
|
455
|
+
const reqId = `${command}-${randomUUID()}`;
|
|
456
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
457
|
+
const onAbort = () => {
|
|
458
|
+
const entry = pending.get(reqId);
|
|
459
|
+
if (!entry)
|
|
460
|
+
return;
|
|
461
|
+
pending.delete(reqId);
|
|
462
|
+
detachPending(entry);
|
|
463
|
+
rejectRequest(signalError(requestSignal, `WeCom ${command} cancelled`));
|
|
464
|
+
};
|
|
465
|
+
const timer = setTimeout(() => {
|
|
466
|
+
const entry = pending.get(reqId);
|
|
467
|
+
if (!entry)
|
|
468
|
+
return;
|
|
469
|
+
pending.delete(reqId);
|
|
470
|
+
detachPending(entry);
|
|
471
|
+
rejectRequest(new Error(`WeCom ${command} timed out after ${options.requestTimeoutMs}ms`));
|
|
472
|
+
}, options.requestTimeoutMs);
|
|
473
|
+
const entry = {
|
|
474
|
+
command,
|
|
475
|
+
resolve: resolveRequest,
|
|
476
|
+
reject: rejectRequest,
|
|
477
|
+
timer,
|
|
478
|
+
signal: requestSignal,
|
|
479
|
+
onAbort,
|
|
480
|
+
};
|
|
481
|
+
pending.set(reqId, entry);
|
|
482
|
+
requestSignal?.addEventListener("abort", onAbort, { once: true });
|
|
483
|
+
try {
|
|
484
|
+
sendRaw({ cmd: command, headers: { req_id: reqId }, body });
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
pending.delete(reqId);
|
|
488
|
+
detachPending(entry);
|
|
489
|
+
rejectRequest(error instanceof Error ? error : new Error("WeCom request send failed"));
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
const settleRequest = (reqId, frame) => {
|
|
494
|
+
const entry = pending.get(reqId);
|
|
495
|
+
if (!entry)
|
|
496
|
+
return false;
|
|
497
|
+
pending.delete(reqId);
|
|
498
|
+
detachPending(entry);
|
|
499
|
+
const code = Number(frame?.errcode);
|
|
500
|
+
if (code !== 0) {
|
|
501
|
+
entry.reject(new Error(`WeCom ${entry.command} failed (code ${Number.isFinite(code) ? code : "invalid"}): ${protocolText(frame?.errmsg, secret)}`));
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
entry.resolve(frame);
|
|
505
|
+
}
|
|
506
|
+
return true;
|
|
507
|
+
};
|
|
508
|
+
const handleCallback = async (frame) => {
|
|
509
|
+
if (signal.aborted || !authenticated)
|
|
510
|
+
return;
|
|
511
|
+
const parsed = parseWecomMessage(frame, botId);
|
|
512
|
+
if (!parsed)
|
|
513
|
+
return;
|
|
514
|
+
const transientFiles = [];
|
|
515
|
+
let handedOff = false;
|
|
313
516
|
try {
|
|
314
|
-
|
|
517
|
+
let text = parsed.msg.text;
|
|
518
|
+
const images = [];
|
|
519
|
+
if (shouldDownload?.(parsed.msg) === true && parsed.media.length) {
|
|
520
|
+
const budget = new InboundMediaBudget("wecom", signal);
|
|
521
|
+
for (const ref of parsed.media) {
|
|
522
|
+
const path = await budget.download((downloadOptions) => downloadWecomMedia(ref, downloadOptions));
|
|
523
|
+
if (!path)
|
|
524
|
+
continue;
|
|
525
|
+
transientFiles.push(path);
|
|
526
|
+
if (ref.kind === "image") {
|
|
527
|
+
images.push(path);
|
|
528
|
+
if (text !== "[图片]" && !text.includes("[图片]"))
|
|
529
|
+
text += `${text ? "\n" : ""}[图片]`;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
const filename = ref.fileName ? basename(ref.fileName) : "";
|
|
533
|
+
const label = ref.kind === "video"
|
|
534
|
+
? `视频${filename ? ` ${filename}` : ""}`
|
|
535
|
+
: `文件${filename ? ` ${filename}` : ""}`;
|
|
536
|
+
const marker = `[${label}: ${path}]`;
|
|
537
|
+
text = text === "[附件]" || text === "[视频]" ? marker : `${text}${text ? "\n" : ""}${marker}`;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (signal.aborted)
|
|
542
|
+
return;
|
|
543
|
+
const inbound = {
|
|
544
|
+
...parsed.msg,
|
|
545
|
+
text: text.trim() || parsed.msg.text,
|
|
546
|
+
images: images.length ? images : undefined,
|
|
547
|
+
transientFiles: transientFiles.length ? transientFiles : undefined,
|
|
548
|
+
};
|
|
549
|
+
handedOff = true;
|
|
550
|
+
await onMessage(inbound);
|
|
315
551
|
}
|
|
316
|
-
|
|
317
|
-
|
|
552
|
+
finally {
|
|
553
|
+
if (!handedOff && transientFiles.length)
|
|
554
|
+
await cleanupTransientMedia("wecom", transientFiles);
|
|
318
555
|
}
|
|
319
|
-
resolve();
|
|
320
556
|
};
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
557
|
+
const handleMessage = async (event) => {
|
|
558
|
+
const data = event?.data;
|
|
559
|
+
if (typeof data === "string" && Buffer.byteLength(data, "utf8") > MAX_FRAME_BYTES) {
|
|
560
|
+
finish("closed", "received an oversized WeCom frame");
|
|
561
|
+
closeSocket();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (data instanceof ArrayBuffer && data.byteLength > MAX_FRAME_BYTES) {
|
|
565
|
+
finish("closed", "received an oversized WeCom frame");
|
|
566
|
+
closeSocket();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (typeof data?.size === "number" && data.size > MAX_FRAME_BYTES) {
|
|
570
|
+
finish("closed", "received an oversized WeCom frame");
|
|
571
|
+
closeSocket();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
335
574
|
let raw;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
raw = Buffer.from(await d.arrayBuffer()).toString("utf8");
|
|
575
|
+
if (typeof data === "string")
|
|
576
|
+
raw = data;
|
|
577
|
+
else if (data instanceof ArrayBuffer)
|
|
578
|
+
raw = Buffer.from(data).toString("utf8");
|
|
579
|
+
else if (typeof data?.arrayBuffer === "function")
|
|
580
|
+
raw = Buffer.from(await data.arrayBuffer()).toString("utf8");
|
|
343
581
|
else
|
|
344
|
-
raw = String(
|
|
345
|
-
let
|
|
582
|
+
raw = String(data ?? "");
|
|
583
|
+
let frame;
|
|
346
584
|
try {
|
|
347
|
-
|
|
585
|
+
frame = JSON.parse(raw);
|
|
348
586
|
}
|
|
349
587
|
catch {
|
|
350
588
|
return;
|
|
351
589
|
}
|
|
352
|
-
const cmd = String(
|
|
353
|
-
const reqId = String(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
590
|
+
const cmd = String(frame?.cmd ?? "");
|
|
591
|
+
const reqId = String(frame?.headers?.req_id ?? "");
|
|
592
|
+
if (reqId && reqId === authReqId) {
|
|
593
|
+
if (authenticated)
|
|
594
|
+
return; // a duplicate auth ACK must not install a second heartbeat timer
|
|
595
|
+
if (authTimer)
|
|
596
|
+
clearTimeout(authTimer);
|
|
597
|
+
authTimer = null;
|
|
598
|
+
if (Number(frame?.errcode) !== 0) {
|
|
599
|
+
finish("auth-failed", `WeCom authentication failed (code ${Number.isFinite(Number(frame?.errcode)) ? Number(frame.errcode) : "invalid"}): ${protocolText(frame?.errmsg, secret)}`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
authenticated = true;
|
|
603
|
+
missedHeartbeats = 0;
|
|
604
|
+
conn.send = request;
|
|
605
|
+
console.error("hara wecom: authenticated.");
|
|
606
|
+
heartbeatTimer = setInterval(() => {
|
|
607
|
+
if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
|
|
608
|
+
finish("closed", `heartbeat ACK timed out after ${missedHeartbeats} missed ping(s)`);
|
|
609
|
+
closeSocket();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const heartbeatId = `${CMD_PING}-${randomUUID()}`;
|
|
613
|
+
missedHeartbeats++;
|
|
614
|
+
heartbeatIds.add(heartbeatId);
|
|
615
|
+
try {
|
|
616
|
+
sendRaw({ cmd: CMD_PING, headers: { req_id: heartbeatId }, body: {} });
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
finish("closed", "heartbeat send failed");
|
|
620
|
+
closeSocket();
|
|
621
|
+
}
|
|
622
|
+
}, options.heartbeatMs);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (reqId && heartbeatIds.has(reqId)) {
|
|
626
|
+
heartbeatIds.delete(reqId);
|
|
627
|
+
if (Number(frame?.errcode) === 0) {
|
|
628
|
+
missedHeartbeats = 0;
|
|
629
|
+
heartbeatIds.clear();
|
|
630
|
+
}
|
|
360
631
|
return;
|
|
361
632
|
}
|
|
362
|
-
if (
|
|
363
|
-
return;
|
|
633
|
+
if (reqId && !CALLBACK_CMDS.has(cmd) && cmd !== CMD_EVENT_CALLBACK && settleRequest(reqId, frame))
|
|
634
|
+
return;
|
|
635
|
+
if (!authenticated)
|
|
636
|
+
return;
|
|
637
|
+
if (cmd === CMD_EVENT_CALLBACK) {
|
|
638
|
+
if (String(frame?.body?.event?.eventtype ?? "") === "disconnected_event") {
|
|
639
|
+
finish("superseded", "another active WeCom connection replaced this one");
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (cmd === CMD_PING)
|
|
644
|
+
return;
|
|
364
645
|
if (!CALLBACK_CMDS.has(cmd))
|
|
365
|
-
return;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
646
|
+
return;
|
|
647
|
+
await handleCallback(frame);
|
|
648
|
+
};
|
|
649
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
650
|
+
ws.addEventListener("open", () => {
|
|
651
|
+
authReqId = `${CMD_SUBSCRIBE}-${randomUUID()}`;
|
|
652
|
+
authTimer = setTimeout(() => {
|
|
653
|
+
finish("auth-failed", `WeCom authentication ACK timed out after ${options.requestTimeoutMs}ms`);
|
|
654
|
+
closeSocket();
|
|
655
|
+
}, options.requestTimeoutMs);
|
|
656
|
+
try {
|
|
657
|
+
sendRaw({
|
|
658
|
+
cmd: CMD_SUBSCRIBE,
|
|
659
|
+
headers: { req_id: authReqId },
|
|
660
|
+
body: { bot_id: botId, secret },
|
|
661
|
+
});
|
|
373
662
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const budget = new InboundMediaBudget("wecom", signal);
|
|
378
|
-
for (const ref of parsed.media) {
|
|
379
|
-
const path = await budget.download((options) => downloadWecomMedia(ref, options));
|
|
380
|
-
if (path) {
|
|
381
|
-
(parsed.msg.images ??= []).push(path);
|
|
382
|
-
(parsed.msg.transientFiles ??= []).push(path);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
await onMessage(parsed.msg).catch(() => { });
|
|
663
|
+
catch {
|
|
664
|
+
finish("closed", "could not send the WeCom authentication frame");
|
|
665
|
+
closeSocket();
|
|
387
666
|
}
|
|
388
667
|
});
|
|
668
|
+
ws.addEventListener("message", (event) => {
|
|
669
|
+
void handleMessage(event).catch((error) => {
|
|
670
|
+
console.error(`hara wecom: callback handling failed — ${protocolText(error instanceof Error ? error.message : error, secret)}`);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
ws.addEventListener("close", () => finish("closed", "connection closed"));
|
|
674
|
+
ws.addEventListener("error", () => {
|
|
675
|
+
finish("closed", "WebSocket transport error");
|
|
676
|
+
closeSocket();
|
|
677
|
+
});
|
|
389
678
|
});
|
|
390
679
|
}
|