@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.
- package/dist/gateway/dispatcher.js +14 -9
- package/dist/gateway-control.d.ts +7 -0
- package/dist/gateway-control.js +106 -0
- package/dist/provision.js +8 -0
- package/package.json +1 -1
- package/src/__tests__/gateway-control.test.ts +81 -0
- package/src/__tests__/provision.test.ts +15 -0
- package/src/gateway/__tests__/transcript.test.ts +25 -0
- package/src/gateway/dispatcher.ts +15 -9
- package/src/gateway-control.ts +121 -0
- package/src/provision.ts +10 -0
|
@@ -862,11 +862,12 @@ export class Dispatcher {
|
|
|
862
862
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
863
863
|
return;
|
|
864
864
|
}
|
|
865
|
-
// Reply gating:
|
|
866
|
-
// output
|
|
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
|
|
869
|
-
//
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
1069
|
-
// Non-owner
|
|
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
|
};
|
package/dist/gateway-control.js
CHANGED
|
@@ -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
|
@@ -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:
|
|
1080
|
-
// output
|
|
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
|
|
1083
|
-
//
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
1284
|
-
// Non-owner
|
|
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",
|
package/src/gateway-control.ts
CHANGED
|
@@ -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) {
|