@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,1134 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createWechatChannel } from "../gateway/channels/wechat.js";
|
|
6
|
+
import type {
|
|
7
|
+
ChannelStartContext,
|
|
8
|
+
GatewayInboundEnvelope,
|
|
9
|
+
GatewayLogger,
|
|
10
|
+
} from "../gateway/types.js";
|
|
11
|
+
import type { FetchLike } from "../gateway/channels/wechat-http.js";
|
|
12
|
+
|
|
13
|
+
const SILENT_LOG: GatewayLogger = {
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
debug: () => {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface StubResponse {
|
|
21
|
+
status?: number;
|
|
22
|
+
body: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a fetch stub whose responses are matched by URL substring. Each
|
|
27
|
+
* matcher returns either a single response or a queue (for `getupdates`,
|
|
28
|
+
* which is called repeatedly).
|
|
29
|
+
*/
|
|
30
|
+
function buildFetchStub(
|
|
31
|
+
matchers: Array<{
|
|
32
|
+
match: string;
|
|
33
|
+
respond: (
|
|
34
|
+
callIdx: number,
|
|
35
|
+
body: Record<string, unknown> | null,
|
|
36
|
+
) => StubResponse | Promise<StubResponse>;
|
|
37
|
+
}>,
|
|
38
|
+
calls: Array<{ url: string; body: Record<string, unknown> | null }>,
|
|
39
|
+
): FetchLike {
|
|
40
|
+
const counters = new Map<string, number>();
|
|
41
|
+
return async (url, init) => {
|
|
42
|
+
let parsed: Record<string, unknown> | null = null;
|
|
43
|
+
if (init?.body) {
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
46
|
+
} catch {
|
|
47
|
+
parsed = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
calls.push({ url, body: parsed });
|
|
51
|
+
for (const m of matchers) {
|
|
52
|
+
if (url.includes(m.match)) {
|
|
53
|
+
const idx = counters.get(m.match) ?? 0;
|
|
54
|
+
counters.set(m.match, idx + 1);
|
|
55
|
+
const resp = await m.respond(idx, parsed);
|
|
56
|
+
const status = resp.status ?? 200;
|
|
57
|
+
const text =
|
|
58
|
+
typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body);
|
|
59
|
+
return {
|
|
60
|
+
status,
|
|
61
|
+
ok: status >= 200 && status < 300,
|
|
62
|
+
text: async () => text,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { status: 404, ok: false, text: async () => "" };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface HarnessResult {
|
|
71
|
+
envelopes: GatewayInboundEnvelope[];
|
|
72
|
+
statusPatches: Array<Record<string, unknown>>;
|
|
73
|
+
pollDone: Promise<void>;
|
|
74
|
+
abort: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startAdapter(
|
|
78
|
+
adapter: ReturnType<typeof createWechatChannel>,
|
|
79
|
+
opts: { stopAfterEnvelopes?: number; stopAfterMs?: number } = {},
|
|
80
|
+
): HarnessResult {
|
|
81
|
+
const ctrl = new AbortController();
|
|
82
|
+
const envelopes: GatewayInboundEnvelope[] = [];
|
|
83
|
+
const statusPatches: Array<Record<string, unknown>> = [];
|
|
84
|
+
const ctx: ChannelStartContext = {
|
|
85
|
+
config: { channels: [], defaultRoute: { runtime: "claude-code", cwd: "/tmp" } },
|
|
86
|
+
accountId: "ag_test",
|
|
87
|
+
abortSignal: ctrl.signal,
|
|
88
|
+
log: SILENT_LOG,
|
|
89
|
+
emit: async (env) => {
|
|
90
|
+
envelopes.push(env);
|
|
91
|
+
if (
|
|
92
|
+
opts.stopAfterEnvelopes !== undefined &&
|
|
93
|
+
envelopes.length >= opts.stopAfterEnvelopes
|
|
94
|
+
) {
|
|
95
|
+
ctrl.abort();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
setStatus: (patch) => {
|
|
99
|
+
statusPatches.push({ ...patch });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const pollDone = adapter.start(ctx) as Promise<void>;
|
|
103
|
+
if (opts.stopAfterMs !== undefined) {
|
|
104
|
+
setTimeout(() => ctrl.abort(), opts.stopAfterMs);
|
|
105
|
+
}
|
|
106
|
+
return { envelopes, statusPatches, pollDone, abort: () => ctrl.abort() };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("wechat channel adapter", () => {
|
|
110
|
+
let tmp: string;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
tmp = mkdtempSync(path.join(tmpdir(), "wechat-ch-"));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("marks status error with reason missing_secret when bot token is unavailable", async () => {
|
|
121
|
+
const fakeSecret = path.join(tmp, "no-such-secret.json");
|
|
122
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
123
|
+
const fetchImpl = buildFetchStub(
|
|
124
|
+
[
|
|
125
|
+
{
|
|
126
|
+
match: "getupdates",
|
|
127
|
+
respond: () => ({ body: { ret: 0, get_updates_buf: "buf-1", msgs: [] } }),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
calls,
|
|
131
|
+
);
|
|
132
|
+
const adapter = createWechatChannel({
|
|
133
|
+
id: "gw_wx_1",
|
|
134
|
+
accountId: "ag_test",
|
|
135
|
+
secretFile: fakeSecret,
|
|
136
|
+
stateFile: path.join(tmp, "state.json"),
|
|
137
|
+
fetchImpl,
|
|
138
|
+
stateDebounceMs: 0,
|
|
139
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
140
|
+
});
|
|
141
|
+
const h = startAdapter(adapter);
|
|
142
|
+
await h.pollDone;
|
|
143
|
+
expect(h.statusPatches.some((p) => p.lastError === "missing_secret")).toBe(true);
|
|
144
|
+
expect(calls.length).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("normalizes a message_type=1 inbound and persists get_updates_buf cursor", async () => {
|
|
148
|
+
const stateFile = path.join(tmp, "state.json");
|
|
149
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
150
|
+
const fetchImpl = buildFetchStub(
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
match: "getupdates",
|
|
154
|
+
respond: (idx) => {
|
|
155
|
+
if (idx === 0) {
|
|
156
|
+
return {
|
|
157
|
+
body: {
|
|
158
|
+
ret: 0,
|
|
159
|
+
get_updates_buf: "cursor-after-1",
|
|
160
|
+
msgs: [
|
|
161
|
+
{
|
|
162
|
+
message_type: 1,
|
|
163
|
+
from_user_id: "alice@im.wechat",
|
|
164
|
+
context_token: "ctx-aaa",
|
|
165
|
+
client_id: "wechat-client-1",
|
|
166
|
+
item_list: [{ type: 1, text_item: { text: "hello world" } }],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Subsequent polls: empty so the loop just keeps spinning until abort.
|
|
173
|
+
return { body: { ret: 0, get_updates_buf: "cursor-after-1", msgs: [] } };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
calls,
|
|
178
|
+
);
|
|
179
|
+
const adapter = createWechatChannel({
|
|
180
|
+
id: "gw_wx_1",
|
|
181
|
+
accountId: "ag_test",
|
|
182
|
+
botToken: "tok-123",
|
|
183
|
+
stateFile,
|
|
184
|
+
fetchImpl,
|
|
185
|
+
stateDebounceMs: 0,
|
|
186
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
187
|
+
});
|
|
188
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
189
|
+
await h.pollDone;
|
|
190
|
+
|
|
191
|
+
expect(h.envelopes).toHaveLength(1);
|
|
192
|
+
const env = h.envelopes[0]!;
|
|
193
|
+
expect(env.message.id).toBe("wechat-client-1");
|
|
194
|
+
expect(env.message.conversation).toEqual({
|
|
195
|
+
id: "wechat:user:alice@im.wechat",
|
|
196
|
+
kind: "direct",
|
|
197
|
+
});
|
|
198
|
+
expect(env.message.sender).toEqual({
|
|
199
|
+
id: "alice@im.wechat",
|
|
200
|
+
kind: "user",
|
|
201
|
+
});
|
|
202
|
+
expect(env.message.text).toBe("hello world");
|
|
203
|
+
expect(env.message.trace?.id).toMatch(/^wechat:alice@im\.wechat:\d+:/);
|
|
204
|
+
|
|
205
|
+
// Cursor on disk reflects the buf returned by the first poll.
|
|
206
|
+
const stateRaw = (await import("node:fs")).readFileSync(stateFile, "utf8");
|
|
207
|
+
expect(JSON.parse(stateRaw).cursor).toBe("cursor-after-1");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("drops messages missing context_token", async () => {
|
|
211
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
212
|
+
const fetchImpl = buildFetchStub(
|
|
213
|
+
[
|
|
214
|
+
{
|
|
215
|
+
match: "getupdates",
|
|
216
|
+
respond: (idx) => {
|
|
217
|
+
if (idx === 0) {
|
|
218
|
+
return {
|
|
219
|
+
body: {
|
|
220
|
+
ret: 0,
|
|
221
|
+
get_updates_buf: "c",
|
|
222
|
+
msgs: [
|
|
223
|
+
{
|
|
224
|
+
message_type: 1,
|
|
225
|
+
from_user_id: "alice@im.wechat",
|
|
226
|
+
// no context_token
|
|
227
|
+
item_list: [{ type: 1, text_item: { text: "hi" } }],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
calls,
|
|
238
|
+
);
|
|
239
|
+
const adapter = createWechatChannel({
|
|
240
|
+
id: "gw_wx_2",
|
|
241
|
+
accountId: "ag_test",
|
|
242
|
+
botToken: "tok",
|
|
243
|
+
stateFile: path.join(tmp, "state.json"),
|
|
244
|
+
fetchImpl,
|
|
245
|
+
stateDebounceMs: 0,
|
|
246
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
247
|
+
});
|
|
248
|
+
const h = startAdapter(adapter, { stopAfterMs: 50 });
|
|
249
|
+
await h.pollDone;
|
|
250
|
+
expect(h.envelopes).toHaveLength(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("drops messages from senders not in allowedSenderIds (default-deny)", async () => {
|
|
254
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
255
|
+
const fetchImpl = buildFetchStub(
|
|
256
|
+
[
|
|
257
|
+
{
|
|
258
|
+
match: "getupdates",
|
|
259
|
+
respond: (idx) => {
|
|
260
|
+
if (idx === 0) {
|
|
261
|
+
return {
|
|
262
|
+
body: {
|
|
263
|
+
ret: 0,
|
|
264
|
+
get_updates_buf: "c",
|
|
265
|
+
msgs: [
|
|
266
|
+
{
|
|
267
|
+
message_type: 1,
|
|
268
|
+
from_user_id: "stranger@im.wechat",
|
|
269
|
+
context_token: "ctx",
|
|
270
|
+
item_list: [{ type: 1, text_item: { text: "hi" } }],
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
calls,
|
|
281
|
+
);
|
|
282
|
+
const adapter = createWechatChannel({
|
|
283
|
+
id: "gw_wx_3",
|
|
284
|
+
accountId: "ag_test",
|
|
285
|
+
botToken: "tok",
|
|
286
|
+
stateFile: path.join(tmp, "state.json"),
|
|
287
|
+
fetchImpl,
|
|
288
|
+
stateDebounceMs: 0,
|
|
289
|
+
// empty allowlist -> default deny
|
|
290
|
+
allowedSenderIds: [],
|
|
291
|
+
});
|
|
292
|
+
const h = startAdapter(adapter, { stopAfterMs: 50 });
|
|
293
|
+
await h.pollDone;
|
|
294
|
+
expect(h.envelopes).toHaveLength(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("send() echoes the inbound context_token bound to the trace id", async () => {
|
|
298
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
299
|
+
const fetchImpl = buildFetchStub(
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
match: "getupdates",
|
|
303
|
+
respond: (idx) => {
|
|
304
|
+
if (idx === 0) {
|
|
305
|
+
return {
|
|
306
|
+
body: {
|
|
307
|
+
ret: 0,
|
|
308
|
+
get_updates_buf: "c",
|
|
309
|
+
msgs: [
|
|
310
|
+
{
|
|
311
|
+
message_type: 1,
|
|
312
|
+
from_user_id: "alice@im.wechat",
|
|
313
|
+
context_token: "ctx-XYZ",
|
|
314
|
+
item_list: [{ type: 1, text_item: { text: "ping" } }],
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
match: "sendmessage",
|
|
325
|
+
respond: () => ({ body: { ret: 0 } }),
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
calls,
|
|
329
|
+
);
|
|
330
|
+
const adapter = createWechatChannel({
|
|
331
|
+
id: "gw_wx_send",
|
|
332
|
+
accountId: "ag_test",
|
|
333
|
+
botToken: "tok",
|
|
334
|
+
stateFile: path.join(tmp, "state.json"),
|
|
335
|
+
fetchImpl,
|
|
336
|
+
stateDebounceMs: 0,
|
|
337
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
338
|
+
});
|
|
339
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
340
|
+
await h.pollDone;
|
|
341
|
+
const trace = h.envelopes[0]!.message.trace!;
|
|
342
|
+
const sendResult = await adapter.send({
|
|
343
|
+
log: SILENT_LOG,
|
|
344
|
+
message: {
|
|
345
|
+
channel: "gw_wx_send",
|
|
346
|
+
accountId: "ag_test",
|
|
347
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
348
|
+
text: "pong",
|
|
349
|
+
traceId: trace.id,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
expect(sendResult.providerMessageId).toMatch(/^botcord-/);
|
|
353
|
+
const sendCall = calls.find((c) => c.url.includes("sendmessage"));
|
|
354
|
+
expect(sendCall).toBeDefined();
|
|
355
|
+
const msg = (sendCall!.body!.msg as Record<string, unknown>) ?? {};
|
|
356
|
+
expect(msg.context_token).toBe("ctx-XYZ");
|
|
357
|
+
expect(msg.to_user_id).toBe("alice@im.wechat");
|
|
358
|
+
expect(msg.message_type).toBe(2);
|
|
359
|
+
const itemList = msg.item_list as Array<Record<string, unknown>>;
|
|
360
|
+
expect((itemList[0]!.text_item as Record<string, unknown>).text).toBe("pong");
|
|
361
|
+
// base_info injected centrally
|
|
362
|
+
expect(sendCall!.body!.base_info).toEqual({ channel_version: "1.0.2" });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("send() rejects when traceId is missing or unknown — no conversation-level fallback", async () => {
|
|
366
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
367
|
+
const fetchImpl = buildFetchStub(
|
|
368
|
+
[
|
|
369
|
+
{
|
|
370
|
+
match: "sendmessage",
|
|
371
|
+
respond: () => ({ body: { ret: 0 } }),
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
calls,
|
|
375
|
+
);
|
|
376
|
+
const adapter = createWechatChannel({
|
|
377
|
+
id: "gw_wx_send2",
|
|
378
|
+
accountId: "ag_test",
|
|
379
|
+
botToken: "tok",
|
|
380
|
+
stateFile: path.join(tmp, "state.json"),
|
|
381
|
+
fetchImpl,
|
|
382
|
+
stateDebounceMs: 0,
|
|
383
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
384
|
+
});
|
|
385
|
+
await expect(
|
|
386
|
+
adapter.send({
|
|
387
|
+
log: SILENT_LOG,
|
|
388
|
+
message: {
|
|
389
|
+
channel: "gw_wx_send2",
|
|
390
|
+
accountId: "ag_test",
|
|
391
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
392
|
+
text: "should fail",
|
|
393
|
+
traceId: null,
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
).rejects.toThrow(/no context_token/);
|
|
397
|
+
await expect(
|
|
398
|
+
adapter.send({
|
|
399
|
+
log: SILENT_LOG,
|
|
400
|
+
message: {
|
|
401
|
+
channel: "gw_wx_send2",
|
|
402
|
+
accountId: "ag_test",
|
|
403
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
404
|
+
text: "still no",
|
|
405
|
+
traceId: "unknown-trace",
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
).rejects.toThrow(/no context_token/);
|
|
409
|
+
expect(calls.find((c) => c.url.includes("sendmessage"))).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("send() splits long replies into chunks <= splitAt, preferring newline boundaries", async () => {
|
|
413
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
414
|
+
const fetchImpl = buildFetchStub(
|
|
415
|
+
[
|
|
416
|
+
{
|
|
417
|
+
match: "getupdates",
|
|
418
|
+
respond: (idx) => {
|
|
419
|
+
if (idx === 0) {
|
|
420
|
+
return {
|
|
421
|
+
body: {
|
|
422
|
+
ret: 0,
|
|
423
|
+
get_updates_buf: "c",
|
|
424
|
+
msgs: [
|
|
425
|
+
{
|
|
426
|
+
message_type: 1,
|
|
427
|
+
from_user_id: "alice@im.wechat",
|
|
428
|
+
context_token: "ctx-1",
|
|
429
|
+
item_list: [{ type: 1, text_item: { text: "go" } }],
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{ match: "sendmessage", respond: () => ({ body: { ret: 0 } }) },
|
|
439
|
+
],
|
|
440
|
+
calls,
|
|
441
|
+
);
|
|
442
|
+
const adapter = createWechatChannel({
|
|
443
|
+
id: "gw_wx_split",
|
|
444
|
+
accountId: "ag_test",
|
|
445
|
+
botToken: "tok",
|
|
446
|
+
splitAt: 50,
|
|
447
|
+
stateFile: path.join(tmp, "state.json"),
|
|
448
|
+
fetchImpl,
|
|
449
|
+
stateDebounceMs: 0,
|
|
450
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
451
|
+
});
|
|
452
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
453
|
+
await h.pollDone;
|
|
454
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
455
|
+
|
|
456
|
+
const part1 = "a".repeat(40);
|
|
457
|
+
const part2 = "b".repeat(40);
|
|
458
|
+
const text = `${part1}\n${part2}`;
|
|
459
|
+
await adapter.send({
|
|
460
|
+
log: SILENT_LOG,
|
|
461
|
+
message: {
|
|
462
|
+
channel: "gw_wx_split",
|
|
463
|
+
accountId: "ag_test",
|
|
464
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
465
|
+
text,
|
|
466
|
+
traceId,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
const sendCalls = calls.filter((c) => c.url.includes("sendmessage"));
|
|
470
|
+
expect(sendCalls.length).toBeGreaterThanOrEqual(2);
|
|
471
|
+
for (const c of sendCalls) {
|
|
472
|
+
const m = c.body!.msg as Record<string, unknown>;
|
|
473
|
+
const items = m.item_list as Array<Record<string, unknown>>;
|
|
474
|
+
const t = (items[0]!.text_item as Record<string, unknown>).text as string;
|
|
475
|
+
expect(t.length).toBeLessThanOrEqual(50);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("typing() caches the typing_ticket from getconfig and reuses it on the next call", async () => {
|
|
480
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
481
|
+
let configCalls = 0;
|
|
482
|
+
const fetchImpl = buildFetchStub(
|
|
483
|
+
[
|
|
484
|
+
{
|
|
485
|
+
match: "getupdates",
|
|
486
|
+
respond: (idx) => {
|
|
487
|
+
if (idx === 0) {
|
|
488
|
+
return {
|
|
489
|
+
body: {
|
|
490
|
+
ret: 0,
|
|
491
|
+
get_updates_buf: "c",
|
|
492
|
+
msgs: [
|
|
493
|
+
{
|
|
494
|
+
message_type: 1,
|
|
495
|
+
from_user_id: "alice@im.wechat",
|
|
496
|
+
context_token: "ctx-1",
|
|
497
|
+
item_list: [{ type: 1, text_item: { text: "go" } }],
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
match: "getconfig",
|
|
508
|
+
respond: () => {
|
|
509
|
+
configCalls += 1;
|
|
510
|
+
return { body: { ret: 0, typing_ticket: "ticket-zzz" } };
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{ match: "sendtyping", respond: () => ({ body: { ret: 0 } }) },
|
|
514
|
+
],
|
|
515
|
+
calls,
|
|
516
|
+
);
|
|
517
|
+
const adapter = createWechatChannel({
|
|
518
|
+
id: "gw_wx_typing",
|
|
519
|
+
accountId: "ag_test",
|
|
520
|
+
botToken: "tok",
|
|
521
|
+
stateFile: path.join(tmp, "state.json"),
|
|
522
|
+
fetchImpl,
|
|
523
|
+
stateDebounceMs: 0,
|
|
524
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
525
|
+
});
|
|
526
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
527
|
+
await h.pollDone;
|
|
528
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
529
|
+
|
|
530
|
+
await adapter.typing!({
|
|
531
|
+
traceId,
|
|
532
|
+
accountId: "ag_test",
|
|
533
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
534
|
+
log: SILENT_LOG,
|
|
535
|
+
});
|
|
536
|
+
await adapter.typing!({
|
|
537
|
+
traceId,
|
|
538
|
+
accountId: "ag_test",
|
|
539
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
540
|
+
log: SILENT_LOG,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
expect(configCalls).toBe(1);
|
|
544
|
+
const sendTypingCalls = calls.filter((c) => c.url.includes("sendtyping"));
|
|
545
|
+
expect(sendTypingCalls.length).toBe(2);
|
|
546
|
+
for (const c of sendTypingCalls) {
|
|
547
|
+
expect(c.body!.typing_ticket).toBe("ticket-zzz");
|
|
548
|
+
expect(c.body!.ilink_user_id).toBe("alice@im.wechat");
|
|
549
|
+
expect(c.body!.status).toBe(1);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("sets provider=wechat and updates lastPollAt / lastInboundAt / lastSendAt / authorized in status", async () => {
|
|
554
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
555
|
+
const fetchImpl = buildFetchStub(
|
|
556
|
+
[
|
|
557
|
+
{
|
|
558
|
+
match: "getupdates",
|
|
559
|
+
respond: (idx) => {
|
|
560
|
+
if (idx === 0) {
|
|
561
|
+
return {
|
|
562
|
+
body: {
|
|
563
|
+
ret: 0,
|
|
564
|
+
get_updates_buf: "c",
|
|
565
|
+
msgs: [
|
|
566
|
+
{
|
|
567
|
+
message_type: 1,
|
|
568
|
+
from_user_id: "alice@im.wechat",
|
|
569
|
+
context_token: "ctx-1",
|
|
570
|
+
item_list: [{ type: 1, text_item: { text: "hi" } }],
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
{ match: "sendmessage", respond: () => ({ body: { ret: 0 } }) },
|
|
580
|
+
],
|
|
581
|
+
calls,
|
|
582
|
+
);
|
|
583
|
+
const adapter = createWechatChannel({
|
|
584
|
+
id: "gw_wx_status",
|
|
585
|
+
accountId: "ag_test",
|
|
586
|
+
botToken: "tok",
|
|
587
|
+
stateFile: path.join(tmp, "state.json"),
|
|
588
|
+
fetchImpl,
|
|
589
|
+
stateDebounceMs: 0,
|
|
590
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
591
|
+
});
|
|
592
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
593
|
+
await h.pollDone;
|
|
594
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
595
|
+
await adapter.send({
|
|
596
|
+
log: SILENT_LOG,
|
|
597
|
+
message: {
|
|
598
|
+
channel: "gw_wx_status",
|
|
599
|
+
accountId: "ag_test",
|
|
600
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
601
|
+
text: "ack",
|
|
602
|
+
traceId,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
const snap = adapter.status!();
|
|
606
|
+
expect(snap.provider).toBe("wechat");
|
|
607
|
+
expect(snap.authorized).toBe(true);
|
|
608
|
+
expect(typeof snap.lastPollAt).toBe("number");
|
|
609
|
+
expect(typeof snap.lastInboundAt).toBe("number");
|
|
610
|
+
expect(typeof snap.lastSendAt).toBe("number");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("send() honors a 30-minute trace TTL — expired traces are rejected", async () => {
|
|
614
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
615
|
+
let nowMs = 1_000_000;
|
|
616
|
+
const fetchImpl = buildFetchStub(
|
|
617
|
+
[
|
|
618
|
+
{
|
|
619
|
+
match: "getupdates",
|
|
620
|
+
respond: (idx) => {
|
|
621
|
+
if (idx === 0) {
|
|
622
|
+
return {
|
|
623
|
+
body: {
|
|
624
|
+
ret: 0,
|
|
625
|
+
get_updates_buf: "c",
|
|
626
|
+
msgs: [
|
|
627
|
+
{
|
|
628
|
+
message_type: 1,
|
|
629
|
+
from_user_id: "alice@im.wechat",
|
|
630
|
+
context_token: "ctx",
|
|
631
|
+
item_list: [{ type: 1, text_item: { text: "hi" } }],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
calls,
|
|
642
|
+
);
|
|
643
|
+
const adapter = createWechatChannel({
|
|
644
|
+
id: "gw_wx_ttl",
|
|
645
|
+
accountId: "ag_test",
|
|
646
|
+
botToken: "tok",
|
|
647
|
+
stateFile: path.join(tmp, "state.json"),
|
|
648
|
+
fetchImpl,
|
|
649
|
+
stateDebounceMs: 0,
|
|
650
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
651
|
+
now: () => nowMs,
|
|
652
|
+
});
|
|
653
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
654
|
+
await h.pollDone;
|
|
655
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
656
|
+
// Advance well past the 30-minute TTL.
|
|
657
|
+
nowMs += 31 * 60 * 1000;
|
|
658
|
+
await expect(
|
|
659
|
+
adapter.send({
|
|
660
|
+
log: SILENT_LOG,
|
|
661
|
+
message: {
|
|
662
|
+
channel: "gw_wx_ttl",
|
|
663
|
+
accountId: "ag_test",
|
|
664
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
665
|
+
text: "late",
|
|
666
|
+
traceId,
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
).rejects.toThrow(/no context_token/);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe("W2: state.update is gated on cursor change", () => {
|
|
674
|
+
let tmpW2: string;
|
|
675
|
+
beforeEach(() => {
|
|
676
|
+
tmpW2 = mkdtempSync(path.join(tmpdir(), "wechat-ch-w2-"));
|
|
677
|
+
});
|
|
678
|
+
afterEach(() => {
|
|
679
|
+
rmSync(tmpW2, { recursive: true, force: true });
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("3 polls returning the same get_updates_buf cause exactly 1 state write", async () => {
|
|
683
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
684
|
+
let pollCount = 0;
|
|
685
|
+
const fetchImpl = buildFetchStub(
|
|
686
|
+
[
|
|
687
|
+
{
|
|
688
|
+
match: "getupdates",
|
|
689
|
+
respond: () => {
|
|
690
|
+
pollCount += 1;
|
|
691
|
+
return { body: { ret: 0, get_updates_buf: "same-cursor", msgs: [] } };
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
],
|
|
695
|
+
calls,
|
|
696
|
+
);
|
|
697
|
+
// Spy on GatewayStateStore.update via the prototype.
|
|
698
|
+
const stateMod = await import("../gateway/channels/state-store.js");
|
|
699
|
+
const updateSpy = vi.spyOn(stateMod.GatewayStateStore.prototype, "update");
|
|
700
|
+
const adapter = createWechatChannel({
|
|
701
|
+
id: "gw_wx_w2",
|
|
702
|
+
accountId: "ag_test",
|
|
703
|
+
botToken: "tok",
|
|
704
|
+
stateFile: path.join(tmpW2, "state.json"),
|
|
705
|
+
fetchImpl,
|
|
706
|
+
stateDebounceMs: 0,
|
|
707
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
708
|
+
});
|
|
709
|
+
const h = startAdapter(adapter, { stopAfterMs: 80 });
|
|
710
|
+
await h.pollDone;
|
|
711
|
+
expect(pollCount).toBeGreaterThanOrEqual(3);
|
|
712
|
+
// Cursor never changed -> at most one update call (the first poll
|
|
713
|
+
// observes "" -> "same-cursor"; subsequent polls observe no change).
|
|
714
|
+
expect(updateSpy.mock.calls.length).toBe(1);
|
|
715
|
+
updateSpy.mockRestore();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe("C2: callApi enforces timeoutMs via AbortSignal", () => {
|
|
720
|
+
let tmp2: string;
|
|
721
|
+
beforeEach(() => {
|
|
722
|
+
tmp2 = mkdtempSync(path.join(tmpdir(), "wechat-ch-c2-"));
|
|
723
|
+
});
|
|
724
|
+
afterEach(() => {
|
|
725
|
+
rmSync(tmp2, { recursive: true, force: true });
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("send() rejects with AbortError shape when fetch hangs past timeout", async () => {
|
|
729
|
+
// Build a fetch stub for the inbound side (fast) plus a hanging
|
|
730
|
+
// sendmessage that respects the AbortSignal so we can assert the timeout.
|
|
731
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
732
|
+
const fetchImpl: FetchLike = async (url, init) => {
|
|
733
|
+
let parsed: Record<string, unknown> | null = null;
|
|
734
|
+
if (init?.body) {
|
|
735
|
+
try {
|
|
736
|
+
parsed = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
737
|
+
} catch {
|
|
738
|
+
parsed = null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
calls.push({ url, body: parsed });
|
|
742
|
+
if (url.includes("getupdates")) {
|
|
743
|
+
const idx = calls.filter((c) => c.url.includes("getupdates")).length - 1;
|
|
744
|
+
if (idx === 0) {
|
|
745
|
+
return {
|
|
746
|
+
status: 200,
|
|
747
|
+
ok: true,
|
|
748
|
+
text: async () =>
|
|
749
|
+
JSON.stringify({
|
|
750
|
+
ret: 0,
|
|
751
|
+
get_updates_buf: "c-c2",
|
|
752
|
+
msgs: [
|
|
753
|
+
{
|
|
754
|
+
message_type: 1,
|
|
755
|
+
from_user_id: "alice@im.wechat",
|
|
756
|
+
context_token: "ctx-c2",
|
|
757
|
+
item_list: [{ type: 1, text_item: { text: "ping" } }],
|
|
758
|
+
},
|
|
759
|
+
],
|
|
760
|
+
}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return { status: 200, ok: true, text: async () => JSON.stringify({ ret: 0, get_updates_buf: "c-c2", msgs: [] }) };
|
|
764
|
+
}
|
|
765
|
+
// sendmessage: hang until the AbortSignal fires.
|
|
766
|
+
const signal = (init as unknown as { signal?: AbortSignal }).signal;
|
|
767
|
+
return await new Promise((_resolve, reject) => {
|
|
768
|
+
if (signal?.aborted) {
|
|
769
|
+
const e = new Error("aborted");
|
|
770
|
+
e.name = "AbortError";
|
|
771
|
+
reject(e);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
signal?.addEventListener("abort", () => {
|
|
775
|
+
const e = new Error("aborted");
|
|
776
|
+
// AbortSignal.timeout produces a TimeoutError; either name is acceptable.
|
|
777
|
+
e.name = signal.reason instanceof Error ? signal.reason.name : "AbortError";
|
|
778
|
+
reject(e);
|
|
779
|
+
});
|
|
780
|
+
// Never resolve otherwise.
|
|
781
|
+
});
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
const adapter = createWechatChannel({
|
|
785
|
+
id: "gw_wx_c2",
|
|
786
|
+
accountId: "ag_test",
|
|
787
|
+
botToken: "tok",
|
|
788
|
+
stateFile: path.join(tmp2, "state.json"),
|
|
789
|
+
fetchImpl,
|
|
790
|
+
stateDebounceMs: 0,
|
|
791
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
792
|
+
});
|
|
793
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
794
|
+
await h.pollDone;
|
|
795
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
796
|
+
|
|
797
|
+
// Patch the adapter to use a tiny timeout so we don't wait 15s. We do
|
|
798
|
+
// this by re-creating it with the same fetch — but since `splitAt` and
|
|
799
|
+
// friends are constants in callApi, the only handle we have is to wait
|
|
800
|
+
// for the real timeout. Instead, force an early abort using
|
|
801
|
+
// AbortSignal.timeout(50ms) by monkey-patching the global once.
|
|
802
|
+
const realTimeout = AbortSignal.timeout;
|
|
803
|
+
let observed = 0;
|
|
804
|
+
AbortSignal.timeout = ((ms: number) => {
|
|
805
|
+
observed = ms;
|
|
806
|
+
// Always return a 50ms timeout so the test runs fast.
|
|
807
|
+
return realTimeout(50);
|
|
808
|
+
}) as typeof AbortSignal.timeout;
|
|
809
|
+
try {
|
|
810
|
+
await expect(
|
|
811
|
+
adapter.send({
|
|
812
|
+
log: SILENT_LOG,
|
|
813
|
+
message: {
|
|
814
|
+
channel: "gw_wx_c2",
|
|
815
|
+
accountId: "ag_test",
|
|
816
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
817
|
+
text: "pong",
|
|
818
|
+
traceId,
|
|
819
|
+
},
|
|
820
|
+
}),
|
|
821
|
+
).rejects.toMatchObject({ name: expect.stringMatching(/AbortError|TimeoutError/) });
|
|
822
|
+
expect(observed).toBeGreaterThan(0);
|
|
823
|
+
} finally {
|
|
824
|
+
AbortSignal.timeout = realTimeout;
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
describe("W4: cursor unchanged when emit throws", () => {
|
|
830
|
+
let tmpW4: string;
|
|
831
|
+
beforeEach(() => {
|
|
832
|
+
tmpW4 = mkdtempSync(path.join(tmpdir(), "wechat-ch-w4-"));
|
|
833
|
+
});
|
|
834
|
+
afterEach(() => {
|
|
835
|
+
rmSync(tmpW4, { recursive: true, force: true });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("leaves get_updates_buf untouched when emit() throws on the first batch", async () => {
|
|
839
|
+
const stateFile = path.join(tmpW4, "state.json");
|
|
840
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
841
|
+
let pollIdx = 0;
|
|
842
|
+
const fetchImpl = buildFetchStub(
|
|
843
|
+
[
|
|
844
|
+
{
|
|
845
|
+
match: "getupdates",
|
|
846
|
+
respond: () => {
|
|
847
|
+
const idx = pollIdx;
|
|
848
|
+
pollIdx += 1;
|
|
849
|
+
// First poll delivers the message; later polls return empty so
|
|
850
|
+
// the loop keeps spinning until the test aborts.
|
|
851
|
+
if (idx === 0) {
|
|
852
|
+
return {
|
|
853
|
+
body: {
|
|
854
|
+
ret: 0,
|
|
855
|
+
get_updates_buf: "after-emit-fail",
|
|
856
|
+
msgs: [
|
|
857
|
+
{
|
|
858
|
+
message_type: 1,
|
|
859
|
+
from_user_id: "alice@im.wechat",
|
|
860
|
+
context_token: "ctx",
|
|
861
|
+
item_list: [{ type: 1, text_item: { text: "hi" } }],
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
return { body: { ret: 0, get_updates_buf: "after-emit-fail", msgs: [] } };
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
],
|
|
871
|
+
calls,
|
|
872
|
+
);
|
|
873
|
+
const adapter = createWechatChannel({
|
|
874
|
+
id: "gw_wx_w4",
|
|
875
|
+
accountId: "ag_test",
|
|
876
|
+
botToken: "tok",
|
|
877
|
+
stateFile,
|
|
878
|
+
fetchImpl,
|
|
879
|
+
stateDebounceMs: 0,
|
|
880
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
881
|
+
});
|
|
882
|
+
const ctrl = new AbortController();
|
|
883
|
+
let emitCalls = 0;
|
|
884
|
+
const ctx: ChannelStartContext = {
|
|
885
|
+
config: { channels: [], defaultRoute: { runtime: "claude-code", cwd: "/tmp" } },
|
|
886
|
+
accountId: "ag_test",
|
|
887
|
+
abortSignal: ctrl.signal,
|
|
888
|
+
log: SILENT_LOG,
|
|
889
|
+
emit: async () => {
|
|
890
|
+
emitCalls += 1;
|
|
891
|
+
ctrl.abort(); // exit the loop after the first failed emit
|
|
892
|
+
throw new Error("emit boom");
|
|
893
|
+
},
|
|
894
|
+
setStatus: () => {},
|
|
895
|
+
};
|
|
896
|
+
await adapter.start(ctx);
|
|
897
|
+
expect(emitCalls).toBeGreaterThanOrEqual(1);
|
|
898
|
+
// Either the state file does not exist, or its cursor is NOT the
|
|
899
|
+
// post-emit value — proving the failed batch will retry.
|
|
900
|
+
let cursor: string | undefined;
|
|
901
|
+
try {
|
|
902
|
+
const raw = (await import("node:fs")).readFileSync(stateFile, "utf8");
|
|
903
|
+
cursor = JSON.parse(raw).cursor;
|
|
904
|
+
} catch {
|
|
905
|
+
cursor = undefined;
|
|
906
|
+
}
|
|
907
|
+
expect(cursor).not.toBe("after-emit-fail");
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
describe("W3: authorized stays false until first ret===0 poll", () => {
|
|
912
|
+
let tmpW3: string;
|
|
913
|
+
beforeEach(() => {
|
|
914
|
+
tmpW3 = mkdtempSync(path.join(tmpdir(), "wechat-ch-w3-"));
|
|
915
|
+
});
|
|
916
|
+
afterEach(() => {
|
|
917
|
+
rmSync(tmpW3, { recursive: true, force: true });
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("does NOT mark authorized=true before the first successful getupdates", async () => {
|
|
921
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
922
|
+
const fetchImpl = buildFetchStub(
|
|
923
|
+
[
|
|
924
|
+
{
|
|
925
|
+
match: "getupdates",
|
|
926
|
+
respond: () => ({ body: { ret: 0, get_updates_buf: "c", msgs: [] } }),
|
|
927
|
+
},
|
|
928
|
+
],
|
|
929
|
+
calls,
|
|
930
|
+
);
|
|
931
|
+
const adapter = createWechatChannel({
|
|
932
|
+
id: "gw_wx_w3",
|
|
933
|
+
accountId: "ag_test",
|
|
934
|
+
botToken: "tok",
|
|
935
|
+
stateFile: path.join(tmpW3, "state.json"),
|
|
936
|
+
fetchImpl,
|
|
937
|
+
stateDebounceMs: 0,
|
|
938
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
939
|
+
});
|
|
940
|
+
const h = startAdapter(adapter, { stopAfterMs: 80 });
|
|
941
|
+
await h.pollDone;
|
|
942
|
+
// Find the very first patch that reported `running: true` — at that
|
|
943
|
+
// moment authorized must still be false.
|
|
944
|
+
const startupPatch = h.statusPatches.find((p) => p.running === true);
|
|
945
|
+
expect(startupPatch).toBeDefined();
|
|
946
|
+
expect(startupPatch!.authorized).toBe(false);
|
|
947
|
+
// After at least one successful poll, authorized should have flipped true.
|
|
948
|
+
const promotion = h.statusPatches.find((p) => p.authorized === true);
|
|
949
|
+
expect(promotion).toBeDefined();
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// vi import kept available for future per-test mocking; nothing to do here.
|
|
954
|
+
void vi;
|
|
955
|
+
|
|
956
|
+
describe("W1: traceContexts hard cap", () => {
|
|
957
|
+
let tmpCap: string;
|
|
958
|
+
beforeEach(() => {
|
|
959
|
+
tmpCap = mkdtempSync(path.join(tmpdir(), "wechat-ch-cap-"));
|
|
960
|
+
});
|
|
961
|
+
afterEach(() => {
|
|
962
|
+
rmSync(tmpCap, { recursive: true, force: true });
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("inserting 5001 entries keeps the map at <= 5000 (oldest pruned)", async () => {
|
|
966
|
+
// Build an adapter with a fake clock so we can control updatedAt order.
|
|
967
|
+
let nowMs = 1_000_000;
|
|
968
|
+
const fetchImpl = buildFetchStub(
|
|
969
|
+
[
|
|
970
|
+
{
|
|
971
|
+
match: "getupdates",
|
|
972
|
+
respond: (idx) => {
|
|
973
|
+
if (idx < 5001) {
|
|
974
|
+
// Each poll returns one message so we get 5001 trace entries.
|
|
975
|
+
nowMs += 1;
|
|
976
|
+
return {
|
|
977
|
+
body: {
|
|
978
|
+
ret: 0,
|
|
979
|
+
get_updates_buf: `buf-${idx}`,
|
|
980
|
+
msgs: [
|
|
981
|
+
{
|
|
982
|
+
message_type: 1,
|
|
983
|
+
from_user_id: "alice@im.wechat",
|
|
984
|
+
context_token: `ctx-${idx}`,
|
|
985
|
+
item_list: [{ type: 1, text_item: { text: `msg-${idx}` } }],
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return { body: { ret: 0, get_updates_buf: `buf-5001`, msgs: [] } };
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
[],
|
|
996
|
+
);
|
|
997
|
+
const adapter = createWechatChannel({
|
|
998
|
+
id: "gw_wx_cap",
|
|
999
|
+
accountId: "ag_test",
|
|
1000
|
+
botToken: "tok",
|
|
1001
|
+
stateFile: path.join(tmpCap, "state.json"),
|
|
1002
|
+
fetchImpl,
|
|
1003
|
+
stateDebounceMs: 0,
|
|
1004
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
1005
|
+
now: () => nowMs,
|
|
1006
|
+
});
|
|
1007
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 5001 });
|
|
1008
|
+
await h.pollDone;
|
|
1009
|
+
// 5001 messages were accepted; the cap should have kept the map <= 5000.
|
|
1010
|
+
expect(h.envelopes.length).toBe(5001);
|
|
1011
|
+
// We can't read traceContexts directly, but we verify that the send() for
|
|
1012
|
+
// the very first trace ID now fails (it was evicted as the oldest entry).
|
|
1013
|
+
const firstTraceId = h.envelopes[0]!.message.trace!.id;
|
|
1014
|
+
await expect(
|
|
1015
|
+
adapter.send({
|
|
1016
|
+
log: SILENT_LOG,
|
|
1017
|
+
message: {
|
|
1018
|
+
channel: "gw_wx_cap",
|
|
1019
|
+
accountId: "ag_test",
|
|
1020
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
1021
|
+
text: "evicted",
|
|
1022
|
+
traceId: firstTraceId,
|
|
1023
|
+
},
|
|
1024
|
+
}),
|
|
1025
|
+
).rejects.toThrow(/no context_token/);
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
describe("W2: started guard", () => {
|
|
1030
|
+
let tmpG: string;
|
|
1031
|
+
beforeEach(() => {
|
|
1032
|
+
tmpG = mkdtempSync(path.join(tmpdir(), "wechat-ch-guard-"));
|
|
1033
|
+
});
|
|
1034
|
+
afterEach(() => {
|
|
1035
|
+
rmSync(tmpG, { recursive: true, force: true });
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it("calling start() a second time throws 'already started'", async () => {
|
|
1039
|
+
const fetchImpl = buildFetchStub(
|
|
1040
|
+
[
|
|
1041
|
+
{ match: "getupdates", respond: () => ({ body: { ret: 0, get_updates_buf: "c", msgs: [] } }) },
|
|
1042
|
+
],
|
|
1043
|
+
[],
|
|
1044
|
+
);
|
|
1045
|
+
const adapter = createWechatChannel({
|
|
1046
|
+
id: "gw_wx_guard",
|
|
1047
|
+
accountId: "ag_test",
|
|
1048
|
+
botToken: "tok",
|
|
1049
|
+
stateFile: path.join(tmpG, "state.json"),
|
|
1050
|
+
fetchImpl,
|
|
1051
|
+
stateDebounceMs: 0,
|
|
1052
|
+
allowedSenderIds: [],
|
|
1053
|
+
});
|
|
1054
|
+
const h = startAdapter(adapter, { stopAfterMs: 30 });
|
|
1055
|
+
// Second start() before the first finishes must throw.
|
|
1056
|
+
await expect(
|
|
1057
|
+
adapter.start({
|
|
1058
|
+
config: { channels: [], defaultRoute: { runtime: "claude-code", cwd: "/tmp" } },
|
|
1059
|
+
accountId: "ag_test",
|
|
1060
|
+
abortSignal: new AbortController().signal,
|
|
1061
|
+
log: SILENT_LOG,
|
|
1062
|
+
emit: async () => {},
|
|
1063
|
+
setStatus: () => {},
|
|
1064
|
+
}),
|
|
1065
|
+
).rejects.toThrow("already started");
|
|
1066
|
+
await h.pollDone;
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
describe("W3: bot token redaction in lastError and logs", () => {
|
|
1071
|
+
let tmp: string;
|
|
1072
|
+
|
|
1073
|
+
beforeEach(() => {
|
|
1074
|
+
tmp = mkdtempSync(path.join(tmpdir(), "wechat-redact-"));
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
afterEach(() => {
|
|
1078
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("does not expose the bot token in lastError or log.error when poll fails", async () => {
|
|
1082
|
+
const SECRET_TOKEN = "SUPER_SECRET_BOT_TOKEN_12345";
|
|
1083
|
+
const capturedLogs: string[] = [];
|
|
1084
|
+
const log = {
|
|
1085
|
+
info: () => {},
|
|
1086
|
+
warn: () => {},
|
|
1087
|
+
error: (_msg: string, meta?: Record<string, unknown>) => {
|
|
1088
|
+
capturedLogs.push(JSON.stringify(meta ?? {}));
|
|
1089
|
+
},
|
|
1090
|
+
debug: () => {},
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const fetchImpl: typeof globalThis.fetch = async () => {
|
|
1094
|
+
// Simulate an error that includes the bot token in its message.
|
|
1095
|
+
throw new Error(`fetch failed: auth=${SECRET_TOKEN}`);
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const adapter = createWechatChannel({
|
|
1099
|
+
id: "gw_wx_redact",
|
|
1100
|
+
accountId: "ag_test",
|
|
1101
|
+
botToken: SECRET_TOKEN,
|
|
1102
|
+
stateFile: path.join(tmp, "state.json"),
|
|
1103
|
+
fetchImpl: fetchImpl as unknown as Parameters<typeof createWechatChannel>[0]["fetchImpl"],
|
|
1104
|
+
stateDebounceMs: 0,
|
|
1105
|
+
allowedSenderIds: ["user1"],
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const statusPatches: Array<Record<string, unknown>> = [];
|
|
1109
|
+
const ctrl = new AbortController();
|
|
1110
|
+
const pollDone = adapter.start({
|
|
1111
|
+
config: { channels: [], defaultRoute: { runtime: "claude-code", cwd: "/tmp" } },
|
|
1112
|
+
accountId: "ag_test",
|
|
1113
|
+
abortSignal: ctrl.signal,
|
|
1114
|
+
log,
|
|
1115
|
+
emit: async () => {},
|
|
1116
|
+
setStatus: (patch) => {
|
|
1117
|
+
statusPatches.push({ ...patch });
|
|
1118
|
+
// Stop after first error patch so test doesn't loop.
|
|
1119
|
+
if (patch.lastError) ctrl.abort();
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
await pollDone;
|
|
1123
|
+
|
|
1124
|
+
// lastError in status patches must not contain the token.
|
|
1125
|
+
const errorPatch = statusPatches.find((p) => typeof p.lastError === "string");
|
|
1126
|
+
expect(errorPatch).toBeDefined();
|
|
1127
|
+
expect(errorPatch!.lastError).not.toContain(SECRET_TOKEN);
|
|
1128
|
+
expect(errorPatch!.lastError).toContain("[REDACTED]");
|
|
1129
|
+
|
|
1130
|
+
// log.error output must not contain the token.
|
|
1131
|
+
const allLogs = capturedLogs.join("\n");
|
|
1132
|
+
expect(allLogs).not.toContain(SECRET_TOKEN);
|
|
1133
|
+
});
|
|
1134
|
+
});
|