@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/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { media } from "@yaebal/core";
1
2
  import type { Context, Plugin } from "@yaebal/core";
2
3
  import { PANEL_HTML } from "./panel-html.js";
3
4
 
@@ -15,11 +16,110 @@ function safeEqual(a: string, b: string): boolean {
15
16
  }
16
17
 
17
18
  export { PANEL_HTML } from "./panel-html.js";
19
+ // note: `serve` (node:http) and `SqlitePanelStore` (node:sqlite) are intentionally NOT
20
+ // re-exported here so this entry stays runtime-agnostic for edge bundles. import them
21
+ // from "@yaebal/panel/serve" and "@yaebal/panel/sqlite" respectively.
22
+
23
+ /** downloadable file kinds carried by a message (each maps to one `file_id`). */
24
+ export type AttachmentType =
25
+ | "photo"
26
+ | "video"
27
+ | "animation"
28
+ | "audio"
29
+ | "voice"
30
+ | "video_note"
31
+ | "document"
32
+ | "sticker";
33
+
34
+ /** single-file media kinds (photo is special — it's an array of sizes). */
35
+ const FILE_KINDS: AttachmentType[] = [
36
+ "video",
37
+ "animation",
38
+ "audio",
39
+ "voice",
40
+ "video_note",
41
+ "document",
42
+ "sticker",
43
+ ];
44
+
45
+ /** non-downloadable kinds we still label in the chat preview. */
46
+ const TAG_KINDS = ["location", "contact", "poll", "dice"] as const;
47
+
48
+ /** a downloadable attachment, referenced by telegram `file_id`. */
49
+ export interface PanelAttachment {
50
+ type: AttachmentType;
51
+ fileId: string;
52
+ fileName?: string;
53
+ mimeType?: string;
54
+ }
55
+
56
+ /** pull every downloadable attachment out of a telegram message. */
57
+ function extractAttachments(message: Record<string, unknown>): PanelAttachment[] {
58
+ const out: PanelAttachment[] = [];
59
+
60
+ const photo = message.photo;
61
+ if (Array.isArray(photo) && photo.length > 0) {
62
+ const largest = photo[photo.length - 1] as { file_id?: string };
63
+ if (largest.file_id) out.push({ type: "photo", fileId: largest.file_id });
64
+ }
65
+
66
+ for (const kind of FILE_KINDS) {
67
+ const m = message[kind] as
68
+ | { file_id?: string; file_name?: string; mime_type?: string }
69
+ | undefined;
70
+ if (m && typeof m.file_id === "string") {
71
+ out.push({ type: kind, fileId: m.file_id, fileName: m.file_name, mimeType: m.mime_type });
72
+ }
73
+ }
74
+
75
+ return out;
76
+ }
77
+
78
+ /** best-effort one-line label for a message: its text/caption, else a `[media]` tag. */
79
+ function describe(message: Record<string, unknown> | undefined): string | undefined {
80
+ if (!message) return undefined;
81
+
82
+ const text = message.text ?? message.caption;
83
+ if (typeof text === "string") return text;
84
+
85
+ const att = extractAttachments(message);
86
+ if (att.length > 0) return `[${att[0]!.type}]`;
87
+
88
+ for (const kind of TAG_KINDS) {
89
+ if (message[kind] !== undefined) return `[${kind}]`;
90
+ }
91
+
92
+ return undefined;
93
+ }
18
94
 
19
95
  export interface PanelMessage {
20
96
  direction: "in" | "out";
97
+ /** caption / text, or a `[kind]` placeholder when the message is media-only. */
21
98
  text: string;
22
99
  date: number;
100
+ /** downloadable attachments, fetched lazily through `GET /api/file?id=…`. */
101
+ attachments?: PanelAttachment[];
102
+ /** telegram album id — consecutive messages sharing it are one media group. */
103
+ mediaGroupId?: string;
104
+ }
105
+
106
+ /** build a {@link PanelMessage} from a telegram message, or undefined if nothing to log. */
107
+ function toPanelMessage(
108
+ direction: "in" | "out",
109
+ message: Record<string, unknown> | undefined,
110
+ ): PanelMessage | undefined {
111
+ if (!message) return undefined;
112
+
113
+ const text = describe(message);
114
+ const attachments = extractAttachments(message);
115
+ if (text === undefined && attachments.length === 0) return undefined;
116
+
117
+ const date = typeof message.date === "number" ? message.date : Math.floor(Date.now() / 1000);
118
+ const msg: PanelMessage = { direction, text: text ?? "", date };
119
+ if (attachments.length > 0) msg.attachments = attachments;
120
+ if (typeof message.media_group_id === "string") msg.mediaGroupId = message.media_group_id;
121
+
122
+ return msg;
23
123
  }
24
124
 
25
125
  export interface PanelChat {
@@ -29,17 +129,35 @@ export interface PanelChat {
29
129
  lastDate: number;
30
130
  }
31
131
 
132
+ /** options for reading a slice of a conversation. */
133
+ export interface HistoryOptions {
134
+ /** return only messages strictly older than this unix timestamp (for "load earlier"). */
135
+ before?: number;
136
+ /** cap the result to the most recent N messages within the window. */
137
+ limit?: number;
138
+ }
139
+
140
+ /** a change the panel may want to react to in real time. */
141
+ export interface PanelEvent {
142
+ type: "record";
143
+ chatId: number;
144
+ direction: "in" | "out";
145
+ }
146
+
32
147
  /** where conversations are kept for the panel to read. implement for persistence. */
33
148
  export interface PanelStore {
34
149
  record(chat: { id: number; name?: string }, message: PanelMessage): void | Promise<void>;
35
150
  chats(): PanelChat[] | Promise<PanelChat[]>;
36
- history(chatId: number): PanelMessage[] | Promise<PanelMessage[]>;
151
+ history(chatId: number, options?: HistoryOptions): PanelMessage[] | Promise<PanelMessage[]>;
152
+ /** optional realtime hook — return an unsubscribe fn. enables the panel's SSE stream. */
153
+ subscribe?(listener: (event: PanelEvent) => void): () => void;
37
154
  }
38
155
 
39
156
  /** defaults to in-memory store. Lost on restart — swap for a persistent one in production. */
40
157
  export class MemoryPanelStore implements PanelStore {
41
158
  #chats = new Map<number, PanelChat>();
42
159
  #messages = new Map<number, PanelMessage[]>();
160
+ #listeners = new Set<(event: PanelEvent) => void>();
43
161
 
44
162
  record(chat: { id: number; name?: string }, message: PanelMessage): void {
45
163
  const list = this.#messages.get(chat.id) ?? [];
@@ -56,14 +174,26 @@ export class MemoryPanelStore implements PanelStore {
56
174
  lastText: message.text,
57
175
  lastDate: message.date,
58
176
  });
177
+
178
+ for (const fn of this.#listeners) {
179
+ fn({ type: "record", chatId: chat.id, direction: message.direction });
180
+ }
59
181
  }
60
182
 
61
183
  chats(): PanelChat[] {
62
184
  return [...this.#chats.values()].sort((a, b) => b.lastDate - a.lastDate);
63
185
  }
64
186
 
65
- history(chatId: number): PanelMessage[] {
66
- return this.#messages.get(chatId) ?? [];
187
+ history(chatId: number, options?: HistoryOptions): PanelMessage[] {
188
+ let list = this.#messages.get(chatId) ?? [];
189
+ if (options?.before !== undefined) list = list.filter((m) => m.date < options.before!);
190
+ if (options?.limit !== undefined) list = list.slice(-options.limit);
191
+ return list;
192
+ }
193
+
194
+ subscribe(listener: (event: PanelEvent) => void): () => void {
195
+ this.#listeners.add(listener);
196
+ return () => this.#listeners.delete(listener);
67
197
  }
68
198
  }
69
199
 
@@ -71,18 +201,15 @@ export class MemoryPanelStore implements PanelStore {
71
201
  export function recorder(store: PanelStore): Plugin<Context, Record<never, never>> {
72
202
  const plugin: Plugin<Context, Record<never, never>> = (composer) =>
73
203
  composer.use(async (ctx, next) => {
74
- const text = ctx.text;
75
204
  const chat = ctx.chat;
205
+ const message = toPanelMessage("in", ctx.message as Record<string, unknown> | undefined);
76
206
 
77
- if (text !== undefined && chat?.type === "private") {
207
+ if (message && chat?.type === "private") {
78
208
  const name = ctx.from?.username
79
209
  ? `@${ctx.from.username}`
80
210
  : (ctx.from?.first_name ?? `chat ${chat.id}`);
81
211
 
82
- await store.record(
83
- { id: chat.id, name },
84
- { direction: "in", text, date: Math.floor(Date.now() / 1000) },
85
- );
212
+ await store.record({ id: chat.id, name }, message);
86
213
  }
87
214
 
88
215
  await next();
@@ -91,13 +218,109 @@ export function recorder(store: PanelStore): Plugin<Context, Record<never, never
91
218
  return plugin;
92
219
  }
93
220
 
94
- interface SendApi {
221
+ /**
222
+ * what {@link panelHandler} needs from the api. `sendMessage` is required; `call` and
223
+ * `fileUrl` unlock media (file proxying + operator uploads) and are present on the real
224
+ * `@yaebal/core` `Api`. without them, media routes answer `501`.
225
+ */
226
+ interface PanelApi {
95
227
  sendMessage(params: Record<string, unknown>): Promise<unknown>;
228
+ call?<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
229
+ fileUrl?(filePath: string): string;
230
+ }
231
+
232
+ /** map an attachment kind to its telegram send method + param field. */
233
+ const SEND_METHODS: Record<AttachmentType, { method: string; field: string }> = {
234
+ photo: { method: "sendPhoto", field: "photo" },
235
+ video: { method: "sendVideo", field: "video" },
236
+ animation: { method: "sendAnimation", field: "animation" },
237
+ audio: { method: "sendAudio", field: "audio" },
238
+ voice: { method: "sendVoice", field: "voice" },
239
+ video_note: { method: "sendVideoNote", field: "video_note" },
240
+ document: { method: "sendDocument", field: "document" },
241
+ sticker: { method: "sendSticker", field: "sticker" },
242
+ };
243
+
244
+ /** choose how to send an operator-uploaded file: explicit `type`, else infer from mime. */
245
+ function pickSendKind(type: string, mime: string): AttachmentType {
246
+ if (type && type in SEND_METHODS) return type as AttachmentType;
247
+ if (mime.startsWith("image/")) return mime === "image/gif" ? "animation" : "photo";
248
+ if (mime.startsWith("video/")) return "video";
249
+ if (mime === "audio/ogg") return "voice";
250
+ if (mime.startsWith("audio/")) return "audio";
251
+ return "document";
252
+ }
253
+
254
+ /** the slice of `@yaebal/core`'s `Api` that {@link recordOutgoing} needs. */
255
+ interface AfterHookApi {
256
+ after(hook: (method: string, result: unknown) => unknown): unknown;
257
+ }
258
+
259
+ /** log a single telegram message result as an outgoing record (text or media). */
260
+ function recordResult(store: PanelStore, result: unknown): void {
261
+ if (!result || typeof result !== "object") return;
262
+
263
+ const raw = result as Record<string, unknown>;
264
+ const chat = raw.chat as { id?: number; type?: string } | undefined;
265
+ if (chat?.id === undefined || chat.type !== "private") return;
266
+
267
+ const message = toPanelMessage("out", raw);
268
+ if (message) void Promise.resolve(store.record({ id: chat.id }, message));
269
+ }
270
+
271
+ /**
272
+ * record replies the bot sends *outside* the panel (e.g. `ctx.reply(...)` or `ctx.replyWithPhoto(...)`
273
+ * in your handlers) so they show up in the conversation too. hooks the api's `after` stage and logs
274
+ * every successful `send*` call (including `sendMediaGroup`, which returns an array) to a private chat.
275
+ *
276
+ * pairs with `panelHandler(..., { recordSends: false })` — the panel records its own sends,
277
+ * so disable that to avoid double entries when this is installed.
278
+ *
279
+ * ```ts
280
+ * recordOutgoing(bot.api, store);
281
+ * const handler = panelHandler(bot.api, store, { token, recordSends: false });
282
+ * ```
283
+ */
284
+ export function recordOutgoing<A extends AfterHookApi>(api: A, store: PanelStore): A {
285
+ api.after((method, result) => {
286
+ if (method.startsWith("send")) {
287
+ if (Array.isArray(result)) for (const item of result) recordResult(store, item);
288
+ else recordResult(store, result);
289
+ }
290
+ return result;
291
+ });
292
+
293
+ return api;
96
294
  }
97
295
 
98
296
  export interface PanelOptions {
99
297
  /** shared secret required to open the panel and call its api. */
100
298
  token: string;
299
+ /**
300
+ * allowed CORS origin(s) for the panel api. omit for same-origin only (default).
301
+ * pass `"*"` to allow any origin, or an explicit list to echo a matching `Origin` back.
302
+ */
303
+ cors?: string | string[];
304
+ /**
305
+ * mount prefix when the handler does not live at the server root, e.g. `"/panel"`.
306
+ * the UI builds its api urls from this, so no extra rewriting is needed. default `""`.
307
+ */
308
+ basePath?: string;
309
+ /**
310
+ * throttle failed auth attempts per client. defaults to 10 failures / 60s, then `429`
311
+ * until the window passes. pass `false` to disable.
312
+ */
313
+ rateLimit?: { max?: number; windowMs?: number } | false;
314
+ /**
315
+ * derive a client key for rate limiting. defaults to `x-forwarded-for` / `x-real-ip`,
316
+ * falling back to a single shared bucket when no proxy header is present.
317
+ */
318
+ clientKey?: (request: Request) => string;
319
+ /**
320
+ * record replies sent from the panel into the store. default `true`. set to `false`
321
+ * when you use {@link recordOutgoing}, which already captures every outgoing message.
322
+ */
323
+ recordSends?: boolean;
101
324
  }
102
325
 
103
326
  const json = (data: unknown, status = 200): Response =>
@@ -106,20 +329,180 @@ const json = (data: unknown, status = 200): Response =>
106
329
  headers: { "content-type": "application/json", "x-content-type-options": "nosniff" },
107
330
  });
108
331
 
332
+ /** resolve the `Access-Control-Allow-Origin` value for a request, or undefined if disallowed. */
333
+ function corsOrigin(cors: PanelOptions["cors"], request: Request): string | undefined {
334
+ if (cors === undefined) return undefined;
335
+ if (cors === "*") return "*";
336
+
337
+ const origin = request.headers.get("origin");
338
+ if (!origin) return undefined;
339
+
340
+ const allowed = Array.isArray(cors) ? cors : [cors];
341
+ return allowed.includes(origin) ? origin : undefined;
342
+ }
343
+
344
+ /** fields a panel client may pass through to `sendMessage` alongside `chat_id`/`text`. */
345
+ const SEND_PASSTHROUGH = ["parse_mode", "reply_to_message_id", "reply_parameters"] as const;
346
+
347
+ /** normalize a mount prefix: `""` or `/foo` (no trailing slash). */
348
+ function normalizeBase(basePath: string | undefined): string {
349
+ if (!basePath) return "";
350
+ const trimmed = basePath.replace(/\/+$/, "");
351
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
352
+ }
353
+
354
+ /** a tiny in-memory limiter for failed auth attempts, keyed per client. */
355
+ function createLimiter(config: PanelOptions["rateLimit"]) {
356
+ if (config === false) return undefined;
357
+
358
+ const max = config?.max ?? 10;
359
+ const windowMs = config?.windowMs ?? 60_000;
360
+ const hits = new Map<string, { count: number; resetAt: number }>();
361
+
362
+ return {
363
+ /** ms the caller must wait, or 0 if still allowed. */
364
+ blockedFor(key: string): number {
365
+ const now = Date.now();
366
+ const entry = hits.get(key);
367
+ if (entry && now < entry.resetAt && entry.count >= max) {
368
+ return entry.resetAt - now;
369
+ }
370
+ return 0;
371
+ },
372
+ fail(key: string): void {
373
+ const now = Date.now();
374
+ const entry = hits.get(key);
375
+ if (!entry || now >= entry.resetAt) {
376
+ hits.set(key, { count: 1, resetAt: now + windowMs });
377
+ } else {
378
+ entry.count++;
379
+ }
380
+ },
381
+ reset(key: string): void {
382
+ hits.delete(key);
383
+ },
384
+ };
385
+ }
386
+
387
+ function defaultClientKey(request: Request): string {
388
+ const fwd = request.headers.get("x-forwarded-for");
389
+ if (fwd) return fwd.split(",")[0]!.trim();
390
+ return request.headers.get("x-real-ip") ?? "shared";
391
+ }
392
+
393
+ /** an SSE response that forwards store events to the browser (keep-alive pinged). */
394
+ function streamResponse(store: PanelStore): Response {
395
+ const encoder = new TextEncoder();
396
+ let unsubscribe: (() => void) | undefined;
397
+ let ping: ReturnType<typeof setInterval> | undefined;
398
+
399
+ const stream = new ReadableStream({
400
+ start(controller) {
401
+ const push = (chunk: string) => {
402
+ try {
403
+ controller.enqueue(encoder.encode(chunk));
404
+ } catch {
405
+ /* client gone — cancel() will clean up */
406
+ }
407
+ };
408
+
409
+ push(": connected\n\n");
410
+ unsubscribe = store.subscribe?.((event) => push(`event: record\ndata: ${JSON.stringify(event)}\n\n`));
411
+ ping = setInterval(() => push(": ping\n\n"), 25_000);
412
+ },
413
+ cancel() {
414
+ if (ping) clearInterval(ping);
415
+ unsubscribe?.();
416
+ },
417
+ });
418
+
419
+ return new Response(stream, {
420
+ headers: {
421
+ "content-type": "text/event-stream; charset=utf-8",
422
+ "cache-control": "no-cache, no-transform",
423
+ "x-content-type-options": "nosniff",
424
+ },
425
+ });
426
+ }
427
+
109
428
  /**
110
- * a fetch-style handler for the operator panel: serves the ui at `/`, and a small
111
- * api to list chats, read a conversation, and send a reply. mount it on any
112
- * fetch-compatible server. open it at `/?token=<your token>`.
429
+ * a fetch-style handler for the operator panel: serves the login + chat UI at the mount
430
+ * root, and a small api to list chats, read a conversation, stream updates, and send a
431
+ * reply. mount it on any fetch-compatible server.
113
432
  */
114
433
  export function panelHandler(
115
- api: SendApi,
434
+ api: PanelApi,
116
435
  store: PanelStore,
117
436
  options: PanelOptions,
118
437
  ): (request: Request) => Promise<Response> {
119
438
  if (!options.token) throw new Error("panelHandler: a non-empty token is required");
120
439
 
440
+ const base = normalizeBase(options.basePath);
441
+ const limiter = createLimiter(options.rateLimit);
442
+ const clientKey = options.clientKey ?? defaultClientKey;
443
+
121
444
  return async (request) => {
445
+ const allowOrigin = corsOrigin(options.cors, request);
446
+
447
+ // attach CORS headers (when enabled) to every response the handler returns
448
+ const finish = (response: Response): Response => {
449
+ if (allowOrigin) {
450
+ response.headers.set("access-control-allow-origin", allowOrigin);
451
+ response.headers.set("vary", "origin");
452
+ }
453
+ return response;
454
+ };
455
+
456
+ // preflight is unauthenticated by spec — answer it before the token check
457
+ if (request.method === "OPTIONS") {
458
+ return finish(
459
+ new Response(null, {
460
+ status: 204,
461
+ headers: {
462
+ "access-control-allow-methods": "GET, POST, OPTIONS",
463
+ "access-control-allow-headers": "authorization, content-type",
464
+ "access-control-max-age": "86400",
465
+ },
466
+ }),
467
+ );
468
+ }
469
+
122
470
  const url = new URL(request.url);
471
+
472
+ // resolve the path relative to the configured mount prefix
473
+ let path = url.pathname;
474
+ if (base) {
475
+ if (path === base) path = "/";
476
+ else if (path.startsWith(`${base}/`)) path = path.slice(base.length);
477
+ else return finish(json({ error: "not found" }, 404));
478
+ }
479
+
480
+ // the login + app shell is public; auth is enforced on /api/* only
481
+ if (path === "/" && request.method === "GET") {
482
+ return finish(
483
+ new Response(PANEL_HTML.replaceAll("__BASE__", base), {
484
+ headers: {
485
+ "content-type": "text/html; charset=utf-8",
486
+ // tokens are never put in the page url anymore, but stay strict anyway
487
+ "referrer-policy": "no-referrer",
488
+ "content-security-policy":
489
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'",
490
+ "x-content-type-options": "nosniff",
491
+ },
492
+ }),
493
+ );
494
+ }
495
+
496
+ // ---- everything below requires a valid token ----
497
+
498
+ const key = clientKey(request);
499
+ const wait = limiter?.blockedFor(key) ?? 0;
500
+ if (wait > 0) {
501
+ const res = json({ error: "too many attempts" }, 429);
502
+ res.headers.set("retry-after", String(Math.ceil(wait / 1000)));
503
+ return finish(res);
504
+ }
505
+
123
506
  const provided =
124
507
  url.searchParams.get("token") ??
125
508
  request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ??
@@ -127,47 +510,117 @@ export function panelHandler(
127
510
 
128
511
  // fail closed: reject empty/missing tokens and use a constant-time compare
129
512
  if (!provided || !safeEqual(provided, options.token)) {
130
- return new Response("unauthorized", { status: 401 });
513
+ limiter?.fail(key);
514
+ return finish(new Response("unauthorized", { status: 401 }));
131
515
  }
516
+ limiter?.reset(key);
132
517
 
133
- if (url.pathname === "/" && request.method === "GET") {
134
- return new Response(PANEL_HTML, {
135
- headers: {
136
- "content-type": "text/html; charset=utf-8",
137
- // the token rides in the URL on this initial load — keep it out of Referer
138
- "referrer-policy": "no-referrer",
139
- "content-security-policy":
140
- "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
141
- "x-content-type-options": "nosniff",
142
- },
143
- });
518
+ if (path === "/api/chats" && request.method === "GET") {
519
+ return finish(json(await store.chats()));
144
520
  }
145
521
 
146
- if (url.pathname === "/api/chats" && request.method === "GET") {
147
- return json(await store.chats());
522
+ // realtime stream of store events (EventSource can't set headers token in query)
523
+ if (path === "/api/stream" && request.method === "GET") {
524
+ return finish(streamResponse(store));
148
525
  }
149
526
 
150
- const get = url.pathname.match(/^\/api\/chats\/(-?\d+)$/);
527
+ // proxy a telegram file by file_id, keeping the bot token server-side
528
+ if (path === "/api/file" && request.method === "GET") {
529
+ const fileId = url.searchParams.get("id");
530
+ if (!fileId) return finish(json({ error: "id required" }, 400));
531
+ if (!api.call || !api.fileUrl) {
532
+ return finish(json({ error: "media proxy needs an api with call()/fileUrl()" }, 501));
533
+ }
534
+
535
+ const file = await api
536
+ .call<{ file_path?: string }>("getFile", { file_id: fileId })
537
+ .catch(() => undefined);
538
+ if (!file?.file_path) return finish(json({ error: "file not found" }, 404));
539
+
540
+ const upstream = await fetch(api.fileUrl(file.file_path));
541
+ if (!upstream.ok || !upstream.body) return finish(json({ error: "download failed" }, 502));
542
+
543
+ return finish(
544
+ new Response(upstream.body, {
545
+ headers: {
546
+ "content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
547
+ "cache-control": "private, max-age=86400",
548
+ "x-content-type-options": "nosniff",
549
+ },
550
+ }),
551
+ );
552
+ }
553
+
554
+ const get = path.match(/^\/api\/chats\/(-?\d+)$/);
151
555
  if (get?.[1] && request.method === "GET") {
152
- return json(await store.history(Number(get[1])));
556
+ const before = url.searchParams.get("before");
557
+ const limit = url.searchParams.get("limit");
558
+ const opts: HistoryOptions = {};
559
+ if (before !== null) opts.before = Number(before);
560
+ if (limit !== null) opts.limit = Number(limit);
561
+ return finish(json(await store.history(Number(get[1]), opts)));
153
562
  }
154
563
 
155
- const send = url.pathname.match(/^\/api\/chats\/(-?\d+)\/send$/);
564
+ const send = path.match(/^\/api\/chats\/(-?\d+)\/send$/);
156
565
  if (send?.[1] && request.method === "POST") {
157
566
  const chatId = Number(send[1]);
158
- const body = (await request.json().catch(() => ({}))) as { text?: string };
567
+ const contentType = request.headers.get("content-type") ?? "";
568
+
569
+ // ---- operator file upload (multipart) → sendPhoto / sendDocument / sendVoice / … ----
570
+ if (contentType.includes("multipart/form-data")) {
571
+ if (!api.call) {
572
+ return finish(json({ error: "uploads need an api with call()" }, 501));
573
+ }
574
+
575
+ const form = await request.formData().catch(() => undefined);
576
+ const file = form?.get("file");
577
+ if (!(file instanceof Blob)) return finish(json({ error: "file required" }, 400));
578
+
579
+ const kind = pickSendKind(String(form?.get("type") ?? ""), file.type);
580
+ const { method, field } = SEND_METHODS[kind];
581
+ const filename = (file as File).name || kind;
582
+ const bytes = new Uint8Array(await file.arrayBuffer());
583
+
584
+ const params: Record<string, unknown> = {
585
+ chat_id: chatId,
586
+ [field]: media.buffer(bytes, filename),
587
+ };
588
+ const caption = form?.get("caption");
589
+ if (typeof caption === "string" && caption) params.caption = caption;
590
+
591
+ const result = await api.call(method, params);
592
+ if (options.recordSends !== false) recordResult(store, result);
593
+
594
+ return finish(json({ ok: true }));
595
+ }
159
596
 
160
- if (!body.text) return json({ error: "text required" }, 400);
597
+ // ---- text reply (json) sendMessage ----
598
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
599
+ if (typeof body.text !== "string" || !body.text) {
600
+ return finish(json({ error: "text required" }, 400));
601
+ }
161
602
 
162
- await api.sendMessage({ chat_id: chatId, text: body.text });
163
- await store.record(
164
- { id: chatId },
165
- { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) },
166
- );
603
+ const params: Record<string, unknown> = { chat_id: chatId, text: body.text };
604
+ for (const field of SEND_PASSTHROUGH) {
605
+ if (body[field] !== undefined) params[field] = body[field];
606
+ }
167
607
 
168
- return json({ ok: true });
608
+ const result = await api.sendMessage(params);
609
+ // skip when recordOutgoing already logs every send (avoids double entries)
610
+ if (options.recordSends !== false) {
611
+ // record from the api result when it's a real message, else fall back to the text
612
+ if (result && typeof result === "object" && "chat" in result) recordResult(store, result);
613
+ else {
614
+ await store.record(
615
+ { id: chatId },
616
+ { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) },
617
+ );
618
+ }
619
+ }
620
+
621
+ return finish(json({ ok: true }));
169
622
  }
170
-
171
- return json({ error: "not found" }, 404);
623
+
624
+ return finish(json({ error: "not found" }, 404));
172
625
  };
173
626
  }