@yaebal/panel 0.0.1 → 0.0.2
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/README.md +136 -5
- package/lib/index.d.ts +90 -7
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +378 -28
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +322 -1
- package/lib/index.test.js.map +1 -1
- package/lib/panel-html.d.ts +2 -2
- package/lib/panel-html.d.ts.map +1 -1
- package/lib/panel-html.js +185 -22
- package/lib/panel-html.js.map +1 -1
- package/lib/serve.d.ts +25 -0
- package/lib/serve.d.ts.map +1 -0
- package/lib/serve.js +47 -0
- package/lib/serve.js.map +1 -0
- package/lib/sqlite.d.ts +32 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +97 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/sqlite.test.d.ts +2 -0
- package/lib/sqlite.test.d.ts.map +1 -0
- package/lib/sqlite.test.js +42 -0
- package/lib/sqlite.test.js.map +1 -0
- package/package.json +9 -1
- package/src/index.test.ts +413 -1
- package/src/index.ts +494 -41
- package/src/panel-html.ts +185 -22
- package/src/serve.ts +65 -0
- package/src/sqlite.test.ts +58 -0
- package/src/sqlite.ts +140 -0
package/src/index.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { Composer, Context, type Middleware } from "@yaebal/core";
|
|
4
|
-
import { MemoryPanelStore, panelHandler, recorder } from "./index.js";
|
|
4
|
+
import { MemoryPanelStore, panelHandler, recordOutgoing, recorder } from "./index.js";
|
|
5
5
|
|
|
6
6
|
const noop = async () => {};
|
|
7
7
|
const entry = <C extends Context>(c: Composer<C>) =>
|
|
@@ -113,3 +113,415 @@ test("panel send posts via the api and logs an outgoing message", async () => {
|
|
|
113
113
|
assert.equal(last?.direction, "out");
|
|
114
114
|
assert.equal(last?.text, "yo");
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
test("recorder records a media placeholder when a private message carries no text", async () => {
|
|
118
|
+
const store = new MemoryPanelStore();
|
|
119
|
+
const api = {} as never;
|
|
120
|
+
const mw = entry(new Composer<Context>().install(recorder(store)));
|
|
121
|
+
|
|
122
|
+
const ctx = new Context({
|
|
123
|
+
api,
|
|
124
|
+
update: {
|
|
125
|
+
update_id: 1,
|
|
126
|
+
message: {
|
|
127
|
+
message_id: 1,
|
|
128
|
+
date: 0,
|
|
129
|
+
chat: { id: 7, type: "private" },
|
|
130
|
+
from: { id: 7, is_bot: false, first_name: "Pat" },
|
|
131
|
+
photo: [{ file_id: "x", file_unique_id: "x", width: 1, height: 1 }],
|
|
132
|
+
},
|
|
133
|
+
} as never,
|
|
134
|
+
updateType: "message",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await mw(ctx, noop);
|
|
138
|
+
|
|
139
|
+
assert.equal(store.history(7)[0]?.text, "[photo]");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("recorder captures a caption as the message text", async () => {
|
|
143
|
+
const store = new MemoryPanelStore();
|
|
144
|
+
const api = {} as never;
|
|
145
|
+
const mw = entry(new Composer<Context>().install(recorder(store)));
|
|
146
|
+
|
|
147
|
+
const ctx = new Context({
|
|
148
|
+
api,
|
|
149
|
+
update: {
|
|
150
|
+
update_id: 1,
|
|
151
|
+
message: {
|
|
152
|
+
message_id: 1,
|
|
153
|
+
date: 0,
|
|
154
|
+
chat: { id: 8, type: "private" },
|
|
155
|
+
from: { id: 8, is_bot: false, first_name: "Lee" },
|
|
156
|
+
document: { file_id: "d", file_unique_id: "d" },
|
|
157
|
+
caption: "the report",
|
|
158
|
+
},
|
|
159
|
+
} as never,
|
|
160
|
+
updateType: "message",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await mw(ctx, noop);
|
|
164
|
+
|
|
165
|
+
assert.equal(store.history(8)[0]?.text, "the report");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("panel send rejects an empty/missing text with 400", async () => {
|
|
169
|
+
const { handler, sent } = fakePanel();
|
|
170
|
+
|
|
171
|
+
const res = await handler(
|
|
172
|
+
new Request("http://x/api/chats/1/send?token=secret", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({}),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
assert.equal(res.status, 400);
|
|
180
|
+
assert.deepEqual(sent, []);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("panel send forwards whitelisted extras (parse_mode) to sendMessage", async () => {
|
|
184
|
+
const { handler, sent } = fakePanel();
|
|
185
|
+
|
|
186
|
+
await handler(
|
|
187
|
+
new Request("http://x/api/chats/1/send?token=secret", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ text: "*hi*", parse_mode: "MarkdownV2", bogus: 1 }),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(sent, [{ chat_id: 1, text: "*hi*", parse_mode: "MarkdownV2" }]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("panel API returns 404 for an unknown path", async () => {
|
|
198
|
+
const { handler } = fakePanel();
|
|
199
|
+
|
|
200
|
+
const res = await handler(
|
|
201
|
+
new Request("http://x/api/nope", { headers: { authorization: "Bearer secret" } }),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
assert.equal(res.status, 404);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("CORS: preflight is answered unauthenticated and echoes an allowed origin", async () => {
|
|
208
|
+
const store = new MemoryPanelStore();
|
|
209
|
+
const api = { sendMessage: () => Promise.resolve({}) };
|
|
210
|
+
const handler = panelHandler(api, store, { token: "secret", cors: "https://ops.example" });
|
|
211
|
+
|
|
212
|
+
const res = await handler(
|
|
213
|
+
new Request("http://x/api/chats", {
|
|
214
|
+
method: "OPTIONS",
|
|
215
|
+
headers: { origin: "https://ops.example" },
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
assert.equal(res.status, 204);
|
|
220
|
+
assert.equal(res.headers.get("access-control-allow-origin"), "https://ops.example");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("CORS: a disallowed origin gets no allow-origin header", async () => {
|
|
224
|
+
const store = new MemoryPanelStore();
|
|
225
|
+
const api = { sendMessage: () => Promise.resolve({}) };
|
|
226
|
+
const handler = panelHandler(api, store, { token: "secret", cors: "https://ops.example" });
|
|
227
|
+
|
|
228
|
+
const res = await handler(
|
|
229
|
+
new Request("http://x/api/chats?token=secret", {
|
|
230
|
+
headers: { origin: "https://evil.example" },
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
assert.equal(res.headers.get("access-control-allow-origin"), null);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("history pagination: limit returns the most recent N, before pages older", () => {
|
|
238
|
+
const s = new MemoryPanelStore();
|
|
239
|
+
for (let i = 1; i <= 5; i++) {
|
|
240
|
+
s.record({ id: 1, name: "@u" }, { direction: "in", text: `m${i}`, date: i });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const recent = s.history(1, { limit: 2 });
|
|
244
|
+
assert.deepEqual(
|
|
245
|
+
recent.map((m) => m.text),
|
|
246
|
+
["m4", "m5"],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const older = s.history(1, { before: 4, limit: 2 });
|
|
250
|
+
assert.deepEqual(
|
|
251
|
+
older.map((m) => m.text),
|
|
252
|
+
["m2", "m3"],
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("MemoryPanelStore.subscribe emits a record event and unsubscribes", () => {
|
|
257
|
+
const s = new MemoryPanelStore();
|
|
258
|
+
const seen: string[] = [];
|
|
259
|
+
const off = s.subscribe((e) => seen.push(`${e.type}:${e.chatId}:${e.direction}`));
|
|
260
|
+
|
|
261
|
+
s.record({ id: 9, name: "@u" }, { direction: "in", text: "hi", date: 1 });
|
|
262
|
+
off();
|
|
263
|
+
s.record({ id: 9, name: "@u" }, { direction: "in", text: "bye", date: 2 });
|
|
264
|
+
|
|
265
|
+
assert.deepEqual(seen, ["record:9:in"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("panel serves the login page at the root WITHOUT a token", async () => {
|
|
269
|
+
const { handler } = fakePanel();
|
|
270
|
+
|
|
271
|
+
const res = await handler(new Request("http://x/"));
|
|
272
|
+
|
|
273
|
+
assert.equal(res.status, 200);
|
|
274
|
+
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
275
|
+
const html = await res.text();
|
|
276
|
+
assert.match(html, /authorize/);
|
|
277
|
+
assert.doesNotMatch(html, /__BASE__/); // placeholder was substituted
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("basePath: routes are matched under the prefix and the UI base is injected", async () => {
|
|
281
|
+
const store = new MemoryPanelStore();
|
|
282
|
+
store.record({ id: 1, name: "@sam" }, { direction: "in", text: "hi", date: 1 });
|
|
283
|
+
const api = { sendMessage: () => Promise.resolve({}) };
|
|
284
|
+
const handler = panelHandler(api, store, { token: "secret", basePath: "/panel" });
|
|
285
|
+
|
|
286
|
+
const off = await handler(new Request("http://x/api/chats?token=secret"));
|
|
287
|
+
assert.equal(off.status, 404); // not under the prefix
|
|
288
|
+
|
|
289
|
+
const page = await handler(new Request("http://x/panel"));
|
|
290
|
+
assert.equal(page.status, 200);
|
|
291
|
+
assert.match(await page.text(), /BASE = "\/panel"/);
|
|
292
|
+
|
|
293
|
+
const chats = await handler(new Request("http://x/panel/api/chats?token=secret"));
|
|
294
|
+
assert.equal(chats.status, 200);
|
|
295
|
+
assert.equal(((await chats.json()) as unknown[]).length, 1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("rate limit: repeated bad tokens get 429 with Retry-After", async () => {
|
|
299
|
+
const store = new MemoryPanelStore();
|
|
300
|
+
const api = { sendMessage: () => Promise.resolve({}) };
|
|
301
|
+
const handler = panelHandler(api, store, {
|
|
302
|
+
token: "secret",
|
|
303
|
+
rateLimit: { max: 3, windowMs: 60_000 },
|
|
304
|
+
});
|
|
305
|
+
const bad = () =>
|
|
306
|
+
handler(new Request("http://x/api/chats", { headers: { authorization: "Bearer nope" } }));
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < 3; i++) assert.equal((await bad()).status, 401);
|
|
309
|
+
|
|
310
|
+
const blocked = await bad();
|
|
311
|
+
assert.equal(blocked.status, 429);
|
|
312
|
+
assert.ok(Number(blocked.headers.get("retry-after")) > 0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("SSE stream advertises text/event-stream and forwards a record event", async () => {
|
|
316
|
+
const store = new MemoryPanelStore();
|
|
317
|
+
const api = { sendMessage: () => Promise.resolve({}) };
|
|
318
|
+
const handler = panelHandler(api, store, { token: "secret" });
|
|
319
|
+
|
|
320
|
+
const res = await handler(new Request("http://x/api/stream?token=secret"));
|
|
321
|
+
assert.match(res.headers.get("content-type") ?? "", /text\/event-stream/);
|
|
322
|
+
|
|
323
|
+
const reader = res.body!.getReader();
|
|
324
|
+
await reader.read(); // ": connected"
|
|
325
|
+
store.record({ id: 1, name: "@u" }, { direction: "in", text: "ping", date: 1 });
|
|
326
|
+
const { value } = await reader.read();
|
|
327
|
+
assert.match(new TextDecoder().decode(value), /event: record/);
|
|
328
|
+
await reader.cancel();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("recordOutgoing logs bot replies sent outside the panel (private only)", () => {
|
|
332
|
+
const store = new MemoryPanelStore();
|
|
333
|
+
// minimal api fake exposing the `after` hook contract
|
|
334
|
+
let hook: ((m: string, r: unknown) => unknown) | undefined;
|
|
335
|
+
const api = { after: (h: (m: string, r: unknown) => unknown) => (hook = h) };
|
|
336
|
+
|
|
337
|
+
recordOutgoing(api, store);
|
|
338
|
+
|
|
339
|
+
// a reply to a private chat → recorded as "out"
|
|
340
|
+
hook?.("sendMessage", { chat: { id: 5, type: "private" }, text: "hey", date: 100 });
|
|
341
|
+
// a group reply → ignored
|
|
342
|
+
hook?.("sendMessage", { chat: { id: -10, type: "group" }, text: "nope", date: 101 });
|
|
343
|
+
// a non-send method → ignored
|
|
344
|
+
hook?.("deleteMessage", { chat: { id: 5, type: "private" } });
|
|
345
|
+
|
|
346
|
+
const hist = store.history(5);
|
|
347
|
+
assert.equal(hist.length, 1);
|
|
348
|
+
assert.equal(hist[0]?.direction, "out");
|
|
349
|
+
assert.equal(hist[0]?.text, "hey");
|
|
350
|
+
assert.equal(store.history(-10).length, 0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("panel send does NOT self-record when recordSends is false", async () => {
|
|
354
|
+
const store = new MemoryPanelStore();
|
|
355
|
+
const sent: Array<Record<string, unknown>> = [];
|
|
356
|
+
const api = {
|
|
357
|
+
sendMessage: (p: Record<string, unknown>) => {
|
|
358
|
+
sent.push(p);
|
|
359
|
+
return Promise.resolve({ message_id: 1 });
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
const handler = panelHandler(api, store, { token: "secret", recordSends: false });
|
|
363
|
+
|
|
364
|
+
await handler(
|
|
365
|
+
new Request("http://x/api/chats/7/send?token=secret", {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { "content-type": "application/json" },
|
|
368
|
+
body: JSON.stringify({ text: "hi" }),
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
assert.deepEqual(sent, [{ chat_id: 7, text: "hi" }]); // still sent
|
|
373
|
+
assert.equal(store.history(7).length, 0); // but not recorded by the panel
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("recorder extracts a photo attachment + caption from an incoming message", async () => {
|
|
377
|
+
const store = new MemoryPanelStore();
|
|
378
|
+
const api = {} as never;
|
|
379
|
+
const mw = entry(new Composer<Context>().install(recorder(store)));
|
|
380
|
+
|
|
381
|
+
const ctx = new Context({
|
|
382
|
+
api,
|
|
383
|
+
update: {
|
|
384
|
+
update_id: 1,
|
|
385
|
+
message: {
|
|
386
|
+
message_id: 1,
|
|
387
|
+
date: 0,
|
|
388
|
+
chat: { id: 7, type: "private" },
|
|
389
|
+
from: { id: 7, is_bot: false, first_name: "Pat" },
|
|
390
|
+
photo: [
|
|
391
|
+
{ file_id: "small", file_unique_id: "s", width: 90, height: 90 },
|
|
392
|
+
{ file_id: "big", file_unique_id: "b", width: 1280, height: 1280 },
|
|
393
|
+
],
|
|
394
|
+
media_group_id: "AG123",
|
|
395
|
+
caption: "look!",
|
|
396
|
+
},
|
|
397
|
+
} as never,
|
|
398
|
+
updateType: "message",
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await mw(ctx, noop);
|
|
402
|
+
|
|
403
|
+
const m = store.history(7)[0];
|
|
404
|
+
assert.equal(m?.text, "look!");
|
|
405
|
+
assert.deepEqual(m?.attachments, [{ type: "photo", fileId: "big" }]); // largest size
|
|
406
|
+
assert.equal(m?.mediaGroupId, "AG123");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("recorder extracts a document with name + mime", async () => {
|
|
410
|
+
const store = new MemoryPanelStore();
|
|
411
|
+
const api = {} as never;
|
|
412
|
+
const mw = entry(new Composer<Context>().install(recorder(store)));
|
|
413
|
+
|
|
414
|
+
const ctx = new Context({
|
|
415
|
+
api,
|
|
416
|
+
update: {
|
|
417
|
+
update_id: 1,
|
|
418
|
+
message: {
|
|
419
|
+
message_id: 1,
|
|
420
|
+
date: 0,
|
|
421
|
+
chat: { id: 8, type: "private" },
|
|
422
|
+
from: { id: 8, is_bot: false, first_name: "Lee" },
|
|
423
|
+
document: { file_id: "doc1", file_unique_id: "d", file_name: "report.pdf", mime_type: "application/pdf" },
|
|
424
|
+
},
|
|
425
|
+
} as never,
|
|
426
|
+
updateType: "message",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await mw(ctx, noop);
|
|
430
|
+
|
|
431
|
+
const m = store.history(8)[0];
|
|
432
|
+
assert.equal(m?.text, "[document]"); // placeholder preview, no caption
|
|
433
|
+
assert.deepEqual(m?.attachments, [
|
|
434
|
+
{ type: "document", fileId: "doc1", fileName: "report.pdf", mimeType: "application/pdf" },
|
|
435
|
+
]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("GET /api/file proxies telegram bytes via getFile + fileUrl", async () => {
|
|
439
|
+
const store = new MemoryPanelStore();
|
|
440
|
+
const calls: string[] = [];
|
|
441
|
+
const api = {
|
|
442
|
+
sendMessage: () => Promise.resolve({}),
|
|
443
|
+
call: <T>(method: string, params?: Record<string, unknown>) => {
|
|
444
|
+
calls.push(`${method}:${params?.file_id}`);
|
|
445
|
+
return Promise.resolve({ file_path: "photos/x.jpg" } as T);
|
|
446
|
+
},
|
|
447
|
+
fileUrl: (p: string) => `https://files.test/${p}`,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// stub fetch for the upstream download
|
|
451
|
+
const realFetch = globalThis.fetch;
|
|
452
|
+
globalThis.fetch = (async (u: string) => {
|
|
453
|
+
assert.equal(u, "https://files.test/photos/x.jpg");
|
|
454
|
+
return new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/jpeg" } });
|
|
455
|
+
}) as typeof fetch;
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const handler = panelHandler(api, store, { token: "secret" });
|
|
459
|
+
const res = await handler(new Request("http://x/api/file?id=ABC&token=secret"));
|
|
460
|
+
|
|
461
|
+
assert.equal(res.status, 200);
|
|
462
|
+
assert.equal(res.headers.get("content-type"), "image/jpeg");
|
|
463
|
+
assert.deepEqual([...new Uint8Array(await res.arrayBuffer())], [1, 2, 3]);
|
|
464
|
+
assert.deepEqual(calls, ["getFile:ABC"]);
|
|
465
|
+
} finally {
|
|
466
|
+
globalThis.fetch = realFetch;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("GET /api/file returns 501 when the api can't proxy", async () => {
|
|
471
|
+
const { handler } = fakePanel(); // fake api has no call/fileUrl
|
|
472
|
+
const res = await handler(new Request("http://x/api/file?id=ABC&token=secret"));
|
|
473
|
+
assert.equal(res.status, 501);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("multipart send uploads a file via the inferred send method", async () => {
|
|
477
|
+
const store = new MemoryPanelStore();
|
|
478
|
+
const calls: Array<{ method: string; params: Record<string, unknown> }> = [];
|
|
479
|
+
|
|
480
|
+
const api = {
|
|
481
|
+
sendMessage: () => Promise.resolve({}),
|
|
482
|
+
call: <T>(method: string, params?: Record<string, unknown>) => {
|
|
483
|
+
calls.push({ method, params: params ?? {} });
|
|
484
|
+
return Promise.resolve({
|
|
485
|
+
chat: { id: 1, type: "private" },
|
|
486
|
+
date: 5,
|
|
487
|
+
photo: [{ file_id: "up1" }],
|
|
488
|
+
} as T);
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
const handler = panelHandler(api, store, { token: "secret" });
|
|
492
|
+
|
|
493
|
+
const fd = new FormData();
|
|
494
|
+
fd.append("file", new Blob([new Uint8Array([9, 9])], { type: "image/png" }), "pic.png");
|
|
495
|
+
fd.append("caption", "hi");
|
|
496
|
+
|
|
497
|
+
const res = await handler(
|
|
498
|
+
new Request("http://x/api/chats/1/send?token=secret", { method: "POST", body: fd }),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
assert.equal(res.status, 200);
|
|
502
|
+
assert.equal(calls[0]?.method, "sendPhoto"); // inferred from image/png
|
|
503
|
+
assert.equal(calls[0]?.params.caption, "hi");
|
|
504
|
+
|
|
505
|
+
// recorded as an outgoing photo
|
|
506
|
+
const m = store.history(1).at(-1);
|
|
507
|
+
assert.equal(m?.direction, "out");
|
|
508
|
+
assert.deepEqual(m?.attachments, [{ type: "photo", fileId: "up1" }]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("recordOutgoing captures a sendMediaGroup array result", () => {
|
|
512
|
+
const store = new MemoryPanelStore();
|
|
513
|
+
let hook: ((m: string, r: unknown) => unknown) | undefined;
|
|
514
|
+
|
|
515
|
+
const api = { after: (h: (m: string, r: unknown) => unknown) => (hook = h) };
|
|
516
|
+
recordOutgoing(api, store);
|
|
517
|
+
|
|
518
|
+
hook?.("sendMediaGroup", [
|
|
519
|
+
{ chat: { id: 3, type: "private" }, date: 1, media_group_id: "G", photo: [{ file_id: "a" }] },
|
|
520
|
+
{ chat: { id: 3, type: "private" }, date: 1, media_group_id: "G", photo: [{ file_id: "b" }] },
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
const hist = store.history(3);
|
|
524
|
+
assert.equal(hist.length, 2);
|
|
525
|
+
assert.equal(hist[0]?.mediaGroupId, "G");
|
|
526
|
+
assert.deepEqual(hist[1]?.attachments, [{ type: "photo", fileId: "b" }]);
|
|
527
|
+
});
|