@botcord/daemon 0.2.35 → 0.2.37
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/config.d.ts +30 -1
- package/dist/config.js +27 -0
- package/dist/daemon-config-map.d.ts +3 -0
- package/dist/daemon-config-map.js +30 -0
- package/dist/daemon.d.ts +15 -1
- package/dist/daemon.js +56 -11
- package/dist/gateway/channels/botcord.js +44 -0
- package/dist/gateway/channels/http-types.d.ts +19 -0
- package/dist/gateway/channels/http-types.js +1 -0
- package/dist/gateway/channels/index.d.ts +5 -0
- package/dist/gateway/channels/index.js +5 -0
- package/dist/gateway/channels/login-session.d.ts +83 -0
- package/dist/gateway/channels/login-session.js +99 -0
- package/dist/gateway/channels/secret-store.d.ts +21 -0
- package/dist/gateway/channels/secret-store.js +75 -0
- package/dist/gateway/channels/state-store.d.ts +60 -0
- package/dist/gateway/channels/state-store.js +173 -0
- package/dist/gateway/channels/telegram.d.ts +31 -0
- package/dist/gateway/channels/telegram.js +371 -0
- package/dist/gateway/channels/text-split.d.ts +13 -0
- package/dist/gateway/channels/text-split.js +33 -0
- package/dist/gateway/channels/url-guard.d.ts +18 -0
- package/dist/gateway/channels/url-guard.js +53 -0
- package/dist/gateway/channels/wechat-http.d.ts +18 -0
- package/dist/gateway/channels/wechat-http.js +28 -0
- package/dist/gateway/channels/wechat-login.d.ts +36 -0
- package/dist/gateway/channels/wechat-login.js +62 -0
- package/dist/gateway/channels/wechat.d.ts +40 -0
- package/dist/gateway/channels/wechat.js +472 -0
- package/dist/gateway/runtimes/openclaw-acp.js +211 -6
- package/dist/gateway/types.d.ts +10 -0
- package/dist/gateway-control.d.ts +53 -0
- package/dist/gateway-control.js +638 -0
- package/dist/openclaw-discovery.js +1 -1
- package/dist/provision.d.ts +7 -0
- package/dist/provision.js +255 -5
- package/package.json +1 -1
- package/src/__tests__/gateway-control.test.ts +499 -0
- package/src/__tests__/openclaw-acp.test.ts +63 -0
- package/src/__tests__/openclaw-discovery.test.ts +36 -0
- package/src/__tests__/provision.test.ts +179 -0
- package/src/__tests__/secret-store.test.ts +70 -0
- package/src/__tests__/state-store.test.ts +119 -0
- package/src/__tests__/third-party-gateway.test.ts +126 -0
- package/src/__tests__/url-guard.test.ts +85 -0
- package/src/__tests__/wechat-channel.test.ts +1134 -0
- package/src/config.ts +72 -1
- package/src/daemon-config-map.ts +24 -0
- package/src/daemon.ts +70 -11
- package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
- package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
- package/src/gateway/channels/botcord.ts +39 -0
- package/src/gateway/channels/http-types.ts +22 -0
- package/src/gateway/channels/index.ts +22 -0
- package/src/gateway/channels/login-session.ts +135 -0
- package/src/gateway/channels/secret-store.ts +100 -0
- package/src/gateway/channels/state-store.ts +213 -0
- package/src/gateway/channels/telegram.ts +469 -0
- package/src/gateway/channels/text-split.ts +29 -0
- package/src/gateway/channels/url-guard.ts +55 -0
- package/src/gateway/channels/wechat-http.ts +35 -0
- package/src/gateway/channels/wechat-login.ts +90 -0
- package/src/gateway/channels/wechat.ts +572 -0
- package/src/gateway/runtimes/openclaw-acp.ts +211 -7
- package/src/gateway/types.ts +10 -0
- package/src/gateway-control.ts +709 -0
- package/src/openclaw-discovery.ts +1 -1
- package/src/provision.ts +336 -5
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createTelegramChannel } from "../channels/telegram.js";
|
|
6
|
+
import type { ChannelStartContext, GatewayInboundEnvelope } from "../types.js";
|
|
7
|
+
import type { GatewayLogger } from "../log.js";
|
|
8
|
+
|
|
9
|
+
const silentLog: GatewayLogger = {
|
|
10
|
+
info: () => {},
|
|
11
|
+
warn: () => {},
|
|
12
|
+
error: () => {},
|
|
13
|
+
debug: () => {},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const stubConfig = {
|
|
17
|
+
channels: [],
|
|
18
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface FetchCall {
|
|
22
|
+
url: string;
|
|
23
|
+
body: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeFetchScript(
|
|
27
|
+
responses: Array<{ ok: boolean; result?: unknown; description?: string }>,
|
|
28
|
+
calls: FetchCall[],
|
|
29
|
+
onAfterUpdates?: () => void,
|
|
30
|
+
): typeof fetch {
|
|
31
|
+
let i = 0;
|
|
32
|
+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
33
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
34
|
+
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
|
35
|
+
calls.push({ url, body });
|
|
36
|
+
const r = responses[i] ?? { ok: true, result: [] };
|
|
37
|
+
i += 1;
|
|
38
|
+
if (url.includes("/getUpdates") && i === 1 && onAfterUpdates) {
|
|
39
|
+
// Fire the abort callback after returning the seeded updates so the
|
|
40
|
+
// poll loop exits before the next iteration.
|
|
41
|
+
queueMicrotask(onAfterUpdates);
|
|
42
|
+
}
|
|
43
|
+
return new Response(JSON.stringify(r), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeStartCtx(opts: {
|
|
51
|
+
abort: AbortController;
|
|
52
|
+
emit?: (env: GatewayInboundEnvelope) => Promise<void>;
|
|
53
|
+
}): {
|
|
54
|
+
ctx: ChannelStartContext;
|
|
55
|
+
emits: GatewayInboundEnvelope[];
|
|
56
|
+
statuses: Array<Record<string, unknown>>;
|
|
57
|
+
} {
|
|
58
|
+
const emits: GatewayInboundEnvelope[] = [];
|
|
59
|
+
const statuses: Array<Record<string, unknown>> = [];
|
|
60
|
+
const ctx: ChannelStartContext = {
|
|
61
|
+
config: stubConfig,
|
|
62
|
+
accountId: "ag_self",
|
|
63
|
+
abortSignal: opts.abort.signal,
|
|
64
|
+
log: silentLog,
|
|
65
|
+
emit:
|
|
66
|
+
opts.emit ??
|
|
67
|
+
(async (env) => {
|
|
68
|
+
emits.push(env);
|
|
69
|
+
}),
|
|
70
|
+
setStatus: (patch) => {
|
|
71
|
+
statuses.push(patch);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return { ctx, emits, statuses };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let tmp: string;
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
tmp = mkdtempSync(path.join(tmpdir(), "tg-channel-"));
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("createTelegramChannel — start()", () => {
|
|
90
|
+
it("marks status error with reason missing_secret when no token is available", async () => {
|
|
91
|
+
const calls: FetchCall[] = [];
|
|
92
|
+
const channel = createTelegramChannel({
|
|
93
|
+
id: "gw_tg_x",
|
|
94
|
+
accountId: "ag_self",
|
|
95
|
+
// No botToken, no allowed* — and a secretFile pointing at a non-existent path.
|
|
96
|
+
secretFile: path.join(tmp, "missing-secret.json"),
|
|
97
|
+
stateFile: path.join(tmp, "state.json"),
|
|
98
|
+
stateDebounceMs: 0,
|
|
99
|
+
fetchImpl: makeFetchScript([], calls),
|
|
100
|
+
});
|
|
101
|
+
const abort = new AbortController();
|
|
102
|
+
const { ctx, statuses } = makeStartCtx({ abort });
|
|
103
|
+
await channel.start(ctx);
|
|
104
|
+
expect(calls).toHaveLength(0);
|
|
105
|
+
const lastErrorPatch = statuses.find((s) => s.lastError === "missing_secret");
|
|
106
|
+
expect(lastErrorPatch).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("normalizes a private text update from an allowed sender/chat", async () => {
|
|
110
|
+
const calls: FetchCall[] = [];
|
|
111
|
+
const update = {
|
|
112
|
+
update_id: 100,
|
|
113
|
+
message: {
|
|
114
|
+
message_id: 7,
|
|
115
|
+
from: { id: 42, username: "alice", first_name: "Alice" },
|
|
116
|
+
chat: { id: 42, type: "private" as const },
|
|
117
|
+
text: "hello world",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const abort = new AbortController();
|
|
121
|
+
const channel = createTelegramChannel({
|
|
122
|
+
id: "gw_tg_a",
|
|
123
|
+
accountId: "ag_self",
|
|
124
|
+
botToken: "tok",
|
|
125
|
+
allowedSenderIds: ["42"],
|
|
126
|
+
allowedChatIds: ["42"],
|
|
127
|
+
stateFile: path.join(tmp, "state.json"),
|
|
128
|
+
stateDebounceMs: 0,
|
|
129
|
+
fetchImpl: makeFetchScript(
|
|
130
|
+
[{ ok: true, result: [update] }],
|
|
131
|
+
calls,
|
|
132
|
+
() => abort.abort(),
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
const { ctx, emits } = makeStartCtx({ abort });
|
|
136
|
+
await channel.start(ctx);
|
|
137
|
+
expect(emits).toHaveLength(1);
|
|
138
|
+
const msg = emits[0]!.message;
|
|
139
|
+
expect(msg.id).toBe("telegram:42:7");
|
|
140
|
+
expect(msg.conversation.id).toBe("telegram:user:42");
|
|
141
|
+
expect(msg.conversation.kind).toBe("direct");
|
|
142
|
+
expect(msg.sender.id).toBe("telegram:user:42");
|
|
143
|
+
expect(msg.sender.kind).toBe("user");
|
|
144
|
+
expect(msg.text).toBe("hello world");
|
|
145
|
+
expect(msg.accountId).toBe("ag_self");
|
|
146
|
+
expect(msg.channel).toBe("gw_tg_a");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("uses telegram:group:<id> for non-private chats", async () => {
|
|
150
|
+
const calls: FetchCall[] = [];
|
|
151
|
+
const update = {
|
|
152
|
+
update_id: 5,
|
|
153
|
+
message: {
|
|
154
|
+
message_id: 3,
|
|
155
|
+
from: { id: 99, first_name: "Bob" },
|
|
156
|
+
chat: { id: -1001, type: "supergroup" as const, title: "Team" },
|
|
157
|
+
text: "yo",
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const abort = new AbortController();
|
|
161
|
+
const channel = createTelegramChannel({
|
|
162
|
+
id: "gw_tg_g",
|
|
163
|
+
accountId: "ag_self",
|
|
164
|
+
botToken: "tok",
|
|
165
|
+
allowedSenderIds: ["99"],
|
|
166
|
+
allowedChatIds: ["-1001"],
|
|
167
|
+
stateFile: path.join(tmp, "state.json"),
|
|
168
|
+
stateDebounceMs: 0,
|
|
169
|
+
fetchImpl: makeFetchScript(
|
|
170
|
+
[{ ok: true, result: [update] }],
|
|
171
|
+
calls,
|
|
172
|
+
() => abort.abort(),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
const { ctx, emits } = makeStartCtx({ abort });
|
|
176
|
+
await channel.start(ctx);
|
|
177
|
+
expect(emits).toHaveLength(1);
|
|
178
|
+
expect(emits[0]!.message.conversation.id).toBe("telegram:group:-1001");
|
|
179
|
+
expect(emits[0]!.message.conversation.kind).toBe("group");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("default-denies inbound when allowed lists are empty", async () => {
|
|
183
|
+
const calls: FetchCall[] = [];
|
|
184
|
+
const update = {
|
|
185
|
+
update_id: 9,
|
|
186
|
+
message: {
|
|
187
|
+
message_id: 1,
|
|
188
|
+
from: { id: 1 },
|
|
189
|
+
chat: { id: 1, type: "private" as const },
|
|
190
|
+
text: "spam",
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const abort = new AbortController();
|
|
194
|
+
const channel = createTelegramChannel({
|
|
195
|
+
id: "gw_tg_deny",
|
|
196
|
+
accountId: "ag_self",
|
|
197
|
+
botToken: "tok",
|
|
198
|
+
// No allowedSenderIds / allowedChatIds.
|
|
199
|
+
stateFile: path.join(tmp, "state.json"),
|
|
200
|
+
stateDebounceMs: 0,
|
|
201
|
+
fetchImpl: makeFetchScript(
|
|
202
|
+
[{ ok: true, result: [update] }],
|
|
203
|
+
calls,
|
|
204
|
+
() => abort.abort(),
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
const { ctx, emits } = makeStartCtx({ abort });
|
|
208
|
+
await channel.start(ctx);
|
|
209
|
+
expect(emits).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("drops a chat that is allowed by sender but not by chat (miss)", async () => {
|
|
213
|
+
const calls: FetchCall[] = [];
|
|
214
|
+
const update = {
|
|
215
|
+
update_id: 9,
|
|
216
|
+
message: {
|
|
217
|
+
message_id: 1,
|
|
218
|
+
from: { id: 1 },
|
|
219
|
+
chat: { id: 2, type: "private" as const },
|
|
220
|
+
text: "hi",
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const abort = new AbortController();
|
|
224
|
+
const channel = createTelegramChannel({
|
|
225
|
+
id: "gw_tg_miss",
|
|
226
|
+
accountId: "ag_self",
|
|
227
|
+
botToken: "tok",
|
|
228
|
+
allowedSenderIds: ["1"],
|
|
229
|
+
allowedChatIds: ["3"], // miss
|
|
230
|
+
stateFile: path.join(tmp, "state.json"),
|
|
231
|
+
stateDebounceMs: 0,
|
|
232
|
+
fetchImpl: makeFetchScript(
|
|
233
|
+
[{ ok: true, result: [update] }],
|
|
234
|
+
calls,
|
|
235
|
+
() => abort.abort(),
|
|
236
|
+
),
|
|
237
|
+
});
|
|
238
|
+
const { ctx, emits } = makeStartCtx({ abort });
|
|
239
|
+
await channel.start(ctx);
|
|
240
|
+
expect(emits).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("persists the next offset to the state file (no replay on restart)", async () => {
|
|
244
|
+
const calls: FetchCall[] = [];
|
|
245
|
+
const stateFile = path.join(tmp, "state.json");
|
|
246
|
+
const update = {
|
|
247
|
+
update_id: 250,
|
|
248
|
+
message: {
|
|
249
|
+
message_id: 7,
|
|
250
|
+
from: { id: 1 },
|
|
251
|
+
chat: { id: 1, type: "private" as const },
|
|
252
|
+
text: "hi",
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const abort = new AbortController();
|
|
256
|
+
const channel = createTelegramChannel({
|
|
257
|
+
id: "gw_tg_state",
|
|
258
|
+
accountId: "ag_self",
|
|
259
|
+
botToken: "tok",
|
|
260
|
+
allowedSenderIds: ["1"],
|
|
261
|
+
allowedChatIds: ["1"],
|
|
262
|
+
stateFile,
|
|
263
|
+
stateDebounceMs: 0,
|
|
264
|
+
fetchImpl: makeFetchScript(
|
|
265
|
+
[{ ok: true, result: [update] }],
|
|
266
|
+
calls,
|
|
267
|
+
() => abort.abort(),
|
|
268
|
+
),
|
|
269
|
+
});
|
|
270
|
+
const { ctx } = makeStartCtx({ abort });
|
|
271
|
+
await channel.start(ctx);
|
|
272
|
+
const persisted = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
273
|
+
expect(persisted.cursor).toBe("251");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("calls getUpdates with the persisted offset on a fresh start", async () => {
|
|
277
|
+
const stateFile = path.join(tmp, "state.json");
|
|
278
|
+
// Seed the state file with a cursor.
|
|
279
|
+
{
|
|
280
|
+
const seed = createTelegramChannel({
|
|
281
|
+
id: "gw_tg_seed",
|
|
282
|
+
accountId: "ag_self",
|
|
283
|
+
botToken: "tok",
|
|
284
|
+
allowedSenderIds: ["1"],
|
|
285
|
+
allowedChatIds: ["1"],
|
|
286
|
+
stateFile,
|
|
287
|
+
stateDebounceMs: 0,
|
|
288
|
+
fetchImpl: makeFetchScript([], []),
|
|
289
|
+
});
|
|
290
|
+
// Reach into the stop() to flush — but easier: just use state-store directly.
|
|
291
|
+
void seed; // unused
|
|
292
|
+
}
|
|
293
|
+
// Manually write a state file simulating prior cursor=999.
|
|
294
|
+
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
295
|
+
mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
296
|
+
writeFileSync(
|
|
297
|
+
stateFile,
|
|
298
|
+
JSON.stringify({ cursor: "999", updatedAt: new Date(0).toISOString() }),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const calls: FetchCall[] = [];
|
|
302
|
+
const abort = new AbortController();
|
|
303
|
+
const channel = createTelegramChannel({
|
|
304
|
+
id: "gw_tg_restart",
|
|
305
|
+
accountId: "ag_self",
|
|
306
|
+
botToken: "tok",
|
|
307
|
+
allowedSenderIds: ["1"],
|
|
308
|
+
allowedChatIds: ["1"],
|
|
309
|
+
stateFile,
|
|
310
|
+
stateDebounceMs: 0,
|
|
311
|
+
fetchImpl: makeFetchScript(
|
|
312
|
+
[{ ok: true, result: [] }],
|
|
313
|
+
calls,
|
|
314
|
+
() => abort.abort(),
|
|
315
|
+
),
|
|
316
|
+
});
|
|
317
|
+
const { ctx } = makeStartCtx({ abort });
|
|
318
|
+
await channel.start(ctx);
|
|
319
|
+
expect(calls).toHaveLength(1);
|
|
320
|
+
expect((calls[0]!.body as Record<string, unknown>).offset).toBe(999);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("createTelegramChannel — send()", () => {
|
|
325
|
+
it("posts to /sendMessage with the chat_id stripped from conversation.id", async () => {
|
|
326
|
+
const calls: FetchCall[] = [];
|
|
327
|
+
const channel = createTelegramChannel({
|
|
328
|
+
id: "gw_tg_send",
|
|
329
|
+
accountId: "ag_self",
|
|
330
|
+
botToken: "tok",
|
|
331
|
+
stateFile: path.join(tmp, "state.json"),
|
|
332
|
+
stateDebounceMs: 0,
|
|
333
|
+
fetchImpl: makeFetchScript(
|
|
334
|
+
[{ ok: true, result: { message_id: 17 } }],
|
|
335
|
+
calls,
|
|
336
|
+
),
|
|
337
|
+
});
|
|
338
|
+
const result = await channel.send({
|
|
339
|
+
message: {
|
|
340
|
+
channel: "gw_tg_send",
|
|
341
|
+
accountId: "ag_self",
|
|
342
|
+
conversationId: "telegram:user:42",
|
|
343
|
+
text: "hi back",
|
|
344
|
+
threadId: null,
|
|
345
|
+
replyTo: null,
|
|
346
|
+
traceId: null,
|
|
347
|
+
},
|
|
348
|
+
log: silentLog,
|
|
349
|
+
});
|
|
350
|
+
expect(calls).toHaveLength(1);
|
|
351
|
+
expect(calls[0]!.url).toContain("/sendMessage");
|
|
352
|
+
const body = calls[0]!.body as Record<string, unknown>;
|
|
353
|
+
expect(body.chat_id).toBe("42");
|
|
354
|
+
expect(body.text).toBe("hi back");
|
|
355
|
+
expect(body.disable_web_page_preview).toBe(true);
|
|
356
|
+
expect(result.providerMessageId).toBe("telegram:42:17");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("splits long text and posts one /sendMessage per chunk", async () => {
|
|
360
|
+
const calls: FetchCall[] = [];
|
|
361
|
+
const channel = createTelegramChannel({
|
|
362
|
+
id: "gw_tg_split",
|
|
363
|
+
accountId: "ag_self",
|
|
364
|
+
botToken: "tok",
|
|
365
|
+
splitAt: 10,
|
|
366
|
+
stateFile: path.join(tmp, "state.json"),
|
|
367
|
+
stateDebounceMs: 0,
|
|
368
|
+
fetchImpl: makeFetchScript(
|
|
369
|
+
[
|
|
370
|
+
{ ok: true, result: { message_id: 1 } },
|
|
371
|
+
{ ok: true, result: { message_id: 2 } },
|
|
372
|
+
{ ok: true, result: { message_id: 3 } },
|
|
373
|
+
],
|
|
374
|
+
calls,
|
|
375
|
+
),
|
|
376
|
+
});
|
|
377
|
+
await channel.send({
|
|
378
|
+
message: {
|
|
379
|
+
channel: "gw_tg_split",
|
|
380
|
+
accountId: "ag_self",
|
|
381
|
+
conversationId: "telegram:group:-100",
|
|
382
|
+
text: "abcdefghij\nklmnopqrst\nuvwxyz",
|
|
383
|
+
threadId: null,
|
|
384
|
+
replyTo: null,
|
|
385
|
+
traceId: null,
|
|
386
|
+
},
|
|
387
|
+
log: silentLog,
|
|
388
|
+
});
|
|
389
|
+
expect(calls.length).toBeGreaterThanOrEqual(2);
|
|
390
|
+
for (const c of calls) {
|
|
391
|
+
const body = c.body as Record<string, unknown>;
|
|
392
|
+
expect(body.chat_id).toBe("-100");
|
|
393
|
+
expect((body.text as string).length).toBeLessThanOrEqual(10);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("W1: cursor unchanged when emit throws", () => {
|
|
399
|
+
it("leaves the on-disk cursor untouched when emit() throws on the first batch", { timeout: 5000 }, async () => {
|
|
400
|
+
const stateFile = path.join(tmp, "state.json");
|
|
401
|
+
const update = {
|
|
402
|
+
update_id: 999,
|
|
403
|
+
message: {
|
|
404
|
+
message_id: 1,
|
|
405
|
+
from: { id: 42, username: "alice" },
|
|
406
|
+
chat: { id: 42, type: "private" as const },
|
|
407
|
+
text: "boom",
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
const calls: FetchCall[] = [];
|
|
411
|
+
const abort = new AbortController();
|
|
412
|
+
// Custom fetch: deliver the update once, then block on AbortSignal so
|
|
413
|
+
// the poll loop doesn't hot-spin while we wait for the abort to fire.
|
|
414
|
+
let delivered = false;
|
|
415
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
416
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
417
|
+
calls.push({ url, body: init?.body ? JSON.parse(init.body as string) : undefined });
|
|
418
|
+
if (!delivered) {
|
|
419
|
+
delivered = true;
|
|
420
|
+
return new Response(JSON.stringify({ ok: true, result: [update] }), {
|
|
421
|
+
status: 200,
|
|
422
|
+
headers: { "content-type": "application/json" },
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Second + later polls: abort and throw immediately so the loop exits.
|
|
426
|
+
abort.abort();
|
|
427
|
+
const e = new Error("aborted");
|
|
428
|
+
e.name = "AbortError";
|
|
429
|
+
throw e;
|
|
430
|
+
}) as unknown as typeof fetch;
|
|
431
|
+
const channel = createTelegramChannel({
|
|
432
|
+
id: "gw_tg_w1",
|
|
433
|
+
accountId: "ag_self",
|
|
434
|
+
botToken: "tok",
|
|
435
|
+
stateFile,
|
|
436
|
+
stateDebounceMs: 0,
|
|
437
|
+
fetchImpl,
|
|
438
|
+
allowedChatIds: ["42"],
|
|
439
|
+
allowedSenderIds: ["42"],
|
|
440
|
+
});
|
|
441
|
+
let emitCalls = 0;
|
|
442
|
+
const { ctx } = makeStartCtx({
|
|
443
|
+
abort,
|
|
444
|
+
emit: async () => {
|
|
445
|
+
emitCalls += 1;
|
|
446
|
+
throw new Error("emit boom");
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
await channel.start(ctx);
|
|
450
|
+
expect(emitCalls).toBeGreaterThanOrEqual(1);
|
|
451
|
+
// No state file written (cursor never advanced) OR if written, cursor
|
|
452
|
+
// must NOT be 1000 — proving the failed batch will retry.
|
|
453
|
+
let cursor: string | undefined;
|
|
454
|
+
try {
|
|
455
|
+
cursor = JSON.parse(readFileSync(stateFile, "utf8")).cursor;
|
|
456
|
+
} catch {
|
|
457
|
+
cursor = undefined;
|
|
458
|
+
}
|
|
459
|
+
expect(cursor).not.toBe("1000");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("W2: started guard", () => {
|
|
464
|
+
it("calling start() a second time throws 'already started'", async () => {
|
|
465
|
+
const calls: FetchCall[] = [];
|
|
466
|
+
const abort = new AbortController();
|
|
467
|
+
const channel = createTelegramChannel({
|
|
468
|
+
id: "gw_tg_guard",
|
|
469
|
+
accountId: "ag_self",
|
|
470
|
+
botToken: "tok",
|
|
471
|
+
stateFile: path.join(tmp, "state.json"),
|
|
472
|
+
stateDebounceMs: 0,
|
|
473
|
+
allowedChatIds: ["1"],
|
|
474
|
+
allowedSenderIds: ["1"],
|
|
475
|
+
fetchImpl: makeFetchScript([{ ok: true, result: [] }], calls),
|
|
476
|
+
});
|
|
477
|
+
const { ctx } = makeStartCtx({ abort });
|
|
478
|
+
// First start — runs async; don't await yet.
|
|
479
|
+
const first = channel.start(ctx);
|
|
480
|
+
// Second start while first is in-flight.
|
|
481
|
+
const secondAbort = new AbortController();
|
|
482
|
+
const { ctx: ctx2 } = makeStartCtx({ abort: secondAbort });
|
|
483
|
+
await expect(channel.start(ctx2)).rejects.toThrow("already started");
|
|
484
|
+
abort.abort();
|
|
485
|
+
await first;
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("C3: bot token redacted in error logs", () => {
|
|
490
|
+
it("fetch error that includes the bot token is re-thrown with token replaced by ***", async () => {
|
|
491
|
+
const SECRET_TOKEN = "1234567890:ABCDEFGHIJKLMNabcdefghijklmn";
|
|
492
|
+
let caughtMessage = "";
|
|
493
|
+
const fetchImpl = (async () => {
|
|
494
|
+
const e = new Error(`network error: https://api.telegram.org/bot${SECRET_TOKEN}/getUpdates failed`);
|
|
495
|
+
throw e;
|
|
496
|
+
}) as unknown as typeof fetch;
|
|
497
|
+
const abort = new AbortController();
|
|
498
|
+
const channel = createTelegramChannel({
|
|
499
|
+
id: "gw_tg_c3",
|
|
500
|
+
accountId: "ag_self",
|
|
501
|
+
botToken: SECRET_TOKEN,
|
|
502
|
+
allowedChatIds: ["1"],
|
|
503
|
+
allowedSenderIds: ["1"],
|
|
504
|
+
stateFile: path.join(tmp, "state-c3.json"),
|
|
505
|
+
stateDebounceMs: 0,
|
|
506
|
+
fetchImpl,
|
|
507
|
+
});
|
|
508
|
+
const { ctx } = makeStartCtx({
|
|
509
|
+
abort,
|
|
510
|
+
emit: async () => {},
|
|
511
|
+
});
|
|
512
|
+
// Override log to capture warn/error output as JSON strings for inspection.
|
|
513
|
+
const logged: string[] = [];
|
|
514
|
+
const captureLog = (...args: unknown[]) => {
|
|
515
|
+
logged.push(JSON.stringify(args));
|
|
516
|
+
};
|
|
517
|
+
(ctx.log as Record<string, unknown>).error = captureLog;
|
|
518
|
+
(ctx.log as Record<string, unknown>).warn = captureLog;
|
|
519
|
+
// The poll loop will catch the fetch error and log it, then back-off.
|
|
520
|
+
// Abort after a short time.
|
|
521
|
+
setTimeout(() => abort.abort(), 200);
|
|
522
|
+
await channel.start(ctx);
|
|
523
|
+
// All logged output must NOT contain the raw token.
|
|
524
|
+
for (const line of logged) {
|
|
525
|
+
expect(line).not.toContain(SECRET_TOKEN);
|
|
526
|
+
}
|
|
527
|
+
// At least one log line must reference *** (token redacted).
|
|
528
|
+
expect(logged.some((l) => l.includes("***"))).toBe(true);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("createTelegramChannel — typing()", () => {
|
|
533
|
+
it("posts to /sendChatAction with action: typing", async () => {
|
|
534
|
+
const calls: FetchCall[] = [];
|
|
535
|
+
const channel = createTelegramChannel({
|
|
536
|
+
id: "gw_tg_typing",
|
|
537
|
+
accountId: "ag_self",
|
|
538
|
+
botToken: "tok",
|
|
539
|
+
stateFile: path.join(tmp, "state.json"),
|
|
540
|
+
stateDebounceMs: 0,
|
|
541
|
+
fetchImpl: makeFetchScript([{ ok: true, result: true }], calls),
|
|
542
|
+
});
|
|
543
|
+
await channel.typing!({
|
|
544
|
+
traceId: "t1",
|
|
545
|
+
accountId: "ag_self",
|
|
546
|
+
conversationId: "telegram:user:42",
|
|
547
|
+
log: silentLog,
|
|
548
|
+
});
|
|
549
|
+
expect(calls).toHaveLength(1);
|
|
550
|
+
expect(calls[0]!.url).toContain("/sendChatAction");
|
|
551
|
+
const body = calls[0]!.body as Record<string, unknown>;
|
|
552
|
+
expect(body.chat_id).toBe("42");
|
|
553
|
+
expect(body.action).toBe("typing");
|
|
554
|
+
});
|
|
555
|
+
});
|
|
@@ -955,6 +955,7 @@ function normalizeBlockForHub(
|
|
|
955
955
|
if (typeof raw?.subtype === "string") payload.subtype = raw.subtype;
|
|
956
956
|
if (typeof raw?.session_id === "string") payload.session_id = raw.session_id;
|
|
957
957
|
if (typeof raw?.model === "string") payload.model = raw.model;
|
|
958
|
+
payload.details = formatBlockDetails(raw);
|
|
958
959
|
return { kind: "system", seq, payload };
|
|
959
960
|
}
|
|
960
961
|
|
|
@@ -966,6 +967,7 @@ function normalizeBlockForHub(
|
|
|
966
967
|
if (typeof raw?.phase === "string") payload.phase = raw.phase;
|
|
967
968
|
if (typeof raw?.label === "string") payload.label = raw.label;
|
|
968
969
|
if (typeof raw?.source === "string") payload.source = raw.source;
|
|
970
|
+
payload.details = formatBlockDetails(raw);
|
|
969
971
|
return { kind: "thinking", seq, payload };
|
|
970
972
|
}
|
|
971
973
|
|
|
@@ -977,3 +979,40 @@ function normalizeBlockForHub(
|
|
|
977
979
|
}
|
|
978
980
|
return { kind: "other", seq, payload };
|
|
979
981
|
}
|
|
982
|
+
|
|
983
|
+
function formatBlockDetails(raw: unknown): string {
|
|
984
|
+
if (!raw || typeof raw !== "object") return "";
|
|
985
|
+
const r = raw as any;
|
|
986
|
+
const direct =
|
|
987
|
+
typeof r.text === "string" ? r.text
|
|
988
|
+
: typeof r.message === "string" ? r.message
|
|
989
|
+
: typeof r.summary === "string" ? r.summary
|
|
990
|
+
: typeof r.label === "string" ? r.label
|
|
991
|
+
: "";
|
|
992
|
+
if (direct) return direct;
|
|
993
|
+
|
|
994
|
+
const contentText = extractContentText(r.content ?? r.message?.content ?? r.params?.update?.content);
|
|
995
|
+
if (contentText) return contentText;
|
|
996
|
+
|
|
997
|
+
try {
|
|
998
|
+
return JSON.stringify(raw, null, 2);
|
|
999
|
+
} catch {
|
|
1000
|
+
return String(raw);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function extractContentText(content: unknown): string {
|
|
1005
|
+
if (!content) return "";
|
|
1006
|
+
if (typeof content === "string") return content;
|
|
1007
|
+
if (Array.isArray(content)) {
|
|
1008
|
+
return content.map(extractContentText).filter(Boolean).join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
if (typeof content === "object") {
|
|
1011
|
+
const c = content as any;
|
|
1012
|
+
if (typeof c.text === "string") return c.text;
|
|
1013
|
+
if (typeof c.thinking === "string") return c.thinking;
|
|
1014
|
+
if (typeof c.content === "string") return c.content;
|
|
1015
|
+
if (Array.isArray(c.content)) return extractContentText(c.content);
|
|
1016
|
+
}
|
|
1017
|
+
return "";
|
|
1018
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical `fetch`-compatible signature shared by gateway-control.ts and
|
|
3
|
+
* the WeChat HTTP helpers. Lets tests inject a stub without depending on
|
|
4
|
+
* undici's full type surface.
|
|
5
|
+
*
|
|
6
|
+
* Kept structurally compatible with both `globalThis.fetch` and the
|
|
7
|
+
* narrower wechat-http test stubs — `body` is optional so callers that
|
|
8
|
+
* only issue GETs (e.g. Telegram `getMe` test probe) can omit it.
|
|
9
|
+
*/
|
|
10
|
+
export type FetchLike = (
|
|
11
|
+
input: string,
|
|
12
|
+
init?: {
|
|
13
|
+
method?: string;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
body?: string;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
},
|
|
18
|
+
) => Promise<{
|
|
19
|
+
status?: number;
|
|
20
|
+
ok?: boolean;
|
|
21
|
+
text(): Promise<string>;
|
|
22
|
+
}>;
|
|
@@ -4,3 +4,25 @@ export type {
|
|
|
4
4
|
BotCordChannelOptions,
|
|
5
5
|
BotCordClientFactory,
|
|
6
6
|
} from "./botcord.js";
|
|
7
|
+
export { createTelegramChannel, type TelegramChannelOptions } from "./telegram.js";
|
|
8
|
+
export { createWechatChannel, type WechatChannelOptions } from "./wechat.js";
|
|
9
|
+
export {
|
|
10
|
+
getBotQrcode,
|
|
11
|
+
getQrcodeStatus,
|
|
12
|
+
DEFAULT_WECHAT_BASE_URL,
|
|
13
|
+
type WechatQrcode,
|
|
14
|
+
type WechatQrcodeStatus,
|
|
15
|
+
type WechatLoginOptions,
|
|
16
|
+
} from "./wechat-login.js";
|
|
17
|
+
export {
|
|
18
|
+
defaultGatewaySecretPath,
|
|
19
|
+
loadGatewaySecret,
|
|
20
|
+
saveGatewaySecret,
|
|
21
|
+
deleteGatewaySecret,
|
|
22
|
+
} from "./secret-store.js";
|
|
23
|
+
export {
|
|
24
|
+
GatewayStateStore,
|
|
25
|
+
defaultGatewayStatePath,
|
|
26
|
+
type GatewayStateStoreOptions,
|
|
27
|
+
type ThirdPartyGatewayState,
|
|
28
|
+
} from "./state-store.js";
|