@botcord/daemon 0.2.41 → 0.2.44

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.
@@ -862,11 +862,12 @@ export class Dispatcher {
862
862
  if (controller.signal.aborted && !slot.timedOut) {
863
863
  return;
864
864
  }
865
- // Reply gating: only owner-chat rooms accept the runtime's plain text
866
- // output as a delivered message. Every other room expects the agent to
865
+ // Reply gating: BotCord network rooms only accept the runtime's plain
866
+ // text output in owner-chat. Other BotCord rooms expect the agent to
867
867
  // call the `botcord_send` tool (or `botcord send` CLI via Bash)
868
- // explicitly; runtime text in those rooms is logged and dropped,
869
- // including timeout / error notifications.
868
+ // explicitly, so final assistant text is logged and dropped there.
869
+ // Third-party gateways (Telegram / WeChat) are themselves direct
870
+ // message transports; their final runtime text is the reply.
870
871
  //
871
872
  // Owner-chat is identified by either the `rm_oc_` room prefix OR
872
873
  // `source_type === "dashboard_user_chat"` on the raw envelope — the
@@ -879,6 +880,7 @@ export class Dispatcher {
879
880
  // expectation is that the agent's `botcord_send` tool calls do their
880
881
  // own loop-risk accounting downstream.
881
882
  const isOwnerChat = isOwnerChatRoom(msg);
883
+ const canDeliverRuntimeText = isOwnerChat || !isBotCordChannel(channel);
882
884
  if (slot.timedOut) {
883
885
  this.transcript.write({
884
886
  ts: nowIso(),
@@ -891,7 +893,7 @@ export class Dispatcher {
891
893
  error: `runtime timeout after ${this.turnTimeoutMs}ms`,
892
894
  durationMs: Date.now() - slot.dispatchedAt,
893
895
  });
894
- if (isOwnerChat) {
896
+ if (canDeliverRuntimeText) {
895
897
  await this.sendReply(channel, {
896
898
  channel: msg.channel,
897
899
  accountId: msg.accountId,
@@ -936,7 +938,7 @@ export class Dispatcher {
936
938
  error: errMsg,
937
939
  durationMs: Date.now() - slot.dispatchedAt,
938
940
  });
939
- if (isOwnerChat) {
941
+ if (canDeliverRuntimeText) {
940
942
  await this.sendReply(channel, {
941
943
  channel: msg.channel,
942
944
  accountId: msg.accountId,
@@ -1026,7 +1028,7 @@ export class Dispatcher {
1026
1028
  runtime: route.runtime,
1027
1029
  error: result.error,
1028
1030
  });
1029
- if (isOwnerChat) {
1031
+ if (canDeliverRuntimeText) {
1030
1032
  const sendResult = await this.sendReply(channel, {
1031
1033
  channel: msg.channel,
1032
1034
  accountId: msg.accountId,
@@ -1065,8 +1067,8 @@ export class Dispatcher {
1065
1067
  });
1066
1068
  return;
1067
1069
  }
1068
- if (!isOwnerChat) {
1069
- // Non-owner-chat rooms: result.text never goes out. The agent is
1070
+ if (!canDeliverRuntimeText) {
1071
+ // Non-owner BotCord rooms: result.text never goes out. The agent is
1070
1072
  // expected to have used the `botcord_send` tool / `botcord send` CLI
1071
1073
  // already; whatever it left in the runtime's final assistant text is
1072
1074
  // discarded so it doesn't leak into the room.
@@ -1266,6 +1268,9 @@ function isOwnerChatRoom(msg) {
1266
1268
  }
1267
1269
  return false;
1268
1270
  }
1271
+ function isBotCordChannel(channel) {
1272
+ return channel.type === "botcord" || channel.id === "botcord";
1273
+ }
1269
1274
  function resolveQueueMode(route, kind) {
1270
1275
  if (route.queueMode)
1271
1276
  return route.queueMode;
@@ -51,6 +51,12 @@ interface GatewayLoginStatusParams {
51
51
  loginId: string;
52
52
  accountId: string;
53
53
  }
54
+ interface GatewayRecentSendersParams {
55
+ provider: GatewayProvider;
56
+ loginId: string;
57
+ accountId: string;
58
+ timeoutSeconds?: number;
59
+ }
54
60
  export type { FetchLike };
55
61
  export interface GatewayControlContext {
56
62
  gateway: Gateway;
@@ -84,6 +90,7 @@ export declare function createGatewayControl(ctx: GatewayControlContext): {
84
90
  handleTest: (params: TestGatewayParams) => Promise<AckBody>;
85
91
  handleLoginStart: (params: GatewayLoginStartParams) => Promise<AckBody>;
86
92
  handleLoginStatus: (params: GatewayLoginStatusParams) => Promise<AckBody>;
93
+ handleRecentSenders: (params: GatewayRecentSendersParams) => Promise<AckBody>;
87
94
  /** Exposed for tests — direct access to the in-memory session map. */
88
95
  _sessions: LoginSessionStore;
89
96
  };
@@ -12,6 +12,7 @@ import { loadConfig, saveConfig, resolveConfiguredAgentIds, } from "./config.js"
12
12
  import { deleteGatewaySecret, loadGatewaySecret, saveGatewaySecret, } from "./gateway/channels/secret-store.js";
13
13
  import { LoginSessionStore, maskTokenPreview, mintLoginId, } from "./gateway/channels/login-session.js";
14
14
  import { DEFAULT_WECHAT_BASE_URL, getBotQrcode, getQrcodeStatus, } from "./gateway/channels/wechat-login.js";
15
+ import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
15
16
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
16
17
  import { log as daemonLog } from "./log.js";
17
18
  /**
@@ -506,6 +507,92 @@ export function createGatewayControl(ctx) {
506
507
  const result = { status };
507
508
  return { ok: true, result };
508
509
  }
510
+ // --- gateway_recent_senders --------------------------------------------
511
+ async function handleRecentSenders(params) {
512
+ if (!isProvider(params.provider)) {
513
+ return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
514
+ }
515
+ if (params.provider !== "wechat") {
516
+ return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
517
+ }
518
+ if (!params.loginId) {
519
+ return badParams("gateway_recent_senders: loginId is required");
520
+ }
521
+ if (!params.accountId || typeof params.accountId !== "string") {
522
+ return badParams("gateway_recent_senders: accountId is required");
523
+ }
524
+ const session = sessions.get(params.loginId);
525
+ if (!session) {
526
+ return {
527
+ ok: false,
528
+ error: { code: "login_expired", message: `wechat login session "${params.loginId}" not found or expired` },
529
+ };
530
+ }
531
+ if (session.provider !== "wechat") {
532
+ return badParams("gateway_recent_senders: provider does not match login session");
533
+ }
534
+ if (session.accountId !== params.accountId) {
535
+ return {
536
+ ok: false,
537
+ error: {
538
+ code: "forbidden",
539
+ message: "gateway_recent_senders: accountId does not match login session",
540
+ },
541
+ };
542
+ }
543
+ if (!session.botToken) {
544
+ return {
545
+ ok: false,
546
+ error: { code: "login_unconfirmed", message: "wechat login session has no bot token yet" },
547
+ };
548
+ }
549
+ try {
550
+ assertSafeBaseUrl(session.baseUrl);
551
+ }
552
+ catch (urlErr) {
553
+ if (urlErr instanceof UnsafeBaseUrlError)
554
+ return badParams(urlErr.message);
555
+ throw urlErr;
556
+ }
557
+ const baseUrl = (session.baseUrl ?? DEFAULT_WECHAT_BASE_URL).replace(/\/+$/, "");
558
+ const timeoutSeconds = typeof params.timeoutSeconds === "number"
559
+ ? Math.min(Math.max(Math.floor(params.timeoutSeconds), 0), 10)
560
+ : 0;
561
+ try {
562
+ const res = await fetchImpl(`${baseUrl}/ilink/bot/getupdates`, {
563
+ method: "POST",
564
+ headers: wechatHeaders(session.botToken),
565
+ body: JSON.stringify({
566
+ get_updates_buf: "",
567
+ base_info: { ...WECHAT_BASE_INFO },
568
+ }),
569
+ signal: AbortSignal.timeout((timeoutSeconds + 10) * 1000),
570
+ });
571
+ const raw = await res.text();
572
+ const data = raw ? JSON.parse(raw) : {};
573
+ if (data.ret !== undefined && data.ret !== 0) {
574
+ return {
575
+ ok: false,
576
+ error: {
577
+ code: "provider_auth_failed",
578
+ message: `wechat getupdates failed: ret=${data.ret}`,
579
+ },
580
+ };
581
+ }
582
+ const result = {
583
+ senders: extractWechatSenders(data.msgs),
584
+ };
585
+ return { ok: true, result };
586
+ }
587
+ catch (err) {
588
+ const message = err instanceof Error ? err.message : String(err);
589
+ daemonLog.warn("gateway_recent_senders.getupdates failed", { error: message });
590
+ return {
591
+ ok: false,
592
+ error: { code: "provider_unreachable", message },
593
+ };
594
+ }
595
+ }
509
596
  return {
510
597
  handleList,
511
598
  handleUpsert,
@@ -513,6 +600,7 @@ export function createGatewayControl(ctx) {
513
600
  handleTest,
514
601
  handleLoginStart,
515
602
  handleLoginStatus,
603
+ handleRecentSenders,
516
604
  /** Exposed for tests — direct access to the in-memory session map. */
517
605
  _sessions: sessions,
518
606
  };
@@ -636,3 +724,21 @@ function mapWechatStatus(raw) {
636
724
  return "expired";
637
725
  return "failed";
638
726
  }
727
+ function extractWechatSenders(msgs) {
728
+ const out = new Map();
729
+ if (!Array.isArray(msgs))
730
+ return [];
731
+ for (const msg of msgs) {
732
+ if (!msg || typeof msg !== "object")
733
+ continue;
734
+ const raw = msg;
735
+ const id = typeof raw.from_user_id === "string" ? raw.from_user_id.trim() : "";
736
+ if (!id)
737
+ continue;
738
+ const label = (typeof raw.from_user_name === "string" && raw.from_user_name.trim()) ||
739
+ (typeof raw.sender_name === "string" && raw.sender_name.trim()) ||
740
+ null;
741
+ out.set(id, { id, label });
742
+ }
743
+ return Array.from(out.values());
744
+ }
package/dist/provision.js CHANGED
@@ -239,6 +239,14 @@ export function createProvisioner(opts) {
239
239
  return v.ack;
240
240
  return gatewayControl.handleLoginStatus(v.params);
241
241
  }
242
+ case "gateway_recent_senders": {
243
+ const v = validateGatewayParams(frame.params, {
244
+ required: ["provider", "loginId", "accountId"],
245
+ });
246
+ if (!v.ok)
247
+ return v.ack;
248
+ return gatewayControl.handleRecentSenders(v.params);
249
+ }
242
250
  case "list_agent_files": {
243
251
  const params = (frame.params ?? {});
244
252
  if (!params.agentId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.41",
3
+ "version": "0.2.44",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -239,6 +239,87 @@ describe("gateway_login_start / status", () => {
239
239
  // Bot token never escapes the daemon.
240
240
  expect(JSON.stringify(statusResult)).not.toContain("wechat-bot-token-1234567890");
241
241
  });
242
+
243
+ it("discovers recent WeChat senders from a confirmed login session", async () => {
244
+ const gw = makeFakeGateway();
245
+ const { io } = makeConfigIO(baseCfg());
246
+ const sessions = new LoginSessionStore();
247
+ sessions.create({
248
+ loginId: "wxl_discover",
249
+ accountId: "ag_alice",
250
+ provider: "wechat",
251
+ qrcode: "QR",
252
+ baseUrl: "https://ilinkai.weixin.qq.com",
253
+ botToken: "wechat-bot-token-1234567890",
254
+ });
255
+ const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
256
+ expect(String(_url)).toBe("https://ilinkai.weixin.qq.com/ilink/bot/getupdates");
257
+ expect(init?.method).toBe("POST");
258
+ expect((init?.headers as Record<string, string>).Authorization).toBe(
259
+ "Bearer wechat-bot-token-1234567890",
260
+ );
261
+ return {
262
+ text: async () =>
263
+ JSON.stringify({
264
+ ret: 0,
265
+ msgs: [
266
+ { from_user_id: "alice@im.wechat", from_user_name: "Alice" },
267
+ { from_user_id: "bob@im.wechat" },
268
+ { from_user_id: "alice@im.wechat", from_user_name: "Alice" },
269
+ { to_user_id: "ignored" },
270
+ ],
271
+ }),
272
+ };
273
+ });
274
+ const ctrl = createGatewayControl({
275
+ gateway: gw as any,
276
+ configIO: io,
277
+ loginSessions: sessions,
278
+ fetchImpl: fetchImpl as any,
279
+ });
280
+
281
+ const ack = await ctrl.handleRecentSenders({
282
+ provider: "wechat",
283
+ loginId: "wxl_discover",
284
+ accountId: "ag_alice",
285
+ timeoutSeconds: 8,
286
+ });
287
+
288
+ expect(ack.ok).toBe(true);
289
+ expect(ack.result).toEqual({
290
+ senders: [
291
+ { id: "alice@im.wechat", label: "Alice" },
292
+ { id: "bob@im.wechat", label: null },
293
+ ],
294
+ });
295
+ });
296
+
297
+ it("rejects sender discovery before WeChat login is confirmed", async () => {
298
+ const gw = makeFakeGateway();
299
+ const { io } = makeConfigIO(baseCfg());
300
+ const sessions = new LoginSessionStore();
301
+ sessions.create({
302
+ loginId: "wxl_pending",
303
+ accountId: "ag_alice",
304
+ provider: "wechat",
305
+ qrcode: "QR",
306
+ baseUrl: "https://ilinkai.weixin.qq.com",
307
+ });
308
+ const ctrl = createGatewayControl({
309
+ gateway: gw as any,
310
+ configIO: io,
311
+ loginSessions: sessions,
312
+ });
313
+
314
+ const ack = await ctrl.handleRecentSenders({
315
+ provider: "wechat",
316
+ loginId: "wxl_pending",
317
+ accountId: "ag_alice",
318
+ });
319
+
320
+ expect(ack.ok).toBe(false);
321
+ expect(ack.error?.code).toBe("login_unconfirmed");
322
+ });
242
323
  });
243
324
 
244
325
  describe("frame schema validation", () => {
@@ -1763,4 +1763,19 @@ describe("W8: gateway frame param validation in provision dispatch", () => {
1763
1763
  expect(ack.ok).toBe(false);
1764
1764
  expect(ack.error?.code).toBe("bad_params");
1765
1765
  });
1766
+
1767
+ it("rejects malformed GATEWAY_RECENT_SENDERS (missing accountId)", async () => {
1768
+ const gw = makeFakeGateway();
1769
+ const provisioner = createProvisioner({
1770
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1771
+ });
1772
+ const ack = await provisioner({
1773
+ id: "req_w8f",
1774
+ type: "gateway_recent_senders",
1775
+ params: { provider: "wechat", loginId: "wxl_1" },
1776
+ });
1777
+ expect(ack.ok).toBe(false);
1778
+ expect(ack.error?.code).toBe("bad_params");
1779
+ expect(ack.error?.message).toContain("accountId");
1780
+ });
1766
1781
  });
@@ -43,6 +43,11 @@ class FakeChannel implements ChannelAdapter {
43
43
  }
44
44
  }
45
45
 
46
+ class FakeTelegramChannel extends FakeChannel {
47
+ override readonly id = "gw_telegram_test";
48
+ override readonly type = "telegram";
49
+ }
50
+
46
51
  interface FakeRuntimeOptions {
47
52
  reply?: string;
48
53
  newSessionId?: string;
@@ -286,6 +291,26 @@ describe("Dispatcher transcript integration", () => {
286
291
  expect(s.channel.sends.length).toBe(0);
287
292
  });
288
293
 
294
+ it("third-party direct chat: runtime text is delivered", async () => {
295
+ const channel = new FakeTelegramChannel();
296
+ const s = track(await scaffold({
297
+ runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
298
+ channel,
299
+ }));
300
+ await s.dispatcher.handle(
301
+ makeEnvelope({
302
+ channel: "gw_telegram_test",
303
+ conversation: { id: "telegram:user:7904063707", kind: "direct" },
304
+ sender: { id: "telegram:user:7904063707", kind: "user", name: "danny_aaas" },
305
+ }),
306
+ );
307
+ const recs = await s.recordsForRoom("telegram:user:7904063707");
308
+ const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
309
+ expect(out.deliveryStatus).toBe("delivered");
310
+ expect(channel.sends).toHaveLength(1);
311
+ expect(channel.sends[0].message.conversationId).toBe("telegram:user:7904063707");
312
+ });
313
+
289
314
  it("dashboard_user_chat raw.source_type → delivered even outside rm_oc_", async () => {
290
315
  const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "yo" }) }));
291
316
  await s.dispatcher.handle(
@@ -1076,11 +1076,12 @@ export class Dispatcher {
1076
1076
  return;
1077
1077
  }
1078
1078
 
1079
- // Reply gating: only owner-chat rooms accept the runtime's plain text
1080
- // output as a delivered message. Every other room expects the agent to
1079
+ // Reply gating: BotCord network rooms only accept the runtime's plain
1080
+ // text output in owner-chat. Other BotCord rooms expect the agent to
1081
1081
  // call the `botcord_send` tool (or `botcord send` CLI via Bash)
1082
- // explicitly; runtime text in those rooms is logged and dropped,
1083
- // including timeout / error notifications.
1082
+ // explicitly, so final assistant text is logged and dropped there.
1083
+ // Third-party gateways (Telegram / WeChat) are themselves direct
1084
+ // message transports; their final runtime text is the reply.
1084
1085
  //
1085
1086
  // Owner-chat is identified by either the `rm_oc_` room prefix OR
1086
1087
  // `source_type === "dashboard_user_chat"` on the raw envelope — the
@@ -1093,6 +1094,7 @@ export class Dispatcher {
1093
1094
  // expectation is that the agent's `botcord_send` tool calls do their
1094
1095
  // own loop-risk accounting downstream.
1095
1096
  const isOwnerChat = isOwnerChatRoom(msg);
1097
+ const canDeliverRuntimeText = isOwnerChat || !isBotCordChannel(channel);
1096
1098
 
1097
1099
  if (slot.timedOut) {
1098
1100
  this.transcript.write({
@@ -1106,7 +1108,7 @@ export class Dispatcher {
1106
1108
  error: `runtime timeout after ${this.turnTimeoutMs}ms`,
1107
1109
  durationMs: Date.now() - slot.dispatchedAt,
1108
1110
  });
1109
- if (isOwnerChat) {
1111
+ if (canDeliverRuntimeText) {
1110
1112
  await this.sendReply(channel, {
1111
1113
  channel: msg.channel,
1112
1114
  accountId: msg.accountId,
@@ -1151,7 +1153,7 @@ export class Dispatcher {
1151
1153
  error: errMsg,
1152
1154
  durationMs: Date.now() - slot.dispatchedAt,
1153
1155
  });
1154
- if (isOwnerChat) {
1156
+ if (canDeliverRuntimeText) {
1155
1157
  await this.sendReply(channel, {
1156
1158
  channel: msg.channel,
1157
1159
  accountId: msg.accountId,
@@ -1240,7 +1242,7 @@ export class Dispatcher {
1240
1242
  runtime: route.runtime,
1241
1243
  error: result.error,
1242
1244
  });
1243
- if (isOwnerChat) {
1245
+ if (canDeliverRuntimeText) {
1244
1246
  const sendResult = await this.sendReply(channel, {
1245
1247
  channel: msg.channel,
1246
1248
  accountId: msg.accountId,
@@ -1280,8 +1282,8 @@ export class Dispatcher {
1280
1282
  return;
1281
1283
  }
1282
1284
 
1283
- if (!isOwnerChat) {
1284
- // Non-owner-chat rooms: result.text never goes out. The agent is
1285
+ if (!canDeliverRuntimeText) {
1286
+ // Non-owner BotCord rooms: result.text never goes out. The agent is
1285
1287
  // expected to have used the `botcord_send` tool / `botcord send` CLI
1286
1288
  // already; whatever it left in the runtime's final assistant text is
1287
1289
  // discarded so it doesn't leak into the room.
@@ -1496,6 +1498,10 @@ function isOwnerChatRoom(msg: GatewayInboundEnvelope["message"]): boolean {
1496
1498
  return false;
1497
1499
  }
1498
1500
 
1501
+ function isBotCordChannel(channel: ChannelAdapter): boolean {
1502
+ return channel.type === "botcord" || channel.id === "botcord";
1503
+ }
1504
+
1499
1505
  function resolveQueueMode(
1500
1506
  route: GatewayRoute,
1501
1507
  kind: "direct" | "group",
@@ -33,6 +33,7 @@ import {
33
33
  getBotQrcode,
34
34
  getQrcodeStatus,
35
35
  } from "./gateway/channels/wechat-login.js";
36
+ import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
36
37
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
37
38
  import { log as daemonLog } from "./log.js";
38
39
  // W7: canonical FetchLike lives in gateway/channels/http-types.ts so the
@@ -143,6 +144,22 @@ interface GatewayLoginStatusResult {
143
144
  tokenPreview?: string;
144
145
  }
145
146
 
147
+ interface GatewayRecentSendersParams {
148
+ provider: GatewayProvider;
149
+ loginId: string;
150
+ accountId: string;
151
+ timeoutSeconds?: number;
152
+ }
153
+
154
+ interface GatewayRecentSender {
155
+ id: string;
156
+ label?: string | null;
157
+ }
158
+
159
+ interface GatewayRecentSendersResult {
160
+ senders: GatewayRecentSender[];
161
+ }
162
+
146
163
  export type { FetchLike };
147
164
 
148
165
  export interface GatewayControlContext {
@@ -664,6 +681,92 @@ export function createGatewayControl(ctx: GatewayControlContext) {
664
681
  return { ok: true, result };
665
682
  }
666
683
 
684
+ // --- gateway_recent_senders --------------------------------------------
685
+ async function handleRecentSenders(params: GatewayRecentSendersParams): Promise<AckBody> {
686
+ if (!isProvider(params.provider)) {
687
+ return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
688
+ }
689
+ if (params.provider !== "wechat") {
690
+ return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
691
+ }
692
+ if (!params.loginId) {
693
+ return badParams("gateway_recent_senders: loginId is required");
694
+ }
695
+ if (!params.accountId || typeof params.accountId !== "string") {
696
+ return badParams("gateway_recent_senders: accountId is required");
697
+ }
698
+ const session = sessions.get(params.loginId);
699
+ if (!session) {
700
+ return {
701
+ ok: false,
702
+ error: { code: "login_expired", message: `wechat login session "${params.loginId}" not found or expired` },
703
+ };
704
+ }
705
+ if (session.provider !== "wechat") {
706
+ return badParams("gateway_recent_senders: provider does not match login session");
707
+ }
708
+ if (session.accountId !== params.accountId) {
709
+ return {
710
+ ok: false,
711
+ error: {
712
+ code: "forbidden",
713
+ message: "gateway_recent_senders: accountId does not match login session",
714
+ },
715
+ };
716
+ }
717
+ if (!session.botToken) {
718
+ return {
719
+ ok: false,
720
+ error: { code: "login_unconfirmed", message: "wechat login session has no bot token yet" },
721
+ };
722
+ }
723
+ try {
724
+ assertSafeBaseUrl(session.baseUrl);
725
+ } catch (urlErr) {
726
+ if (urlErr instanceof UnsafeBaseUrlError) return badParams(urlErr.message);
727
+ throw urlErr;
728
+ }
729
+
730
+ const baseUrl = (session.baseUrl ?? DEFAULT_WECHAT_BASE_URL).replace(/\/+$/, "");
731
+ const timeoutSeconds =
732
+ typeof params.timeoutSeconds === "number"
733
+ ? Math.min(Math.max(Math.floor(params.timeoutSeconds), 0), 10)
734
+ : 0;
735
+ try {
736
+ const res = await fetchImpl(`${baseUrl}/ilink/bot/getupdates`, {
737
+ method: "POST",
738
+ headers: wechatHeaders(session.botToken),
739
+ body: JSON.stringify({
740
+ get_updates_buf: "",
741
+ base_info: { ...WECHAT_BASE_INFO },
742
+ }),
743
+ signal: AbortSignal.timeout((timeoutSeconds + 10) * 1000),
744
+ });
745
+ const raw = await res.text();
746
+ const data = raw ? (JSON.parse(raw) as { msgs?: unknown[]; ret?: number }) : {};
747
+ if (data.ret !== undefined && data.ret !== 0) {
748
+ return {
749
+ ok: false,
750
+ error: {
751
+ code: "provider_auth_failed",
752
+ message: `wechat getupdates failed: ret=${data.ret}`,
753
+ },
754
+ };
755
+ }
756
+ const result: GatewayRecentSendersResult = {
757
+ senders: extractWechatSenders(data.msgs),
758
+ };
759
+ return { ok: true, result };
760
+ } catch (err) {
761
+ const message = err instanceof Error ? err.message : String(err);
762
+ daemonLog.warn("gateway_recent_senders.getupdates failed", { error: message });
763
+ return {
764
+ ok: false,
765
+ error: { code: "provider_unreachable", message },
766
+ };
767
+ }
768
+ }
769
+
667
770
  return {
668
771
  handleList,
669
772
  handleUpsert,
@@ -671,6 +774,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
671
774
  handleTest,
672
775
  handleLoginStart,
673
776
  handleLoginStatus,
777
+ handleRecentSenders,
674
778
  /** Exposed for tests — direct access to the in-memory session map. */
675
779
  _sessions: sessions,
676
780
  };
@@ -794,3 +898,20 @@ function mapWechatStatus(raw: string): GatewayLoginStatusResult["status"] {
794
898
  if (v === "expired" || v === "timeout") return "expired";
795
899
  return "failed";
796
900
  }
901
+
902
+ function extractWechatSenders(msgs: unknown): GatewayRecentSender[] {
903
+ const out = new Map<string, GatewayRecentSender>();
904
+ if (!Array.isArray(msgs)) return [];
905
+ for (const msg of msgs) {
906
+ if (!msg || typeof msg !== "object") continue;
907
+ const raw = msg as Record<string, unknown>;
908
+ const id = typeof raw.from_user_id === "string" ? raw.from_user_id.trim() : "";
909
+ if (!id) continue;
910
+ const label =
911
+ (typeof raw.from_user_name === "string" && raw.from_user_name.trim()) ||
912
+ (typeof raw.sender_name === "string" && raw.sender_name.trim()) ||
913
+ null;
914
+ out.set(id, { id, label });
915
+ }
916
+ return Array.from(out.values());
917
+ }
package/src/provision.ts CHANGED
@@ -371,6 +371,16 @@ export function createProvisioner(opts: ProvisionerOptions): (
371
371
  );
372
372
  }
373
373
 
374
+ case "gateway_recent_senders": {
375
+ const v = validateGatewayParams(frame.params, {
376
+ required: ["provider", "loginId", "accountId"],
377
+ });
378
+ if (!v.ok) return v.ack;
379
+ return gatewayControl.handleRecentSenders(
380
+ v.params as unknown as Parameters<typeof gatewayControl.handleRecentSenders>[0],
381
+ );
382
+ }
383
+
374
384
  case "list_agent_files": {
375
385
  const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
376
386
  if (!params.agentId) {