@yaebal/panel 0.0.1 → 0.0.4
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 +203 -12
- package/lib/index.d.ts +131 -17
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +619 -36
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +400 -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 +504 -44
- 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 +29 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +155 -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 +75 -0
- package/lib/sqlite.test.js.map +1 -0
- package/package.json +10 -2
- package/src/index.test.ts +514 -2
- package/src/index.ts +804 -54
- package/src/panel-html.ts +504 -44
- package/src/serve.ts +65 -0
- package/src/sqlite.test.ts +96 -0
- package/src/sqlite.ts +213 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Context, Plugin } from "@yaebal/core";
|
|
1
|
+
import type { ApiOptions, Context, Plugin } from "@yaebal/core";
|
|
2
|
+
import { createApi, media } from "@yaebal/core";
|
|
2
3
|
import { PANEL_HTML } from "./panel-html.js";
|
|
3
4
|
|
|
4
5
|
/** keep at most this many messages per chat in the in-memory store. */
|
|
@@ -15,33 +16,267 @@ 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
|
+
export interface PanelKeyboardButton {
|
|
57
|
+
text: string;
|
|
58
|
+
kind?: "callback" | "url" | "web_app" | "login_url" | "switch_inline" | "pay" | "unknown";
|
|
59
|
+
callbackData?: string;
|
|
60
|
+
url?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PanelKeyboard {
|
|
64
|
+
type: "inline" | "reply";
|
|
65
|
+
rows: PanelKeyboardButton[][];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PanelMessageEventType =
|
|
69
|
+
| "callback"
|
|
70
|
+
| "reaction"
|
|
71
|
+
| "reaction_count"
|
|
72
|
+
| "poll_answer"
|
|
73
|
+
| "chat_member";
|
|
74
|
+
|
|
75
|
+
export interface PanelMessageEvent {
|
|
76
|
+
type: PanelMessageEventType;
|
|
77
|
+
title: string;
|
|
78
|
+
detail?: string;
|
|
79
|
+
data?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PanelChatRecord {
|
|
83
|
+
id: number;
|
|
84
|
+
name?: string;
|
|
85
|
+
firstName?: string;
|
|
86
|
+
lastName?: string;
|
|
87
|
+
username?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
91
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stringValue(value: unknown): string | undefined {
|
|
95
|
+
return typeof value === "string" && value ? value : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function numberValue(value: unknown): number | undefined {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** pull every downloadable attachment out of a telegram message. */
|
|
103
|
+
function extractAttachments(message: Record<string, unknown>): PanelAttachment[] {
|
|
104
|
+
const out: PanelAttachment[] = [];
|
|
105
|
+
|
|
106
|
+
const photo = message.photo;
|
|
107
|
+
if (Array.isArray(photo) && photo.length > 0) {
|
|
108
|
+
const largest = photo[photo.length - 1] as { file_id?: string };
|
|
109
|
+
if (largest.file_id) out.push({ type: "photo", fileId: largest.file_id });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const kind of FILE_KINDS) {
|
|
113
|
+
const m = message[kind] as
|
|
114
|
+
| { file_id?: string; file_name?: string; mime_type?: string }
|
|
115
|
+
| undefined;
|
|
116
|
+
if (m && typeof m.file_id === "string") {
|
|
117
|
+
out.push({ type: kind, fileId: m.file_id, fileName: m.file_name, mimeType: m.mime_type });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function keyboardButton(raw: unknown): PanelKeyboardButton | undefined {
|
|
125
|
+
if (!isRecord(raw)) return undefined;
|
|
126
|
+
const text = stringValue(raw.text);
|
|
127
|
+
if (!text) return undefined;
|
|
128
|
+
|
|
129
|
+
const button: PanelKeyboardButton = { text };
|
|
130
|
+
const callbackData = stringValue(raw.callback_data);
|
|
131
|
+
const url = stringValue(raw.url);
|
|
132
|
+
|
|
133
|
+
if (callbackData) {
|
|
134
|
+
button.kind = "callback";
|
|
135
|
+
button.callbackData = callbackData;
|
|
136
|
+
} else if (url) {
|
|
137
|
+
button.kind = "url";
|
|
138
|
+
button.url = url;
|
|
139
|
+
} else if (raw.web_app !== undefined) button.kind = "web_app";
|
|
140
|
+
else if (raw.login_url !== undefined) button.kind = "login_url";
|
|
141
|
+
else if (
|
|
142
|
+
raw.switch_inline_query !== undefined ||
|
|
143
|
+
raw.switch_inline_query_current_chat !== undefined
|
|
144
|
+
) {
|
|
145
|
+
button.kind = "switch_inline";
|
|
146
|
+
} else if (raw.pay === true) button.kind = "pay";
|
|
147
|
+
else button.kind = "unknown";
|
|
148
|
+
|
|
149
|
+
return button;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function keyboardRows(raw: unknown): PanelKeyboardButton[][] {
|
|
153
|
+
if (!Array.isArray(raw)) return [];
|
|
154
|
+
|
|
155
|
+
const rows: PanelKeyboardButton[][] = [];
|
|
156
|
+
for (const row of raw) {
|
|
157
|
+
if (!Array.isArray(row)) continue;
|
|
158
|
+
|
|
159
|
+
const buttons: PanelKeyboardButton[] = [];
|
|
160
|
+
for (const item of row) {
|
|
161
|
+
const button = keyboardButton(item);
|
|
162
|
+
if (button) buttons.push(button);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (buttons.length > 0) rows.push(buttons);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractKeyboard(message: Record<string, unknown>): PanelKeyboard | undefined {
|
|
172
|
+
const markup = message.reply_markup;
|
|
173
|
+
if (!isRecord(markup)) return undefined;
|
|
174
|
+
|
|
175
|
+
const inline = keyboardRows(markup.inline_keyboard);
|
|
176
|
+
if (inline.length > 0) return { type: "inline", rows: inline };
|
|
177
|
+
|
|
178
|
+
const reply = keyboardRows(markup.keyboard);
|
|
179
|
+
if (reply.length > 0) return { type: "reply", rows: reply };
|
|
180
|
+
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** best-effort one-line label for a message: its text/caption, else a `[media]` tag. */
|
|
185
|
+
function describe(message: Record<string, unknown> | undefined): string | undefined {
|
|
186
|
+
if (!message) return undefined;
|
|
187
|
+
|
|
188
|
+
const text = message.text ?? message.caption;
|
|
189
|
+
if (typeof text === "string") return text;
|
|
190
|
+
|
|
191
|
+
const att = extractAttachments(message);
|
|
192
|
+
if (att.length > 0) return `[${att[0]?.type}]`;
|
|
193
|
+
|
|
194
|
+
for (const kind of TAG_KINDS) {
|
|
195
|
+
if (message[kind] !== undefined) return `[${kind}]`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
18
200
|
|
|
19
201
|
export interface PanelMessage {
|
|
20
202
|
direction: "in" | "out";
|
|
203
|
+
/** caption / text, or a `[kind]` placeholder when the message is media-only. */
|
|
21
204
|
text: string;
|
|
22
205
|
date: number;
|
|
206
|
+
/** downloadable attachments, fetched lazily through `GET /api/file?id=...`. */
|
|
207
|
+
attachments?: PanelAttachment[];
|
|
208
|
+
/** telegram album id — consecutive messages sharing it are one media group. */
|
|
209
|
+
mediaGroupId?: string;
|
|
210
|
+
/** inline/reply keyboard attached to the telegram message, rendered as a compact preview. */
|
|
211
|
+
keyboard?: PanelKeyboard;
|
|
212
|
+
/** non-message update rendered in the timeline (callback, reaction, poll answer, member event). */
|
|
213
|
+
event?: PanelMessageEvent;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** build a {@link PanelMessage} from a telegram message, or undefined if nothing to log. */
|
|
217
|
+
function toPanelMessage(
|
|
218
|
+
direction: "in" | "out",
|
|
219
|
+
message: Record<string, unknown> | undefined,
|
|
220
|
+
): PanelMessage | undefined {
|
|
221
|
+
if (!message) return undefined;
|
|
222
|
+
|
|
223
|
+
const text = describe(message);
|
|
224
|
+
const attachments = extractAttachments(message);
|
|
225
|
+
const keyboard = extractKeyboard(message);
|
|
226
|
+
if (text === undefined && attachments.length === 0 && !keyboard) return undefined;
|
|
227
|
+
|
|
228
|
+
const date = typeof message.date === "number" ? message.date : Math.floor(Date.now() / 1000);
|
|
229
|
+
const msg: PanelMessage = { direction, text: text ?? "", date };
|
|
230
|
+
if (attachments.length > 0) msg.attachments = attachments;
|
|
231
|
+
if (typeof message.media_group_id === "string") msg.mediaGroupId = message.media_group_id;
|
|
232
|
+
if (keyboard) msg.keyboard = keyboard;
|
|
233
|
+
|
|
234
|
+
return msg;
|
|
23
235
|
}
|
|
24
236
|
|
|
25
237
|
export interface PanelChat {
|
|
26
238
|
id: number;
|
|
27
239
|
name: string;
|
|
240
|
+
firstName?: string;
|
|
241
|
+
lastName?: string;
|
|
242
|
+
username?: string;
|
|
28
243
|
lastText: string;
|
|
29
244
|
lastDate: number;
|
|
245
|
+
lastAttachmentType?: AttachmentType;
|
|
246
|
+
lastEventType?: PanelMessageEventType;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** options for reading a slice of a conversation. */
|
|
250
|
+
export interface HistoryOptions {
|
|
251
|
+
/** return only messages strictly older than this unix timestamp (for "load earlier"). */
|
|
252
|
+
before?: number;
|
|
253
|
+
/** cap the result to the most recent N messages within the window. */
|
|
254
|
+
limit?: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** a change the panel may want to react to in real time. */
|
|
258
|
+
export interface PanelEvent {
|
|
259
|
+
type: "record";
|
|
260
|
+
chatId: number;
|
|
261
|
+
direction: "in" | "out";
|
|
30
262
|
}
|
|
31
263
|
|
|
32
264
|
/** where conversations are kept for the panel to read. implement for persistence. */
|
|
33
265
|
export interface PanelStore {
|
|
34
|
-
record(chat:
|
|
266
|
+
record(chat: PanelChatRecord, message: PanelMessage): void | Promise<void>;
|
|
35
267
|
chats(): PanelChat[] | Promise<PanelChat[]>;
|
|
36
|
-
history(chatId: number): PanelMessage[] | Promise<PanelMessage[]>;
|
|
268
|
+
history(chatId: number, options?: HistoryOptions): PanelMessage[] | Promise<PanelMessage[]>;
|
|
269
|
+
/** optional realtime hook — return an unsubscribe fn. enables the panel's SSE stream. */
|
|
270
|
+
subscribe?(listener: (event: PanelEvent) => void): () => void;
|
|
37
271
|
}
|
|
38
272
|
|
|
39
273
|
/** defaults to in-memory store. Lost on restart — swap for a persistent one in production. */
|
|
40
274
|
export class MemoryPanelStore implements PanelStore {
|
|
41
275
|
#chats = new Map<number, PanelChat>();
|
|
42
276
|
#messages = new Map<number, PanelMessage[]>();
|
|
277
|
+
#listeners = new Set<(event: PanelEvent) => void>();
|
|
43
278
|
|
|
44
|
-
record(chat:
|
|
279
|
+
record(chat: PanelChatRecord, message: PanelMessage): void {
|
|
45
280
|
const list = this.#messages.get(chat.id) ?? [];
|
|
46
281
|
|
|
47
282
|
list.push(message);
|
|
@@ -50,40 +285,208 @@ export class MemoryPanelStore implements PanelStore {
|
|
|
50
285
|
this.#messages.set(chat.id, list);
|
|
51
286
|
|
|
52
287
|
const prev = this.#chats.get(chat.id);
|
|
53
|
-
|
|
288
|
+
const next: PanelChat = {
|
|
54
289
|
id: chat.id,
|
|
55
290
|
name: chat.name ?? prev?.name ?? `chat ${chat.id}`,
|
|
56
291
|
lastText: message.text,
|
|
57
292
|
lastDate: message.date,
|
|
58
|
-
}
|
|
293
|
+
};
|
|
294
|
+
const firstName = chat.firstName ?? prev?.firstName;
|
|
295
|
+
const lastName = chat.lastName ?? prev?.lastName;
|
|
296
|
+
const username = chat.username ?? prev?.username;
|
|
297
|
+
const lastAttachmentType = message.attachments?.[0]?.type;
|
|
298
|
+
const lastEventType = message.event?.type;
|
|
299
|
+
|
|
300
|
+
if (firstName) next.firstName = firstName;
|
|
301
|
+
if (lastName) next.lastName = lastName;
|
|
302
|
+
if (username) next.username = username;
|
|
303
|
+
if (lastAttachmentType) next.lastAttachmentType = lastAttachmentType;
|
|
304
|
+
if (lastEventType) next.lastEventType = lastEventType;
|
|
305
|
+
|
|
306
|
+
this.#chats.set(chat.id, next);
|
|
307
|
+
|
|
308
|
+
for (const fn of this.#listeners) {
|
|
309
|
+
fn({ type: "record", chatId: chat.id, direction: message.direction });
|
|
310
|
+
}
|
|
59
311
|
}
|
|
60
312
|
|
|
61
313
|
chats(): PanelChat[] {
|
|
62
314
|
return [...this.#chats.values()].sort((a, b) => b.lastDate - a.lastDate);
|
|
63
315
|
}
|
|
64
316
|
|
|
65
|
-
history(chatId: number): PanelMessage[] {
|
|
66
|
-
|
|
317
|
+
history(chatId: number, options?: HistoryOptions): PanelMessage[] {
|
|
318
|
+
let list = this.#messages.get(chatId) ?? [];
|
|
319
|
+
|
|
320
|
+
if (options?.before !== undefined) list = list.filter((m) => m.date < options.before!);
|
|
321
|
+
if (options?.limit !== undefined) list = list.slice(-options.limit);
|
|
322
|
+
|
|
323
|
+
return list;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
subscribe(listener: (event: PanelEvent) => void): () => void {
|
|
327
|
+
this.#listeners.add(listener);
|
|
328
|
+
return () => this.#listeners.delete(listener);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function chatIdentity(chatId: number, user: unknown): PanelChatRecord {
|
|
333
|
+
const out: PanelChatRecord = { id: chatId };
|
|
334
|
+
|
|
335
|
+
if (isRecord(user)) {
|
|
336
|
+
const firstName = stringValue(user.first_name);
|
|
337
|
+
const lastName = stringValue(user.last_name);
|
|
338
|
+
const username = stringValue(user.username);
|
|
339
|
+
|
|
340
|
+
if (firstName) out.firstName = firstName;
|
|
341
|
+
if (lastName) out.lastName = lastName;
|
|
342
|
+
if (username) out.username = username;
|
|
343
|
+
|
|
344
|
+
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
345
|
+
out.name = username ? `@${username}` : fullName || undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
out.name ??= `chat ${chatId}`;
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function privateChatId(chat: unknown): number | undefined {
|
|
353
|
+
if (!isRecord(chat) || chat.type !== "private") return undefined;
|
|
354
|
+
return numberValue(chat.id);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function eventMessage(
|
|
358
|
+
type: PanelMessageEventType,
|
|
359
|
+
title: string,
|
|
360
|
+
detail?: string,
|
|
361
|
+
data?: string,
|
|
362
|
+
): PanelMessage {
|
|
363
|
+
const event: PanelMessageEvent = { type, title };
|
|
364
|
+
if (detail) event.detail = detail;
|
|
365
|
+
if (data) event.data = data;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
direction: "in",
|
|
369
|
+
text: detail ? `${title}: ${detail}` : title,
|
|
370
|
+
date: Math.floor(Date.now() / 1000),
|
|
371
|
+
event,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function reactionCount(raw: unknown): number | undefined {
|
|
376
|
+
return Array.isArray(raw) ? raw.length : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function eventRecord(
|
|
380
|
+
update: Record<string, unknown>,
|
|
381
|
+
): { chat: PanelChatRecord; message: PanelMessage } | undefined {
|
|
382
|
+
if (isRecord(update.callback_query)) {
|
|
383
|
+
const query = update.callback_query;
|
|
384
|
+
const message = isRecord(query.message) ? query.message : undefined;
|
|
385
|
+
const chatId = privateChatId(message?.chat);
|
|
386
|
+
if (chatId === undefined) return undefined;
|
|
387
|
+
|
|
388
|
+
const data = stringValue(query.data);
|
|
389
|
+
return {
|
|
390
|
+
chat: chatIdentity(chatId, query.from),
|
|
391
|
+
message: eventMessage("callback", "button clicked", data ?? "callback query", data),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (isRecord(update.message_reaction)) {
|
|
396
|
+
const reaction = update.message_reaction;
|
|
397
|
+
const chatId = privateChatId(reaction.chat);
|
|
398
|
+
if (chatId === undefined) return undefined;
|
|
399
|
+
|
|
400
|
+
const next = reactionCount(reaction.new_reaction) ?? 0;
|
|
401
|
+
const detail = `changed to ${next} reaction${next === 1 ? "" : "s"}`;
|
|
402
|
+
return {
|
|
403
|
+
chat: chatIdentity(chatId, reaction.user),
|
|
404
|
+
message: eventMessage("reaction", "message reaction", detail),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (isRecord(update.message_reaction_count)) {
|
|
409
|
+
const reaction = update.message_reaction_count;
|
|
410
|
+
const chatId = privateChatId(reaction.chat);
|
|
411
|
+
if (chatId === undefined) return undefined;
|
|
412
|
+
|
|
413
|
+
const next = reactionCount(reaction.reactions) ?? 0;
|
|
414
|
+
const detail = `${next} reaction type${next === 1 ? "" : "s"}`;
|
|
415
|
+
return {
|
|
416
|
+
chat: chatIdentity(chatId, reaction.chat),
|
|
417
|
+
message: eventMessage("reaction_count", "reaction count", detail),
|
|
418
|
+
};
|
|
67
419
|
}
|
|
420
|
+
|
|
421
|
+
if (isRecord(update.poll_answer)) {
|
|
422
|
+
const answer = update.poll_answer;
|
|
423
|
+
const user = answer.user;
|
|
424
|
+
if (!isRecord(user)) return undefined;
|
|
425
|
+
|
|
426
|
+
const chatId = numberValue(user.id);
|
|
427
|
+
if (chatId === undefined) return undefined;
|
|
428
|
+
|
|
429
|
+
const optionIds = Array.isArray(answer.option_ids)
|
|
430
|
+
? answer.option_ids.filter((id): id is number => typeof id === "number")
|
|
431
|
+
: [];
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
chat: chatIdentity(chatId, user),
|
|
435
|
+
message: eventMessage("poll_answer", "poll answer", `options ${optionIds.join(", ")}`),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const member = isRecord(update.my_chat_member)
|
|
440
|
+
? update.my_chat_member
|
|
441
|
+
: isRecord(update.chat_member)
|
|
442
|
+
? update.chat_member
|
|
443
|
+
: undefined;
|
|
444
|
+
if (member) {
|
|
445
|
+
const chatId = privateChatId(member.chat);
|
|
446
|
+
if (chatId === undefined) return undefined;
|
|
447
|
+
|
|
448
|
+
const next = isRecord(member.new_chat_member)
|
|
449
|
+
? stringValue(member.new_chat_member.status)
|
|
450
|
+
: undefined;
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
chat: chatIdentity(chatId, member.from),
|
|
454
|
+
message: eventMessage("chat_member", "chat member", next ?? "updated"),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return undefined;
|
|
68
459
|
}
|
|
69
460
|
|
|
70
|
-
/** records
|
|
461
|
+
/** records a raw Telegram update into the store; useful from any bot framework. */
|
|
462
|
+
export async function recordTelegramUpdate(store: PanelStore, update: unknown): Promise<boolean> {
|
|
463
|
+
if (!isRecord(update)) return false;
|
|
464
|
+
|
|
465
|
+
let recorded = false;
|
|
466
|
+
const rawMessage = update.message ?? update.edited_message ?? update.channel_post;
|
|
467
|
+
if (isRecord(rawMessage)) {
|
|
468
|
+
const chatId = privateChatId(rawMessage.chat);
|
|
469
|
+
const message = toPanelMessage("in", rawMessage);
|
|
470
|
+
if (chatId !== undefined && message) {
|
|
471
|
+
await store.record(chatIdentity(chatId, rawMessage.from), message);
|
|
472
|
+
recorded = true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const event = eventRecord(update);
|
|
477
|
+
if (event) {
|
|
478
|
+
await store.record(event.chat, event.message);
|
|
479
|
+
recorded = true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return recorded;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** records incoming private-chat updates into the store so the panel can show them. */
|
|
71
486
|
export function recorder(store: PanelStore): Plugin<Context, Record<never, never>> {
|
|
72
487
|
const plugin: Plugin<Context, Record<never, never>> = (composer) =>
|
|
73
488
|
composer.use(async (ctx, next) => {
|
|
74
|
-
|
|
75
|
-
const chat = ctx.chat;
|
|
76
|
-
|
|
77
|
-
if (text !== undefined && chat?.type === "private") {
|
|
78
|
-
const name = ctx.from?.username
|
|
79
|
-
? `@${ctx.from.username}`
|
|
80
|
-
: (ctx.from?.first_name ?? `chat ${chat.id}`);
|
|
81
|
-
|
|
82
|
-
await store.record(
|
|
83
|
-
{ id: chat.id, name },
|
|
84
|
-
{ direction: "in", text, date: Math.floor(Date.now() / 1000) },
|
|
85
|
-
);
|
|
86
|
-
}
|
|
489
|
+
await recordTelegramUpdate(store, ctx.update);
|
|
87
490
|
|
|
88
491
|
await next();
|
|
89
492
|
});
|
|
@@ -91,13 +494,114 @@ export function recorder(store: PanelStore): Plugin<Context, Record<never, never
|
|
|
91
494
|
return plugin;
|
|
92
495
|
}
|
|
93
496
|
|
|
94
|
-
|
|
497
|
+
/**
|
|
498
|
+
* what {@link panelHandler} needs from the api. `sendMessage` is required; `call` and
|
|
499
|
+
* `fileUrl` unlock media (file proxying + operator uploads) and are present on the real
|
|
500
|
+
* `@yaebal/core` `Api`. without them, media routes answer `501`.
|
|
501
|
+
*/
|
|
502
|
+
export interface PanelApi {
|
|
95
503
|
sendMessage(params: Record<string, unknown>): Promise<unknown>;
|
|
504
|
+
call?<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
505
|
+
fileUrl?(filePath: string): string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** create a small Bot API client that satisfies {@link PanelApi}; useful with any framework. */
|
|
509
|
+
export function createPanelApi(token: string, options?: ApiOptions): PanelApi {
|
|
510
|
+
return createApi(token, options);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** map an attachment kind to its telegram send method + param field. */
|
|
514
|
+
const SEND_METHODS: Record<AttachmentType, { method: string; field: string }> = {
|
|
515
|
+
photo: { method: "sendPhoto", field: "photo" },
|
|
516
|
+
video: { method: "sendVideo", field: "video" },
|
|
517
|
+
animation: { method: "sendAnimation", field: "animation" },
|
|
518
|
+
audio: { method: "sendAudio", field: "audio" },
|
|
519
|
+
voice: { method: "sendVoice", field: "voice" },
|
|
520
|
+
video_note: { method: "sendVideoNote", field: "video_note" },
|
|
521
|
+
document: { method: "sendDocument", field: "document" },
|
|
522
|
+
sticker: { method: "sendSticker", field: "sticker" },
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/** choose how to send an operator-uploaded file: explicit `type`, else infer from mime. */
|
|
526
|
+
function pickSendKind(type: string, mime: string): AttachmentType {
|
|
527
|
+
if (type && type in SEND_METHODS) return type as AttachmentType;
|
|
528
|
+
if (mime.startsWith("image/")) return mime === "image/gif" ? "animation" : "photo";
|
|
529
|
+
if (mime.startsWith("video/")) return "video";
|
|
530
|
+
if (mime === "audio/ogg") return "voice";
|
|
531
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
532
|
+
return "document";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** the slice of `@yaebal/core`'s `Api` that {@link recordOutgoing} needs. */
|
|
536
|
+
interface AfterHookApi {
|
|
537
|
+
after(hook: (method: string, result: unknown) => unknown): unknown;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** log a single telegram message result as an outgoing record (text or media). */
|
|
541
|
+
function recordResult(store: PanelStore, result: unknown): void {
|
|
542
|
+
if (!result || typeof result !== "object") return;
|
|
543
|
+
|
|
544
|
+
const raw = result as Record<string, unknown>;
|
|
545
|
+
const chat = raw.chat as { id?: number; type?: string } | undefined;
|
|
546
|
+
if (chat?.id === undefined || chat.type !== "private") return;
|
|
547
|
+
|
|
548
|
+
const message = toPanelMessage("out", raw);
|
|
549
|
+
if (message) void Promise.resolve(store.record(chatIdentity(chat.id, raw.chat), message));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* record replies the bot sends *outside* the panel (e.g. `ctx.reply(...)` or `ctx.replyWithPhoto(...)`
|
|
554
|
+
* in your handlers) so they show up in the conversation too. hooks the api's `after` stage and logs
|
|
555
|
+
* every successful `send*` call (including `sendMediaGroup`, which returns an array) to a private chat.
|
|
556
|
+
*
|
|
557
|
+
* pairs with `panelHandler(..., { recordSends: false })` — the panel records its own sends,
|
|
558
|
+
* so disable that to avoid double entries when this is installed.
|
|
559
|
+
*
|
|
560
|
+
* ```ts
|
|
561
|
+
* recordOutgoing(bot.api, store);
|
|
562
|
+
* const handler = panelHandler(bot.api, store, { token, recordSends: false });
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
export function recordOutgoing<A extends AfterHookApi>(api: A, store: PanelStore): A {
|
|
566
|
+
api.after((method, result) => {
|
|
567
|
+
if (method.startsWith("send")) {
|
|
568
|
+
if (Array.isArray(result)) for (const item of result) recordResult(store, item);
|
|
569
|
+
else recordResult(store, result);
|
|
570
|
+
}
|
|
571
|
+
return result;
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
return api;
|
|
96
575
|
}
|
|
97
576
|
|
|
98
577
|
export interface PanelOptions {
|
|
99
578
|
/** shared secret required to open the panel and call its api. */
|
|
100
579
|
token: string;
|
|
580
|
+
/**
|
|
581
|
+
* allowed CORS origin(s) for the panel api. omit for same-origin only (default).
|
|
582
|
+
* pass `"*"` to allow any origin, or an explicit list to echo a matching `Origin` back.
|
|
583
|
+
*/
|
|
584
|
+
cors?: string | string[];
|
|
585
|
+
/**
|
|
586
|
+
* mount prefix when the handler does not live at the server root, e.g. `"/panel"`.
|
|
587
|
+
* the UI builds its api urls from this, so no extra rewriting is needed. default `""`.
|
|
588
|
+
*/
|
|
589
|
+
basePath?: string;
|
|
590
|
+
/**
|
|
591
|
+
* throttle failed auth attempts per client. defaults to 10 failures / 60s, then `429`
|
|
592
|
+
* until the window passes. pass `false` to disable.
|
|
593
|
+
*/
|
|
594
|
+
rateLimit?: { max?: number; windowMs?: number } | false;
|
|
595
|
+
/**
|
|
596
|
+
* derive a client key for rate limiting. defaults to `x-forwarded-for` / `x-real-ip`,
|
|
597
|
+
* falling back to a single shared bucket when no proxy header is present.
|
|
598
|
+
*/
|
|
599
|
+
clientKey?: (request: Request) => string;
|
|
600
|
+
/**
|
|
601
|
+
* record replies sent from the panel into the store. default `true`. set to `false`
|
|
602
|
+
* when you use {@link recordOutgoing}, which already captures every outgoing message.
|
|
603
|
+
*/
|
|
604
|
+
recordSends?: boolean;
|
|
101
605
|
}
|
|
102
606
|
|
|
103
607
|
const json = (data: unknown, status = 200): Response =>
|
|
@@ -106,20 +610,193 @@ const json = (data: unknown, status = 200): Response =>
|
|
|
106
610
|
headers: { "content-type": "application/json", "x-content-type-options": "nosniff" },
|
|
107
611
|
});
|
|
108
612
|
|
|
613
|
+
/** resolve the `Access-Control-Allow-Origin` value for a request, or undefined if disallowed. */
|
|
614
|
+
function corsOrigin(cors: PanelOptions["cors"], request: Request): string | undefined {
|
|
615
|
+
if (cors === undefined) return undefined;
|
|
616
|
+
if (cors === "*") return "*";
|
|
617
|
+
|
|
618
|
+
const origin = request.headers.get("origin");
|
|
619
|
+
if (!origin) return undefined;
|
|
620
|
+
|
|
621
|
+
const allowed = Array.isArray(cors) ? cors : [cors];
|
|
622
|
+
return allowed.includes(origin) ? origin : undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** fields a panel client may pass through to `sendMessage` alongside `chat_id`/`text`. */
|
|
626
|
+
const SEND_PASSTHROUGH = [
|
|
627
|
+
"parse_mode",
|
|
628
|
+
"entities",
|
|
629
|
+
"link_preview_options",
|
|
630
|
+
"reply_to_message_id",
|
|
631
|
+
"reply_parameters",
|
|
632
|
+
"reply_markup",
|
|
633
|
+
"disable_notification",
|
|
634
|
+
"protect_content",
|
|
635
|
+
] as const;
|
|
636
|
+
|
|
637
|
+
/** normalize a mount prefix: `""` or `/foo` (no trailing slash). */
|
|
638
|
+
function normalizeBase(basePath: string | undefined): string {
|
|
639
|
+
if (!basePath) return "";
|
|
640
|
+
const trimmed = basePath.replace(/\/+$/, "");
|
|
641
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** a tiny in-memory limiter for failed auth attempts, keyed per client. */
|
|
645
|
+
function createLimiter(config: PanelOptions["rateLimit"]) {
|
|
646
|
+
if (config === false) return undefined;
|
|
647
|
+
|
|
648
|
+
const max = config?.max ?? 10;
|
|
649
|
+
const windowMs = config?.windowMs ?? 60_000;
|
|
650
|
+
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
/** ms the caller must wait, or 0 if still allowed. */
|
|
654
|
+
blockedFor(key: string): number {
|
|
655
|
+
const now = Date.now();
|
|
656
|
+
const entry = hits.get(key);
|
|
657
|
+
if (entry && now < entry.resetAt && entry.count >= max) {
|
|
658
|
+
return entry.resetAt - now;
|
|
659
|
+
}
|
|
660
|
+
return 0;
|
|
661
|
+
},
|
|
662
|
+
fail(key: string): void {
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
const entry = hits.get(key);
|
|
665
|
+
if (!entry || now >= entry.resetAt) {
|
|
666
|
+
hits.set(key, { count: 1, resetAt: now + windowMs });
|
|
667
|
+
} else {
|
|
668
|
+
entry.count++;
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
reset(key: string): void {
|
|
672
|
+
hits.delete(key);
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function defaultClientKey(request: Request): string {
|
|
678
|
+
const fwd = request.headers.get("x-forwarded-for");
|
|
679
|
+
const forwardedIp = fwd?.split(",")[0]?.trim();
|
|
680
|
+
if (forwardedIp) return forwardedIp;
|
|
681
|
+
|
|
682
|
+
return request.headers.get("x-real-ip") ?? "shared";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** an SSE response that forwards store events to the browser (keep-alive pinged). */
|
|
686
|
+
function streamResponse(store: PanelStore): Response {
|
|
687
|
+
const encoder = new TextEncoder();
|
|
688
|
+
let unsubscribe: (() => void) | undefined;
|
|
689
|
+
let ping: ReturnType<typeof setInterval> | undefined;
|
|
690
|
+
|
|
691
|
+
const stream = new ReadableStream({
|
|
692
|
+
start(controller) {
|
|
693
|
+
const push = (chunk: string) => {
|
|
694
|
+
try {
|
|
695
|
+
controller.enqueue(encoder.encode(chunk));
|
|
696
|
+
} catch {
|
|
697
|
+
/* client gone — cancel() will clean up */
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
push(": connected\n\n");
|
|
702
|
+
unsubscribe = store.subscribe?.((event) =>
|
|
703
|
+
push(`event: record\ndata: ${JSON.stringify(event)}\n\n`),
|
|
704
|
+
);
|
|
705
|
+
ping = setInterval(() => push(": ping\n\n"), 25_000);
|
|
706
|
+
},
|
|
707
|
+
cancel() {
|
|
708
|
+
if (ping) clearInterval(ping);
|
|
709
|
+
unsubscribe?.();
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return new Response(stream, {
|
|
714
|
+
headers: {
|
|
715
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
716
|
+
"cache-control": "no-cache, no-transform",
|
|
717
|
+
"x-content-type-options": "nosniff",
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
109
722
|
/**
|
|
110
|
-
* a fetch-style handler for the operator panel: serves the
|
|
111
|
-
* api to list chats, read a conversation, and send a
|
|
112
|
-
*
|
|
723
|
+
* a fetch-style handler for the operator panel: serves the login + chat UI at the mount
|
|
724
|
+
* root, and a small api to list chats, read a conversation, stream updates, and send a
|
|
725
|
+
* reply. mount it on any fetch-compatible server.
|
|
113
726
|
*/
|
|
114
727
|
export function panelHandler(
|
|
115
|
-
api:
|
|
728
|
+
api: PanelApi,
|
|
116
729
|
store: PanelStore,
|
|
117
730
|
options: PanelOptions,
|
|
118
731
|
): (request: Request) => Promise<Response> {
|
|
119
732
|
if (!options.token) throw new Error("panelHandler: a non-empty token is required");
|
|
120
733
|
|
|
734
|
+
const base = normalizeBase(options.basePath);
|
|
735
|
+
const limiter = createLimiter(options.rateLimit);
|
|
736
|
+
const clientKey = options.clientKey ?? defaultClientKey;
|
|
737
|
+
|
|
121
738
|
return async (request) => {
|
|
739
|
+
const allowOrigin = corsOrigin(options.cors, request);
|
|
740
|
+
|
|
741
|
+
// attach CORS headers (when enabled) to every response the handler returns
|
|
742
|
+
const finish = (response: Response): Response => {
|
|
743
|
+
if (allowOrigin) {
|
|
744
|
+
response.headers.set("access-control-allow-origin", allowOrigin);
|
|
745
|
+
response.headers.set("vary", "origin");
|
|
746
|
+
}
|
|
747
|
+
return response;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// preflight is unauthenticated by spec — answer it before the token check
|
|
751
|
+
if (request.method === "OPTIONS") {
|
|
752
|
+
return finish(
|
|
753
|
+
new Response(null, {
|
|
754
|
+
status: 204,
|
|
755
|
+
headers: {
|
|
756
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
757
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
758
|
+
"access-control-max-age": "86400",
|
|
759
|
+
},
|
|
760
|
+
}),
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
122
764
|
const url = new URL(request.url);
|
|
765
|
+
|
|
766
|
+
// resolve the path relative to the configured mount prefix
|
|
767
|
+
let path = url.pathname;
|
|
768
|
+
if (base) {
|
|
769
|
+
if (path === base) path = "/";
|
|
770
|
+
else if (path.startsWith(`${base}/`)) path = path.slice(base.length);
|
|
771
|
+
else return finish(json({ error: "not found" }, 404));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// the login + app shell is public; auth is enforced on /api/* only
|
|
775
|
+
if (path === "/" && request.method === "GET") {
|
|
776
|
+
return finish(
|
|
777
|
+
new Response(PANEL_HTML.replaceAll("__BASE__", base), {
|
|
778
|
+
headers: {
|
|
779
|
+
"content-type": "text/html; charset=utf-8",
|
|
780
|
+
// tokens are never put in the page url anymore, but stay strict anyway
|
|
781
|
+
"referrer-policy": "no-referrer",
|
|
782
|
+
"content-security-policy":
|
|
783
|
+
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'",
|
|
784
|
+
"x-content-type-options": "nosniff",
|
|
785
|
+
},
|
|
786
|
+
}),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- everything below requires a valid token ----
|
|
791
|
+
|
|
792
|
+
const key = clientKey(request);
|
|
793
|
+
const wait = limiter?.blockedFor(key) ?? 0;
|
|
794
|
+
if (wait > 0) {
|
|
795
|
+
const res = json({ error: "too many attempts" }, 429);
|
|
796
|
+
res.headers.set("retry-after", String(Math.ceil(wait / 1000)));
|
|
797
|
+
return finish(res);
|
|
798
|
+
}
|
|
799
|
+
|
|
123
800
|
const provided =
|
|
124
801
|
url.searchParams.get("token") ??
|
|
125
802
|
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ??
|
|
@@ -127,47 +804,120 @@ export function panelHandler(
|
|
|
127
804
|
|
|
128
805
|
// fail closed: reject empty/missing tokens and use a constant-time compare
|
|
129
806
|
if (!provided || !safeEqual(provided, options.token)) {
|
|
130
|
-
|
|
807
|
+
limiter?.fail(key);
|
|
808
|
+
return finish(new Response("unauthorized", { status: 401 }));
|
|
809
|
+
}
|
|
810
|
+
limiter?.reset(key);
|
|
811
|
+
|
|
812
|
+
if (path === "/api/chats" && request.method === "GET") {
|
|
813
|
+
return finish(json(await store.chats()));
|
|
131
814
|
}
|
|
132
815
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
});
|
|
816
|
+
// realtime stream of store events (EventSource can't set headers → token in query)
|
|
817
|
+
if (path === "/api/stream" && request.method === "GET") {
|
|
818
|
+
return finish(streamResponse(store));
|
|
144
819
|
}
|
|
145
820
|
|
|
146
|
-
|
|
147
|
-
|
|
821
|
+
// proxy a telegram file by file_id, keeping the bot token server-side
|
|
822
|
+
if (path === "/api/file" && request.method === "GET") {
|
|
823
|
+
const fileId = url.searchParams.get("id");
|
|
824
|
+
if (!fileId) return finish(json({ error: "id required" }, 400));
|
|
825
|
+
if (!api.call || !api.fileUrl) {
|
|
826
|
+
return finish(json({ error: "media proxy needs an api with call()/fileUrl()" }, 501));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const file = await api
|
|
830
|
+
.call<{ file_path?: string }>("getFile", { file_id: fileId })
|
|
831
|
+
.catch(() => undefined);
|
|
832
|
+
if (!file?.file_path) return finish(json({ error: "file not found" }, 404));
|
|
833
|
+
|
|
834
|
+
const upstream = await fetch(api.fileUrl(file.file_path));
|
|
835
|
+
if (!upstream.ok || !upstream.body) return finish(json({ error: "download failed" }, 502));
|
|
836
|
+
|
|
837
|
+
return finish(
|
|
838
|
+
new Response(upstream.body, {
|
|
839
|
+
headers: {
|
|
840
|
+
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
|
841
|
+
"cache-control": "private, max-age=86400",
|
|
842
|
+
"x-content-type-options": "nosniff",
|
|
843
|
+
},
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
148
846
|
}
|
|
149
847
|
|
|
150
|
-
const get =
|
|
848
|
+
const get = path.match(/^\/api\/chats\/(-?\d+)$/);
|
|
151
849
|
if (get?.[1] && request.method === "GET") {
|
|
152
|
-
|
|
850
|
+
const before = url.searchParams.get("before");
|
|
851
|
+
const limit = url.searchParams.get("limit");
|
|
852
|
+
const opts: HistoryOptions = {};
|
|
853
|
+
if (before !== null) opts.before = Number(before);
|
|
854
|
+
if (limit !== null) opts.limit = Number(limit);
|
|
855
|
+
return finish(json(await store.history(Number(get[1]), opts)));
|
|
153
856
|
}
|
|
154
857
|
|
|
155
|
-
const send =
|
|
858
|
+
const send = path.match(/^\/api\/chats\/(-?\d+)\/send$/);
|
|
156
859
|
if (send?.[1] && request.method === "POST") {
|
|
157
860
|
const chatId = Number(send[1]);
|
|
158
|
-
const
|
|
861
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
862
|
+
|
|
863
|
+
// ---- operator file upload (multipart) -> sendPhoto / sendDocument / sendVoice / ... ----
|
|
864
|
+
if (contentType.includes("multipart/form-data")) {
|
|
865
|
+
if (!api.call) {
|
|
866
|
+
return finish(json({ error: "uploads need an api with call()" }, 501));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const form = await request.formData().catch(() => undefined);
|
|
870
|
+
const file = form?.get("file");
|
|
871
|
+
if (!(file instanceof Blob)) return finish(json({ error: "file required" }, 400));
|
|
872
|
+
|
|
873
|
+
const kind = pickSendKind(String(form?.get("type") ?? ""), file.type);
|
|
874
|
+
const { method, field } = SEND_METHODS[kind];
|
|
875
|
+
const filename = (file as File).name || kind;
|
|
876
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
877
|
+
|
|
878
|
+
const params: Record<string, unknown> = {
|
|
879
|
+
chat_id: chatId,
|
|
880
|
+
[field]: media.buffer(bytes, filename),
|
|
881
|
+
};
|
|
882
|
+
const caption = form?.get("caption");
|
|
883
|
+
if (typeof caption === "string" && caption) params.caption = caption;
|
|
884
|
+
|
|
885
|
+
const result = await api.call(method, params);
|
|
886
|
+
if (options.recordSends !== false) recordResult(store, result);
|
|
887
|
+
|
|
888
|
+
return finish(json({ ok: true }));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---- text reply (json) → sendMessage ----
|
|
892
|
+
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
893
|
+
if (typeof body.text !== "string" || !body.text) {
|
|
894
|
+
return finish(json({ error: "text required" }, 400));
|
|
895
|
+
}
|
|
159
896
|
|
|
160
|
-
|
|
897
|
+
const params: Record<string, unknown> = { chat_id: chatId, text: body.text };
|
|
898
|
+
for (const field of SEND_PASSTHROUGH) {
|
|
899
|
+
if (body[field] !== undefined) params[field] = body[field];
|
|
900
|
+
}
|
|
161
901
|
|
|
162
|
-
await api.sendMessage(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
902
|
+
const result = await api.sendMessage(params);
|
|
903
|
+
// skip when recordOutgoing already logs every send (avoids double entries)
|
|
904
|
+
if (options.recordSends !== false) {
|
|
905
|
+
// record from the api result when it's a real message, else fall back to the text
|
|
906
|
+
if (result && typeof result === "object" && "chat" in result) recordResult(store, result);
|
|
907
|
+
else {
|
|
908
|
+
const fallback = toPanelMessage("out", {
|
|
909
|
+
text: body.text,
|
|
910
|
+
date: Math.floor(Date.now() / 1000),
|
|
911
|
+
reply_markup: body.reply_markup,
|
|
912
|
+
}) ?? { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) };
|
|
913
|
+
|
|
914
|
+
await store.record({ id: chatId }, fallback);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
167
917
|
|
|
168
|
-
return json({ ok: true });
|
|
918
|
+
return finish(json({ ok: true }));
|
|
169
919
|
}
|
|
170
|
-
|
|
171
|
-
return json({ error: "not found" }, 404);
|
|
920
|
+
|
|
921
|
+
return finish(json({ error: "not found" }, 404));
|
|
172
922
|
};
|
|
173
923
|
}
|