@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.
@@ -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
- const clientId = `botcord-${randomUUID()}`;
405
- const body = {
406
- msg: {
407
- from_user_id: "",
408
- to_user_id: trace.fromUserId,
409
- client_id: clientId,
410
- message_type: 2, // BOT → user
411
- message_state: 2, // FINISH
412
- context_token: trace.contextToken,
413
- item_list: [{ type: 1, text_item: { text: chunk } }],
414
- },
415
- };
416
- const resp = await callApi("ilink/bot/sendmessage", body, 15_000);
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) {
@@ -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
- throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
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. Have existing creds and no `--relogin` return existing record, even
334
- * when a dashboard `--install-token` is present. The token is one-time and
335
- * the generated install command should be safe to re-run after first login.
336
- * 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
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
- const tok = await redeemInstallToken({ hubUrl, installToken, label });
372
- const record = userAuthFromTokenResponse(tok, { label });
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
@@ -1,7 +1,7 @@
1
1
  export function resolveStartAuthAction(opts) {
2
- if (opts.existing && !opts.relogin)
3
- return "reuse-existing";
4
2
  if (opts.installToken)
5
3
  return "install-token";
4
+ if (opts.existing && !opts.relogin)
5
+ return "reuse-existing";
6
6
  return "device-code";
7
7
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.52",
3
+ "version": "0.2.53",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,14 +14,14 @@ const existingAuth: UserAuthRecord = {
14
14
  };
15
15
 
16
16
  describe("resolveStartAuthAction", () => {
17
- it("reuses existing auth even when a one-time install token is present", () => {
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("reuse-existing");
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: Record<string, unknown> | null }>,
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
- try {
45
- parsed = JSON.parse(init.body as string) as Record<string, unknown>;
46
- } catch {
47
- parsed = null;
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 { status: 404, ok: false, text: async () => "" };
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
- const clientId = `botcord-${randomUUID()}`;
502
- const body = {
503
- msg: {
504
- from_user_id: "",
505
- to_user_id: trace.fromUserId,
506
- client_id: clientId,
507
- message_type: 2, // BOT → user
508
- message_state: 2, // FINISH
509
- context_token: trace.contextToken,
510
- item_list: [{ type: 1, text_item: { text: chunk } }],
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) {
@@ -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
- throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
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. Have existing creds and no `--relogin` return existing record, even
422
- * when a dashboard `--install-token` is present. The token is one-time and
423
- * the generated install command should be safe to re-run after first login.
424
- * 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
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
- const tok = await redeemInstallToken({ hubUrl, installToken, label });
467
- const record = userAuthFromTokenResponse(tok, { label });
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}`);