@botcord/daemon 0.2.52 → 0.2.53
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/dist/gateway/channels/http-types.d.ts +4 -1
- package/dist/gateway/channels/wechat.js +152 -19
- package/dist/gateway/types.d.ts +10 -0
- package/dist/index.js +85 -15
- package/dist/start-auth.js +2 -2
- package/dist/turn-text.js +9 -0
- package/package.json +1 -1
- package/src/__tests__/start-auth.test.ts +2 -2
- package/src/__tests__/turn-text.test.ts +3 -0
- package/src/__tests__/wechat-channel.test.ts +126 -8
- package/src/gateway/channels/http-types.ts +2 -1
- package/src/gateway/channels/wechat.ts +180 -19
- package/src/gateway/types.ts +11 -0
- package/src/index.ts +83 -16
- package/src/start-auth.ts +1 -1
- package/src/turn-text.ts +13 -0
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
export type FetchLike = (input: string, init?: {
|
|
11
11
|
method?: string;
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
|
-
body?: string;
|
|
13
|
+
body?: BodyInit | Uint8Array | string;
|
|
14
14
|
signal?: AbortSignal;
|
|
15
15
|
}) => Promise<{
|
|
16
16
|
status?: number;
|
|
17
17
|
ok?: boolean;
|
|
18
|
+
headers?: {
|
|
19
|
+
get(name: string): string | null;
|
|
20
|
+
};
|
|
18
21
|
text(): Promise<string>;
|
|
19
22
|
}>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createCipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
1
4
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
2
5
|
import { GatewayStateStore } from "./state-store.js";
|
|
3
6
|
import { loadGatewaySecret } from "./secret-store.js";
|
|
4
7
|
import { splitText } from "./text-split.js";
|
|
5
8
|
import { wechatHeaders, WECHAT_BASE_INFO } from "./wechat-http.js";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
9
|
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
10
|
+
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
8
11
|
/**
|
|
9
12
|
* Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
|
|
10
13
|
* No-ops when token is falsy (not yet loaded).
|
|
@@ -149,6 +152,125 @@ export function createWechatChannel(opts) {
|
|
|
149
152
|
return {};
|
|
150
153
|
}
|
|
151
154
|
}
|
|
155
|
+
function cdnUploadUrl(resp) {
|
|
156
|
+
if (typeof resp.upload_full_url === "string" && resp.upload_full_url.length > 0) {
|
|
157
|
+
return resp.upload_full_url;
|
|
158
|
+
}
|
|
159
|
+
if (typeof resp.upload_param === "string" && resp.upload_param.length > 0) {
|
|
160
|
+
return `${DEFAULT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(resp.upload_param)}`;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
async function uploadEncryptedMedia(trace, attachment) {
|
|
165
|
+
const raw = attachment.data ??
|
|
166
|
+
(attachment.filePath ? await readFile(attachment.filePath) : undefined);
|
|
167
|
+
if (!raw || raw.length === 0) {
|
|
168
|
+
throw new Error("wechat media upload requires non-empty attachment data or filePath");
|
|
169
|
+
}
|
|
170
|
+
const data = Buffer.from(raw);
|
|
171
|
+
const filename = attachment.filename ??
|
|
172
|
+
(attachment.filePath ? basename(attachment.filePath) : "attachment");
|
|
173
|
+
const kind = attachment.kind ?? kindFromContentType(attachment.contentType);
|
|
174
|
+
const mediaType = kind === "image" ? 1 : kind === "video" ? 2 : 3;
|
|
175
|
+
const itemType = kind === "image" ? 2 : kind === "video" ? 5 : 4;
|
|
176
|
+
const aesKey = randomBytes(16);
|
|
177
|
+
const aesKeyHex = aesKey.toString("hex");
|
|
178
|
+
const encrypted = encryptAes128Ecb(data, aesKey);
|
|
179
|
+
const filekey = `botcord-${randomUUID()}`;
|
|
180
|
+
const uploadResp = await callApi("ilink/bot/getuploadurl", {
|
|
181
|
+
filekey,
|
|
182
|
+
media_type: mediaType,
|
|
183
|
+
to_user_id: trace.fromUserId,
|
|
184
|
+
rawsize: data.length,
|
|
185
|
+
rawfilemd5: md5Hex(data),
|
|
186
|
+
filesize: encrypted.length,
|
|
187
|
+
aeskey: aesKeyHex,
|
|
188
|
+
no_need_thumb: true,
|
|
189
|
+
}, 15_000);
|
|
190
|
+
if (uploadResp.ret !== 0 && uploadResp.ret !== undefined) {
|
|
191
|
+
throw new Error(redactSecret(`wechat getuploadurl failed: ret=${uploadResp.ret}`, botToken));
|
|
192
|
+
}
|
|
193
|
+
const uploadUrl = cdnUploadUrl(uploadResp);
|
|
194
|
+
if (!uploadUrl)
|
|
195
|
+
throw new Error("wechat getuploadurl returned no upload URL");
|
|
196
|
+
const uploadResult = await fetchImpl(uploadUrl, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
199
|
+
body: encrypted,
|
|
200
|
+
signal: AbortSignal.timeout(30_000),
|
|
201
|
+
});
|
|
202
|
+
const encryptedParam = uploadResult.headers?.get("x-encrypted-param") ??
|
|
203
|
+
uploadResult.headers?.get("X-Encrypted-Param") ??
|
|
204
|
+
(await readEncryptedParamFromBody(uploadResult));
|
|
205
|
+
if (!encryptedParam) {
|
|
206
|
+
throw new Error("wechat CDN upload returned no x-encrypted-param");
|
|
207
|
+
}
|
|
208
|
+
const media = {
|
|
209
|
+
encrypt_query_param: encryptedParam,
|
|
210
|
+
aes_key: Buffer.from(aesKeyHex, "utf8").toString("base64"),
|
|
211
|
+
};
|
|
212
|
+
if (itemType === 2) {
|
|
213
|
+
return {
|
|
214
|
+
type: itemType,
|
|
215
|
+
image_item: {
|
|
216
|
+
media,
|
|
217
|
+
aeskey: aesKeyHex,
|
|
218
|
+
mid_size: data.length,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (itemType === 5) {
|
|
223
|
+
return {
|
|
224
|
+
type: itemType,
|
|
225
|
+
video_item: {
|
|
226
|
+
media,
|
|
227
|
+
video_size: data.length,
|
|
228
|
+
file_name: filename,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
type: itemType,
|
|
234
|
+
file_item: {
|
|
235
|
+
media,
|
|
236
|
+
file_name: filename,
|
|
237
|
+
md5: md5Hex(data),
|
|
238
|
+
len: data.length,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async function readEncryptedParamFromBody(resp) {
|
|
243
|
+
const raw = await resp.text().catch(() => "");
|
|
244
|
+
if (!raw)
|
|
245
|
+
return null;
|
|
246
|
+
try {
|
|
247
|
+
const json = JSON.parse(raw);
|
|
248
|
+
const v = json.encrypted_query_param ?? json.encrypt_query_param ?? json.upload_param;
|
|
249
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function sendItems(trace, items) {
|
|
256
|
+
const clientId = `botcord-${randomUUID()}`;
|
|
257
|
+
const body = {
|
|
258
|
+
msg: {
|
|
259
|
+
from_user_id: "",
|
|
260
|
+
to_user_id: trace.fromUserId,
|
|
261
|
+
client_id: clientId,
|
|
262
|
+
message_type: 2, // BOT → user
|
|
263
|
+
message_state: 2, // FINISH
|
|
264
|
+
context_token: trace.contextToken,
|
|
265
|
+
item_list: items,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const resp = await callApi("ilink/bot/sendmessage", body, 15_000);
|
|
269
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
270
|
+
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
271
|
+
}
|
|
272
|
+
return clientId;
|
|
273
|
+
}
|
|
152
274
|
function extractText(msg) {
|
|
153
275
|
const parts = [];
|
|
154
276
|
for (const item of msg.item_list ?? []) {
|
|
@@ -398,27 +520,23 @@ export function createWechatChannel(opts) {
|
|
|
398
520
|
throw new Error(`wechat send: no context_token for traceId=${message.traceId ?? "<missing>"}` +
|
|
399
521
|
` (expired or never bound — daemon does not support unsolicited replies)`);
|
|
400
522
|
}
|
|
401
|
-
const chunks = splitText(message.text, splitAt);
|
|
523
|
+
const chunks = message.text.length > 0 ? splitText(message.text, splitAt) : [];
|
|
402
524
|
let lastClientId = null;
|
|
403
525
|
for (const chunk of chunks) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
418
|
-
log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
|
|
419
|
-
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
526
|
+
lastClientId = await sendItems(trace, [{ type: 1, text_item: { text: chunk } }]);
|
|
527
|
+
}
|
|
528
|
+
for (const attachment of message.attachments ?? []) {
|
|
529
|
+
try {
|
|
530
|
+
const item = await uploadEncryptedMedia(trace, attachment);
|
|
531
|
+
lastClientId = await sendItems(trace, [item]);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
log.warn("wechat media send failed", {
|
|
535
|
+
err: redactSecret(String(err), botToken),
|
|
536
|
+
filename: attachment.filename ?? attachment.filePath ?? "attachment",
|
|
537
|
+
});
|
|
538
|
+
throw err;
|
|
420
539
|
}
|
|
421
|
-
lastClientId = clientId;
|
|
422
540
|
}
|
|
423
541
|
const sendAt = Date.now();
|
|
424
542
|
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
@@ -453,6 +571,21 @@ export function createWechatChannel(opts) {
|
|
|
453
571
|
};
|
|
454
572
|
return adapter;
|
|
455
573
|
}
|
|
574
|
+
function md5Hex(data) {
|
|
575
|
+
return createHash("md5").update(data).digest("hex");
|
|
576
|
+
}
|
|
577
|
+
function encryptAes128Ecb(data, key) {
|
|
578
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
579
|
+
cipher.setAutoPadding(true);
|
|
580
|
+
return Buffer.concat([cipher.update(data), cipher.final()]);
|
|
581
|
+
}
|
|
582
|
+
function kindFromContentType(contentType) {
|
|
583
|
+
if (contentType?.startsWith("image/"))
|
|
584
|
+
return "image";
|
|
585
|
+
if (contentType?.startsWith("video/"))
|
|
586
|
+
return "video";
|
|
587
|
+
return "file";
|
|
588
|
+
}
|
|
456
589
|
function sleep(ms, signal) {
|
|
457
590
|
return new Promise((resolve) => {
|
|
458
591
|
if (signal?.aborted) {
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -140,12 +140,22 @@ export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
|
|
|
140
140
|
*/
|
|
141
141
|
export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
|
|
142
142
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
143
|
+
export interface GatewayOutboundAttachment {
|
|
144
|
+
/** Local daemon-readable file path. */
|
|
145
|
+
filePath?: string;
|
|
146
|
+
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
147
|
+
data?: Uint8Array;
|
|
148
|
+
filename?: string;
|
|
149
|
+
contentType?: string;
|
|
150
|
+
kind?: "image" | "file" | "video";
|
|
151
|
+
}
|
|
143
152
|
export interface GatewayOutboundMessage {
|
|
144
153
|
channel: string;
|
|
145
154
|
accountId: string;
|
|
146
155
|
conversationId: string;
|
|
147
156
|
threadId?: string | null;
|
|
148
157
|
text: string;
|
|
158
|
+
attachments?: GatewayOutboundAttachment[];
|
|
149
159
|
replyTo?: string | null;
|
|
150
160
|
traceId?: string | null;
|
|
151
161
|
}
|
package/dist/index.js
CHANGED
|
@@ -172,6 +172,48 @@ function pidAlive(pid) {
|
|
|
172
172
|
return false;
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
if (!pidAlive(pid))
|
|
179
|
+
return true;
|
|
180
|
+
await delay(100);
|
|
181
|
+
}
|
|
182
|
+
return !pidAlive(pid);
|
|
183
|
+
}
|
|
184
|
+
async function stopExistingDaemonForRestart(pid) {
|
|
185
|
+
if (pid === process.pid)
|
|
186
|
+
return;
|
|
187
|
+
log.info("existing daemon found; restarting", { pid });
|
|
188
|
+
try {
|
|
189
|
+
process.kill(pid, "SIGTERM");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
try {
|
|
193
|
+
unlinkSync(PID_PATH);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!(await waitForPidExit(pid, 5_000))) {
|
|
201
|
+
log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
|
|
202
|
+
try {
|
|
203
|
+
process.kill(pid, "SIGKILL");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
await waitForPidExit(pid, 2_000);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(PID_PATH);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// ignore
|
|
215
|
+
}
|
|
216
|
+
}
|
|
175
217
|
/**
|
|
176
218
|
* Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
|
|
177
219
|
* with sensible defaults on first run. `--agent` (repeated) pins explicit
|
|
@@ -251,6 +293,8 @@ async function redeemInstallToken(opts) {
|
|
|
251
293
|
const body = { install_token: opts.installToken };
|
|
252
294
|
if (opts.label)
|
|
253
295
|
body.label = opts.label;
|
|
296
|
+
if (opts.daemonInstanceId)
|
|
297
|
+
body.daemon_instance_id = opts.daemonInstanceId;
|
|
254
298
|
const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
|
|
255
299
|
method: "POST",
|
|
256
300
|
headers: { "Content-Type": "application/json" },
|
|
@@ -259,7 +303,9 @@ async function redeemInstallToken(opts) {
|
|
|
259
303
|
});
|
|
260
304
|
if (!resp.ok) {
|
|
261
305
|
const text = await resp.text().catch(() => "");
|
|
262
|
-
|
|
306
|
+
const err = new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
|
|
307
|
+
err.status = resp.status;
|
|
308
|
+
throw err;
|
|
263
309
|
}
|
|
264
310
|
return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
|
|
265
311
|
}
|
|
@@ -330,10 +376,10 @@ async function runDeviceCodeFlow(opts) {
|
|
|
330
376
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
331
377
|
*
|
|
332
378
|
* Decision tree (plan §4.4 + §6.4):
|
|
333
|
-
* 1.
|
|
334
|
-
*
|
|
335
|
-
* the
|
|
336
|
-
* 2.
|
|
379
|
+
* 1. `--install-token` → redeem the one-time dashboard ticket. If local
|
|
380
|
+
* user-auth exists, include its daemonInstanceId so Hub can re-authorize
|
|
381
|
+
* the same device instead of creating a new one.
|
|
382
|
+
* 2. Have existing creds and no `--relogin` → return existing record.
|
|
337
383
|
* 3. `--relogin` → device-code login.
|
|
338
384
|
* 4. No creds + TTY → device-code login.
|
|
339
385
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
@@ -359,22 +405,40 @@ async function ensureUserAuthForStart(args) {
|
|
|
359
405
|
if (labelFlag && existing.label !== labelFlag) {
|
|
360
406
|
console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
|
|
361
407
|
}
|
|
362
|
-
if (installToken) {
|
|
363
|
-
console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
|
|
364
|
-
}
|
|
365
408
|
return existing;
|
|
366
409
|
}
|
|
367
410
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
368
411
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
369
412
|
const label = labelFlag ?? defaultLoginLabel();
|
|
370
413
|
if (authAction === "install-token" && installToken) {
|
|
371
|
-
|
|
372
|
-
|
|
414
|
+
let tok;
|
|
415
|
+
try {
|
|
416
|
+
tok = await redeemInstallToken({
|
|
417
|
+
hubUrl,
|
|
418
|
+
installToken,
|
|
419
|
+
label,
|
|
420
|
+
daemonInstanceId: existing?.daemonInstanceId,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
if (existing && !relogin && !existsSync(AUTH_EXPIRED_FLAG_PATH)) {
|
|
425
|
+
console.error(`note: --install-token could not be redeemed (${err instanceof Error ? err.message : String(err)}); reusing existing daemon auth`);
|
|
426
|
+
return existing;
|
|
427
|
+
}
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
const record = userAuthFromTokenResponse(tok, {
|
|
431
|
+
label,
|
|
432
|
+
loggedInAt: existing?.daemonInstanceId && existing.daemonInstanceId === tok.daemonInstanceId
|
|
433
|
+
? existing.loggedInAt
|
|
434
|
+
: undefined,
|
|
435
|
+
});
|
|
373
436
|
saveUserAuth(record);
|
|
374
437
|
clearAuthExpiredFlag();
|
|
375
438
|
log.info("install-token flow: authorized", {
|
|
376
439
|
userId: record.userId,
|
|
377
440
|
daemonInstanceId: record.daemonInstanceId,
|
|
441
|
+
reusedExistingDaemonInstance: existing?.daemonInstanceId === record.daemonInstanceId,
|
|
378
442
|
hubUrl: record.hubUrl,
|
|
379
443
|
label,
|
|
380
444
|
});
|
|
@@ -424,11 +488,6 @@ async function cmdStart(args) {
|
|
|
424
488
|
relogin: args.flags.relogin === true,
|
|
425
489
|
child: process.env.BOTCORD_DAEMON_CHILD === "1",
|
|
426
490
|
});
|
|
427
|
-
const existing = readPid();
|
|
428
|
-
if (existing && pidAlive(existing)) {
|
|
429
|
-
console.error(`daemon already running (pid ${existing})`);
|
|
430
|
-
process.exit(1);
|
|
431
|
-
}
|
|
432
491
|
// Login MUST happen before fork — once detached, stdio is gone and the
|
|
433
492
|
// user can't see the device code. We also run it for explicit
|
|
434
493
|
// --foreground so an interactive user can log in without the fork dance.
|
|
@@ -436,6 +495,17 @@ async function cmdStart(args) {
|
|
|
436
495
|
// var so we don't try to re-prompt for credentials it already has.
|
|
437
496
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
438
497
|
await ensureUserAuthForStart(args);
|
|
498
|
+
const existing = readPid();
|
|
499
|
+
if (existing && pidAlive(existing)) {
|
|
500
|
+
await stopExistingDaemonForRestart(existing);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const existing = readPid();
|
|
505
|
+
if (existing && existing !== process.pid && pidAlive(existing)) {
|
|
506
|
+
console.error(`daemon already running (pid ${existing})`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
439
509
|
}
|
|
440
510
|
if (background) {
|
|
441
511
|
// Detached child re-exec in foreground mode. The child writes the PID
|
package/dist/start-auth.js
CHANGED
package/dist/turn-text.js
CHANGED
|
@@ -40,6 +40,13 @@ function replyDeliveryHint(msg) {
|
|
|
40
40
|
? THIRD_PARTY_REPLY_HINT
|
|
41
41
|
: NON_OWNER_REPLY_HINT;
|
|
42
42
|
}
|
|
43
|
+
function appendConversationFields(fields, msg) {
|
|
44
|
+
const conversationId = sanitizeSenderName(msg.conversation.id);
|
|
45
|
+
fields.push(`conversation_id: ${conversationId}`);
|
|
46
|
+
if (isThirdPartyConversation(msg.conversation.id)) {
|
|
47
|
+
fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
43
50
|
/**
|
|
44
51
|
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
45
52
|
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
@@ -138,6 +145,7 @@ export function composeBotCordUserTurn(msg) {
|
|
|
138
145
|
`from: ${sanitizedSenderLabel}`,
|
|
139
146
|
`to: ${msg.accountId}`,
|
|
140
147
|
];
|
|
148
|
+
appendConversationFields(headerFields, msg);
|
|
141
149
|
if (isGroup && roomTitle) {
|
|
142
150
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
143
151
|
headerFields.push(`room: ${safeRoom}`);
|
|
@@ -190,6 +198,7 @@ function composeBatchedTurn(msg, batch) {
|
|
|
190
198
|
`[BotCord Messages (${batch.length} new)]`,
|
|
191
199
|
`to: ${msg.accountId}`,
|
|
192
200
|
];
|
|
201
|
+
appendConversationFields(header, msg);
|
|
193
202
|
if (isGroup && roomTitle) {
|
|
194
203
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
195
204
|
header.push(`room: ${safeRoom}`);
|
package/package.json
CHANGED
|
@@ -14,14 +14,14 @@ const existingAuth: UserAuthRecord = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
describe("resolveStartAuthAction", () => {
|
|
17
|
-
it("
|
|
17
|
+
it("redeems an install token even when existing auth is available", () => {
|
|
18
18
|
expect(
|
|
19
19
|
resolveStartAuthAction({
|
|
20
20
|
existing: existingAuth,
|
|
21
21
|
relogin: false,
|
|
22
22
|
installToken: "dit_expired",
|
|
23
23
|
}),
|
|
24
|
-
).toBe("
|
|
24
|
+
).toBe("install-token");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("redeems an install token when no existing auth is available", () => {
|
|
@@ -116,6 +116,8 @@ describe("composeBotCordUserTurn", () => {
|
|
|
116
116
|
);
|
|
117
117
|
expect(out).toContain("third-party gateway chat");
|
|
118
118
|
expect(out).toContain("Reply normally in your final assistant message");
|
|
119
|
+
expect(out).toContain("conversation_id: telegram:user:7904063707");
|
|
120
|
+
expect(out).toContain("channel: gw_telegram_123");
|
|
119
121
|
expect(out).not.toContain("Plain text output WILL NOT be sent");
|
|
120
122
|
expect(out).not.toContain("botcord_send");
|
|
121
123
|
});
|
|
@@ -219,6 +221,7 @@ describe("composeBotCordUserTurn", () => {
|
|
|
219
221
|
}),
|
|
220
222
|
);
|
|
221
223
|
expect(out).toContain("[BotCord Messages (2 new)]");
|
|
224
|
+
expect(out).toContain("conversation_id: rm_team");
|
|
222
225
|
expect(out).toContain("room: Ouraca");
|
|
223
226
|
expect(out).toContain("mentioned: true");
|
|
224
227
|
expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -20,6 +20,7 @@ const SILENT_LOG: GatewayLogger = {
|
|
|
20
20
|
interface StubResponse {
|
|
21
21
|
status?: number;
|
|
22
22
|
body: unknown;
|
|
23
|
+
headers?: Record<string, string>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -35,16 +36,20 @@ function buildFetchStub(
|
|
|
35
36
|
body: Record<string, unknown> | null,
|
|
36
37
|
) => StubResponse | Promise<StubResponse>;
|
|
37
38
|
}>,
|
|
38
|
-
calls: Array<{ url: string; body:
|
|
39
|
+
calls: Array<{ url: string; body: any }>,
|
|
39
40
|
): FetchLike {
|
|
40
41
|
const counters = new Map<string, number>();
|
|
41
42
|
return async (url, init) => {
|
|
42
|
-
let parsed: Record<string, unknown> | null = null;
|
|
43
|
+
let parsed: Record<string, unknown> | Uint8Array | null = null;
|
|
43
44
|
if (init?.body) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (typeof init.body === "string") {
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(init.body) as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
parsed = null;
|
|
50
|
+
}
|
|
51
|
+
} else if (init.body instanceof Uint8Array) {
|
|
52
|
+
parsed = init.body;
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
calls.push({ url, body: parsed });
|
|
@@ -59,11 +64,21 @@ function buildFetchStub(
|
|
|
59
64
|
return {
|
|
60
65
|
status,
|
|
61
66
|
ok: status >= 200 && status < 300,
|
|
67
|
+
headers: {
|
|
68
|
+
get(name: string) {
|
|
69
|
+
return resp.headers?.[name] ?? resp.headers?.[name.toLowerCase()] ?? null;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
62
72
|
text: async () => text,
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
status: 404,
|
|
78
|
+
ok: false,
|
|
79
|
+
headers: { get: () => null },
|
|
80
|
+
text: async () => "",
|
|
81
|
+
};
|
|
67
82
|
};
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -409,6 +424,109 @@ describe("wechat channel adapter", () => {
|
|
|
409
424
|
expect(calls.find((c) => c.url.includes("sendmessage"))).toBeUndefined();
|
|
410
425
|
});
|
|
411
426
|
|
|
427
|
+
it("send() uploads local file attachments to WeChat CDN and sends a file_item", async () => {
|
|
428
|
+
const filePath = path.join(tmp, "report.pdf");
|
|
429
|
+
writeFileSync(filePath, "plain file bytes");
|
|
430
|
+
const calls: Array<{ url: string; body: any }> = [];
|
|
431
|
+
const fetchImpl = buildFetchStub(
|
|
432
|
+
[
|
|
433
|
+
{
|
|
434
|
+
match: "getupdates",
|
|
435
|
+
respond: (idx) => {
|
|
436
|
+
if (idx === 0) {
|
|
437
|
+
return {
|
|
438
|
+
body: {
|
|
439
|
+
ret: 0,
|
|
440
|
+
get_updates_buf: "c",
|
|
441
|
+
msgs: [
|
|
442
|
+
{
|
|
443
|
+
message_type: 1,
|
|
444
|
+
from_user_id: "alice@im.wechat",
|
|
445
|
+
context_token: "ctx-file",
|
|
446
|
+
item_list: [{ type: 1, text_item: { text: "send file" } }],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
match: "getuploadurl",
|
|
457
|
+
respond: () => ({
|
|
458
|
+
body: {
|
|
459
|
+
ret: 0,
|
|
460
|
+
upload_full_url: "https://cdn.test/upload/report",
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
match: "cdn.test/upload",
|
|
466
|
+
respond: () => ({
|
|
467
|
+
body: "",
|
|
468
|
+
headers: { "x-encrypted-param": "encrypted-download-token" },
|
|
469
|
+
}),
|
|
470
|
+
},
|
|
471
|
+
{ match: "sendmessage", respond: () => ({ body: { ret: 0 } }) },
|
|
472
|
+
],
|
|
473
|
+
calls,
|
|
474
|
+
);
|
|
475
|
+
const adapter = createWechatChannel({
|
|
476
|
+
id: "gw_wx_file",
|
|
477
|
+
accountId: "ag_test",
|
|
478
|
+
botToken: "tok",
|
|
479
|
+
stateFile: path.join(tmp, "state.json"),
|
|
480
|
+
fetchImpl,
|
|
481
|
+
stateDebounceMs: 0,
|
|
482
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
483
|
+
});
|
|
484
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
485
|
+
await h.pollDone;
|
|
486
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
487
|
+
|
|
488
|
+
await adapter.send({
|
|
489
|
+
log: SILENT_LOG,
|
|
490
|
+
message: {
|
|
491
|
+
channel: "gw_wx_file",
|
|
492
|
+
accountId: "ag_test",
|
|
493
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
494
|
+
text: "",
|
|
495
|
+
traceId,
|
|
496
|
+
attachments: [
|
|
497
|
+
{
|
|
498
|
+
filePath,
|
|
499
|
+
filename: "report.pdf",
|
|
500
|
+
contentType: "application/pdf",
|
|
501
|
+
kind: "file",
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const uploadRequest = calls.find((c) => c.url.includes("getuploadurl"))!;
|
|
508
|
+
expect(uploadRequest.body!.media_type).toBe(3);
|
|
509
|
+
expect(uploadRequest.body!.rawsize).toBe("plain file bytes".length);
|
|
510
|
+
expect(uploadRequest.body!.filesize).toBeGreaterThan("plain file bytes".length);
|
|
511
|
+
|
|
512
|
+
const cdnCall = calls.find((c) => c.url.includes("cdn.test/upload"))!;
|
|
513
|
+
expect(cdnCall.body).toBeInstanceOf(Uint8Array);
|
|
514
|
+
expect(Buffer.from(cdnCall.body as Uint8Array).toString("utf8")).not.toContain(
|
|
515
|
+
"plain file bytes",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const sendCall = calls.find((c) => c.url.includes("sendmessage"))!;
|
|
519
|
+
const msg = sendCall.body!.msg as Record<string, unknown>;
|
|
520
|
+
const item = (msg.item_list as Array<Record<string, unknown>>)[0]!;
|
|
521
|
+
expect(item.type).toBe(4);
|
|
522
|
+
const fileItem = item.file_item as Record<string, unknown>;
|
|
523
|
+
expect(fileItem.file_name).toBe("report.pdf");
|
|
524
|
+
expect(fileItem.len).toBe("plain file bytes".length);
|
|
525
|
+
expect((fileItem.media as Record<string, unknown>).encrypt_query_param).toBe(
|
|
526
|
+
"encrypted-download-token",
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
412
530
|
it("send() splits long replies into chunks <= splitAt, preferring newline boundaries", async () => {
|
|
413
531
|
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
414
532
|
const fetchImpl = buildFetchStub(
|
|
@@ -12,11 +12,12 @@ export type FetchLike = (
|
|
|
12
12
|
init?: {
|
|
13
13
|
method?: string;
|
|
14
14
|
headers?: Record<string, string>;
|
|
15
|
-
body?: string;
|
|
15
|
+
body?: BodyInit | Uint8Array | string;
|
|
16
16
|
signal?: AbortSignal;
|
|
17
17
|
},
|
|
18
18
|
) => Promise<{
|
|
19
19
|
status?: number;
|
|
20
20
|
ok?: boolean;
|
|
21
|
+
headers?: { get(name: string): string | null };
|
|
21
22
|
text(): Promise<string>;
|
|
22
23
|
}>;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createCipheriv,
|
|
5
|
+
createHash,
|
|
6
|
+
randomBytes,
|
|
7
|
+
randomUUID,
|
|
8
|
+
} from "node:crypto";
|
|
1
9
|
import type {
|
|
2
10
|
ChannelAdapter,
|
|
3
11
|
ChannelSendContext,
|
|
@@ -8,15 +16,16 @@ import type {
|
|
|
8
16
|
ChannelTypingContext,
|
|
9
17
|
GatewayInboundEnvelope,
|
|
10
18
|
GatewayInboundMessage,
|
|
19
|
+
GatewayOutboundAttachment,
|
|
11
20
|
} from "../types.js";
|
|
12
21
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
13
22
|
import { GatewayStateStore } from "./state-store.js";
|
|
14
23
|
import { loadGatewaySecret } from "./secret-store.js";
|
|
15
24
|
import { splitText } from "./text-split.js";
|
|
16
25
|
import { wechatHeaders, WECHAT_BASE_INFO, type FetchLike } from "./wechat-http.js";
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
18
26
|
|
|
19
27
|
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
28
|
+
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
|
|
@@ -92,6 +101,11 @@ interface WechatGenericResp {
|
|
|
92
101
|
[k: string]: unknown;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
interface WechatUploadUrlResp extends WechatGenericResp {
|
|
105
|
+
upload_param?: string;
|
|
106
|
+
upload_full_url?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
interface TraceContext {
|
|
96
110
|
contextToken: string;
|
|
97
111
|
fromUserId: string;
|
|
@@ -231,6 +245,142 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
231
245
|
}
|
|
232
246
|
}
|
|
233
247
|
|
|
248
|
+
function cdnUploadUrl(resp: WechatUploadUrlResp): string | null {
|
|
249
|
+
if (typeof resp.upload_full_url === "string" && resp.upload_full_url.length > 0) {
|
|
250
|
+
return resp.upload_full_url;
|
|
251
|
+
}
|
|
252
|
+
if (typeof resp.upload_param === "string" && resp.upload_param.length > 0) {
|
|
253
|
+
return `${DEFAULT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(
|
|
254
|
+
resp.upload_param,
|
|
255
|
+
)}`;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function uploadEncryptedMedia(
|
|
261
|
+
trace: TraceContext,
|
|
262
|
+
attachment: GatewayOutboundAttachment,
|
|
263
|
+
): Promise<Record<string, unknown>> {
|
|
264
|
+
const raw =
|
|
265
|
+
attachment.data ??
|
|
266
|
+
(attachment.filePath ? await readFile(attachment.filePath) : undefined);
|
|
267
|
+
if (!raw || raw.length === 0) {
|
|
268
|
+
throw new Error("wechat media upload requires non-empty attachment data or filePath");
|
|
269
|
+
}
|
|
270
|
+
const data = Buffer.from(raw);
|
|
271
|
+
const filename =
|
|
272
|
+
attachment.filename ??
|
|
273
|
+
(attachment.filePath ? basename(attachment.filePath) : "attachment");
|
|
274
|
+
const kind = attachment.kind ?? kindFromContentType(attachment.contentType);
|
|
275
|
+
const mediaType = kind === "image" ? 1 : kind === "video" ? 2 : 3;
|
|
276
|
+
const itemType = kind === "image" ? 2 : kind === "video" ? 5 : 4;
|
|
277
|
+
const aesKey = randomBytes(16);
|
|
278
|
+
const aesKeyHex = aesKey.toString("hex");
|
|
279
|
+
const encrypted = encryptAes128Ecb(data, aesKey);
|
|
280
|
+
const filekey = `botcord-${randomUUID()}`;
|
|
281
|
+
const uploadResp = await callApi<WechatUploadUrlResp>(
|
|
282
|
+
"ilink/bot/getuploadurl",
|
|
283
|
+
{
|
|
284
|
+
filekey,
|
|
285
|
+
media_type: mediaType,
|
|
286
|
+
to_user_id: trace.fromUserId,
|
|
287
|
+
rawsize: data.length,
|
|
288
|
+
rawfilemd5: md5Hex(data),
|
|
289
|
+
filesize: encrypted.length,
|
|
290
|
+
aeskey: aesKeyHex,
|
|
291
|
+
no_need_thumb: true,
|
|
292
|
+
},
|
|
293
|
+
15_000,
|
|
294
|
+
);
|
|
295
|
+
if (uploadResp.ret !== 0 && uploadResp.ret !== undefined) {
|
|
296
|
+
throw new Error(redactSecret(`wechat getuploadurl failed: ret=${uploadResp.ret}`, botToken));
|
|
297
|
+
}
|
|
298
|
+
const uploadUrl = cdnUploadUrl(uploadResp);
|
|
299
|
+
if (!uploadUrl) throw new Error("wechat getuploadurl returned no upload URL");
|
|
300
|
+
|
|
301
|
+
const uploadResult = await fetchImpl(uploadUrl, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
304
|
+
body: encrypted,
|
|
305
|
+
signal: AbortSignal.timeout(30_000),
|
|
306
|
+
});
|
|
307
|
+
const encryptedParam =
|
|
308
|
+
uploadResult.headers?.get("x-encrypted-param") ??
|
|
309
|
+
uploadResult.headers?.get("X-Encrypted-Param") ??
|
|
310
|
+
(await readEncryptedParamFromBody(uploadResult));
|
|
311
|
+
if (!encryptedParam) {
|
|
312
|
+
throw new Error("wechat CDN upload returned no x-encrypted-param");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const media = {
|
|
316
|
+
encrypt_query_param: encryptedParam,
|
|
317
|
+
aes_key: Buffer.from(aesKeyHex, "utf8").toString("base64"),
|
|
318
|
+
};
|
|
319
|
+
if (itemType === 2) {
|
|
320
|
+
return {
|
|
321
|
+
type: itemType,
|
|
322
|
+
image_item: {
|
|
323
|
+
media,
|
|
324
|
+
aeskey: aesKeyHex,
|
|
325
|
+
mid_size: data.length,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (itemType === 5) {
|
|
330
|
+
return {
|
|
331
|
+
type: itemType,
|
|
332
|
+
video_item: {
|
|
333
|
+
media,
|
|
334
|
+
video_size: data.length,
|
|
335
|
+
file_name: filename,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
type: itemType,
|
|
341
|
+
file_item: {
|
|
342
|
+
media,
|
|
343
|
+
file_name: filename,
|
|
344
|
+
md5: md5Hex(data),
|
|
345
|
+
len: data.length,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readEncryptedParamFromBody(
|
|
351
|
+
resp: Awaited<ReturnType<FetchLike>>,
|
|
352
|
+
): Promise<string | null> {
|
|
353
|
+
const raw = await resp.text().catch(() => "");
|
|
354
|
+
if (!raw) return null;
|
|
355
|
+
try {
|
|
356
|
+
const json = JSON.parse(raw) as Record<string, unknown>;
|
|
357
|
+
const v = json.encrypted_query_param ?? json.encrypt_query_param ?? json.upload_param;
|
|
358
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function sendItems(trace: TraceContext, items: Record<string, unknown>[]): Promise<string> {
|
|
365
|
+
const clientId = `botcord-${randomUUID()}`;
|
|
366
|
+
const body = {
|
|
367
|
+
msg: {
|
|
368
|
+
from_user_id: "",
|
|
369
|
+
to_user_id: trace.fromUserId,
|
|
370
|
+
client_id: clientId,
|
|
371
|
+
message_type: 2, // BOT → user
|
|
372
|
+
message_state: 2, // FINISH
|
|
373
|
+
context_token: trace.contextToken,
|
|
374
|
+
item_list: items,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const resp = await callApi<WechatGenericResp>("ilink/bot/sendmessage", body, 15_000);
|
|
378
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
379
|
+
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
380
|
+
}
|
|
381
|
+
return clientId;
|
|
382
|
+
}
|
|
383
|
+
|
|
234
384
|
function extractText(msg: WechatInboundMsg): string {
|
|
235
385
|
const parts: string[] = [];
|
|
236
386
|
for (const item of msg.item_list ?? []) {
|
|
@@ -495,27 +645,22 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
495
645
|
);
|
|
496
646
|
}
|
|
497
647
|
|
|
498
|
-
const chunks = splitText(message.text, splitAt);
|
|
648
|
+
const chunks = message.text.length > 0 ? splitText(message.text, splitAt) : [];
|
|
499
649
|
let lastClientId: string | null = null;
|
|
500
650
|
for (const chunk of chunks) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const resp = await callApi<WechatGenericResp>("ilink/bot/sendmessage", body, 15_000);
|
|
514
|
-
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
515
|
-
log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
|
|
516
|
-
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
651
|
+
lastClientId = await sendItems(trace, [{ type: 1, text_item: { text: chunk } }]);
|
|
652
|
+
}
|
|
653
|
+
for (const attachment of message.attachments ?? []) {
|
|
654
|
+
try {
|
|
655
|
+
const item = await uploadEncryptedMedia(trace, attachment);
|
|
656
|
+
lastClientId = await sendItems(trace, [item]);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
log.warn("wechat media send failed", {
|
|
659
|
+
err: redactSecret(String(err), botToken),
|
|
660
|
+
filename: attachment.filename ?? attachment.filePath ?? "attachment",
|
|
661
|
+
});
|
|
662
|
+
throw err;
|
|
517
663
|
}
|
|
518
|
-
lastClientId = clientId;
|
|
519
664
|
}
|
|
520
665
|
const sendAt = Date.now();
|
|
521
666
|
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
@@ -553,6 +698,22 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
553
698
|
return adapter;
|
|
554
699
|
}
|
|
555
700
|
|
|
701
|
+
function md5Hex(data: Buffer): string {
|
|
702
|
+
return createHash("md5").update(data).digest("hex");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function encryptAes128Ecb(data: Buffer, key: Buffer): Buffer {
|
|
706
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
707
|
+
cipher.setAutoPadding(true);
|
|
708
|
+
return Buffer.concat([cipher.update(data), cipher.final()]);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function kindFromContentType(contentType: string | undefined): "image" | "file" | "video" {
|
|
712
|
+
if (contentType?.startsWith("image/")) return "image";
|
|
713
|
+
if (contentType?.startsWith("video/")) return "video";
|
|
714
|
+
return "file";
|
|
715
|
+
}
|
|
716
|
+
|
|
556
717
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
557
718
|
return new Promise((resolve) => {
|
|
558
719
|
if (signal?.aborted) {
|
package/src/gateway/types.ts
CHANGED
|
@@ -172,12 +172,23 @@ export type OutboundObserver = (
|
|
|
172
172
|
) => Promise<void> | void;
|
|
173
173
|
|
|
174
174
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
175
|
+
export interface GatewayOutboundAttachment {
|
|
176
|
+
/** Local daemon-readable file path. */
|
|
177
|
+
filePath?: string;
|
|
178
|
+
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
179
|
+
data?: Uint8Array;
|
|
180
|
+
filename?: string;
|
|
181
|
+
contentType?: string;
|
|
182
|
+
kind?: "image" | "file" | "video";
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
export interface GatewayOutboundMessage {
|
|
176
186
|
channel: string;
|
|
177
187
|
accountId: string;
|
|
178
188
|
conversationId: string;
|
|
179
189
|
threadId?: string | null;
|
|
180
190
|
text: string;
|
|
191
|
+
attachments?: GatewayOutboundAttachment[];
|
|
181
192
|
replyTo?: string | null;
|
|
182
193
|
traceId?: string | null;
|
|
183
194
|
}
|
package/src/index.ts
CHANGED
|
@@ -231,6 +231,44 @@ function pidAlive(pid: number): boolean {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
235
|
+
const deadline = Date.now() + timeoutMs;
|
|
236
|
+
while (Date.now() < deadline) {
|
|
237
|
+
if (!pidAlive(pid)) return true;
|
|
238
|
+
await delay(100);
|
|
239
|
+
}
|
|
240
|
+
return !pidAlive(pid);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function stopExistingDaemonForRestart(pid: number): Promise<void> {
|
|
244
|
+
if (pid === process.pid) return;
|
|
245
|
+
log.info("existing daemon found; restarting", { pid });
|
|
246
|
+
try {
|
|
247
|
+
process.kill(pid, "SIGTERM");
|
|
248
|
+
} catch {
|
|
249
|
+
try {
|
|
250
|
+
unlinkSync(PID_PATH);
|
|
251
|
+
} catch {
|
|
252
|
+
// ignore
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!(await waitForPidExit(pid, 5_000))) {
|
|
257
|
+
log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
|
|
258
|
+
try {
|
|
259
|
+
process.kill(pid, "SIGKILL");
|
|
260
|
+
} catch {
|
|
261
|
+
// ignore
|
|
262
|
+
}
|
|
263
|
+
await waitForPidExit(pid, 2_000);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
unlinkSync(PID_PATH);
|
|
267
|
+
} catch {
|
|
268
|
+
// ignore
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
234
272
|
/**
|
|
235
273
|
* Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
|
|
236
274
|
* with sensible defaults on first run. `--agent` (repeated) pins explicit
|
|
@@ -323,9 +361,11 @@ async function redeemInstallToken(opts: {
|
|
|
323
361
|
hubUrl: string;
|
|
324
362
|
installToken: string;
|
|
325
363
|
label?: string;
|
|
364
|
+
daemonInstanceId?: string;
|
|
326
365
|
}): Promise<DaemonTokenResponse> {
|
|
327
366
|
const body: Record<string, unknown> = { install_token: opts.installToken };
|
|
328
367
|
if (opts.label) body.label = opts.label;
|
|
368
|
+
if (opts.daemonInstanceId) body.daemon_instance_id = opts.daemonInstanceId;
|
|
329
369
|
const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
|
|
330
370
|
method: "POST",
|
|
331
371
|
headers: { "Content-Type": "application/json" },
|
|
@@ -334,7 +374,9 @@ async function redeemInstallToken(opts: {
|
|
|
334
374
|
});
|
|
335
375
|
if (!resp.ok) {
|
|
336
376
|
const text = await resp.text().catch(() => "");
|
|
337
|
-
|
|
377
|
+
const err = new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
|
|
378
|
+
(err as unknown as { status?: number }).status = resp.status;
|
|
379
|
+
throw err;
|
|
338
380
|
}
|
|
339
381
|
return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
|
|
340
382
|
}
|
|
@@ -418,10 +460,10 @@ async function runDeviceCodeFlow(opts: {
|
|
|
418
460
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
419
461
|
*
|
|
420
462
|
* Decision tree (plan §4.4 + §6.4):
|
|
421
|
-
* 1.
|
|
422
|
-
*
|
|
423
|
-
* the
|
|
424
|
-
* 2.
|
|
463
|
+
* 1. `--install-token` → redeem the one-time dashboard ticket. If local
|
|
464
|
+
* user-auth exists, include its daemonInstanceId so Hub can re-authorize
|
|
465
|
+
* the same device instead of creating a new one.
|
|
466
|
+
* 2. Have existing creds and no `--relogin` → return existing record.
|
|
425
467
|
* 3. `--relogin` → device-code login.
|
|
426
468
|
* 4. No creds + TTY → device-code login.
|
|
427
469
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
@@ -452,9 +494,6 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
|
|
|
452
494
|
`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`,
|
|
453
495
|
);
|
|
454
496
|
}
|
|
455
|
-
if (installToken) {
|
|
456
|
-
console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
|
|
457
|
-
}
|
|
458
497
|
return existing;
|
|
459
498
|
}
|
|
460
499
|
|
|
@@ -463,13 +502,37 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
|
|
|
463
502
|
const label = labelFlag ?? defaultLoginLabel();
|
|
464
503
|
|
|
465
504
|
if (authAction === "install-token" && installToken) {
|
|
466
|
-
|
|
467
|
-
|
|
505
|
+
let tok: DaemonTokenResponse;
|
|
506
|
+
try {
|
|
507
|
+
tok = await redeemInstallToken({
|
|
508
|
+
hubUrl,
|
|
509
|
+
installToken,
|
|
510
|
+
label,
|
|
511
|
+
daemonInstanceId: existing?.daemonInstanceId,
|
|
512
|
+
});
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (existing && !relogin && !existsSync(AUTH_EXPIRED_FLAG_PATH)) {
|
|
515
|
+
console.error(
|
|
516
|
+
`note: --install-token could not be redeemed (${err instanceof Error ? err.message : String(err)}); reusing existing daemon auth`,
|
|
517
|
+
);
|
|
518
|
+
return existing;
|
|
519
|
+
}
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
const record = userAuthFromTokenResponse(tok, {
|
|
523
|
+
label,
|
|
524
|
+
loggedInAt:
|
|
525
|
+
existing?.daemonInstanceId && existing.daemonInstanceId === tok.daemonInstanceId
|
|
526
|
+
? existing.loggedInAt
|
|
527
|
+
: undefined,
|
|
528
|
+
});
|
|
468
529
|
saveUserAuth(record);
|
|
469
530
|
clearAuthExpiredFlag();
|
|
470
531
|
log.info("install-token flow: authorized", {
|
|
471
532
|
userId: record.userId,
|
|
472
533
|
daemonInstanceId: record.daemonInstanceId,
|
|
534
|
+
reusedExistingDaemonInstance:
|
|
535
|
+
existing?.daemonInstanceId === record.daemonInstanceId,
|
|
473
536
|
hubUrl: record.hubUrl,
|
|
474
537
|
label,
|
|
475
538
|
});
|
|
@@ -527,12 +590,6 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
|
|
|
527
590
|
child: process.env.BOTCORD_DAEMON_CHILD === "1",
|
|
528
591
|
});
|
|
529
592
|
|
|
530
|
-
const existing = readPid();
|
|
531
|
-
if (existing && pidAlive(existing)) {
|
|
532
|
-
console.error(`daemon already running (pid ${existing})`);
|
|
533
|
-
process.exit(1);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
593
|
// Login MUST happen before fork — once detached, stdio is gone and the
|
|
537
594
|
// user can't see the device code. We also run it for explicit
|
|
538
595
|
// --foreground so an interactive user can log in without the fork dance.
|
|
@@ -540,6 +597,16 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
|
|
|
540
597
|
// var so we don't try to re-prompt for credentials it already has.
|
|
541
598
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
542
599
|
await ensureUserAuthForStart(args);
|
|
600
|
+
const existing = readPid();
|
|
601
|
+
if (existing && pidAlive(existing)) {
|
|
602
|
+
await stopExistingDaemonForRestart(existing);
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
const existing = readPid();
|
|
606
|
+
if (existing && existing !== process.pid && pidAlive(existing)) {
|
|
607
|
+
console.error(`daemon already running (pid ${existing})`);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
543
610
|
}
|
|
544
611
|
|
|
545
612
|
if (background) {
|
package/src/start-auth.ts
CHANGED
|
@@ -7,7 +7,7 @@ export function resolveStartAuthAction(opts: {
|
|
|
7
7
|
relogin: boolean;
|
|
8
8
|
installToken?: string;
|
|
9
9
|
}): StartAuthAction {
|
|
10
|
-
if (opts.existing && !opts.relogin) return "reuse-existing";
|
|
11
10
|
if (opts.installToken) return "install-token";
|
|
11
|
+
if (opts.existing && !opts.relogin) return "reuse-existing";
|
|
12
12
|
return "device-code";
|
|
13
13
|
}
|
package/src/turn-text.ts
CHANGED
|
@@ -76,6 +76,17 @@ function replyDeliveryHint(msg: GatewayInboundMessage): string {
|
|
|
76
76
|
: NON_OWNER_REPLY_HINT;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function appendConversationFields(
|
|
80
|
+
fields: string[],
|
|
81
|
+
msg: GatewayInboundMessage,
|
|
82
|
+
): void {
|
|
83
|
+
const conversationId = sanitizeSenderName(msg.conversation.id);
|
|
84
|
+
fields.push(`conversation_id: ${conversationId}`);
|
|
85
|
+
if (isThirdPartyConversation(msg.conversation.id)) {
|
|
86
|
+
fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/** Minimal shape of one batched inbound entry. Matches the BotCord channel
|
|
80
91
|
* `BatchedInboxRaw.batch[]` elements but expressed structurally so the
|
|
81
92
|
* composer doesn't import channel internals. */
|
|
@@ -205,6 +216,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
|
205
216
|
`from: ${sanitizedSenderLabel}`,
|
|
206
217
|
`to: ${msg.accountId}`,
|
|
207
218
|
];
|
|
219
|
+
appendConversationFields(headerFields, msg);
|
|
208
220
|
if (isGroup && roomTitle) {
|
|
209
221
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
210
222
|
headerFields.push(`room: ${safeRoom}`);
|
|
@@ -267,6 +279,7 @@ function composeBatchedTurn(
|
|
|
267
279
|
`[BotCord Messages (${batch.length} new)]`,
|
|
268
280
|
`to: ${msg.accountId}`,
|
|
269
281
|
];
|
|
282
|
+
appendConversationFields(header, msg);
|
|
270
283
|
if (isGroup && roomTitle) {
|
|
271
284
|
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
272
285
|
header.push(`room: ${safeRoom}`);
|