@yaebal/panel 0.0.2 → 0.0.5
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 +156 -96
- package/lib/index.d.ts +43 -12
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +253 -20
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +81 -3
- 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 +412 -115
- package/lib/panel-html.js.map +1 -1
- package/lib/serve.d.ts.map +1 -1
- package/lib/serve.js.map +1 -1
- package/lib/sqlite.d.ts +2 -5
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +76 -18
- package/lib/sqlite.js.map +1 -1
- package/lib/sqlite.test.js +41 -8
- package/lib/sqlite.test.js.map +1 -1
- package/package.json +2 -2
- package/src/index.test.ts +104 -4
- package/src/index.ts +327 -30
- package/src/panel-html.ts +412 -115
- package/src/serve.ts +1 -1
- package/src/sqlite.test.ts +47 -9
- package/src/sqlite.ts +94 -21
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { ApiOptions, Context, Plugin } from "@yaebal/core";
|
|
2
|
+
import { createApi, media } from "@yaebal/core";
|
|
3
3
|
import { PANEL_HTML } from "./panel-html.js";
|
|
4
4
|
|
|
5
5
|
/** keep at most this many messages per chat in the in-memory store. */
|
|
@@ -53,6 +53,52 @@ export interface PanelAttachment {
|
|
|
53
53
|
mimeType?: string;
|
|
54
54
|
}
|
|
55
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
|
+
|
|
56
102
|
/** pull every downloadable attachment out of a telegram message. */
|
|
57
103
|
function extractAttachments(message: Record<string, unknown>): PanelAttachment[] {
|
|
58
104
|
const out: PanelAttachment[] = [];
|
|
@@ -75,6 +121,66 @@ function extractAttachments(message: Record<string, unknown>): PanelAttachment[]
|
|
|
75
121
|
return out;
|
|
76
122
|
}
|
|
77
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
|
+
|
|
78
184
|
/** best-effort one-line label for a message: its text/caption, else a `[media]` tag. */
|
|
79
185
|
function describe(message: Record<string, unknown> | undefined): string | undefined {
|
|
80
186
|
if (!message) return undefined;
|
|
@@ -83,7 +189,7 @@ function describe(message: Record<string, unknown> | undefined): string | undefi
|
|
|
83
189
|
if (typeof text === "string") return text;
|
|
84
190
|
|
|
85
191
|
const att = extractAttachments(message);
|
|
86
|
-
if (att.length > 0) return `[${att[0]
|
|
192
|
+
if (att.length > 0) return `[${att[0]?.type}]`;
|
|
87
193
|
|
|
88
194
|
for (const kind of TAG_KINDS) {
|
|
89
195
|
if (message[kind] !== undefined) return `[${kind}]`;
|
|
@@ -97,10 +203,14 @@ export interface PanelMessage {
|
|
|
97
203
|
/** caption / text, or a `[kind]` placeholder when the message is media-only. */
|
|
98
204
|
text: string;
|
|
99
205
|
date: number;
|
|
100
|
-
/** downloadable attachments, fetched lazily through `GET /api/file?id
|
|
206
|
+
/** downloadable attachments, fetched lazily through `GET /api/file?id=...`. */
|
|
101
207
|
attachments?: PanelAttachment[];
|
|
102
208
|
/** telegram album id — consecutive messages sharing it are one media group. */
|
|
103
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;
|
|
104
214
|
}
|
|
105
215
|
|
|
106
216
|
/** build a {@link PanelMessage} from a telegram message, or undefined if nothing to log. */
|
|
@@ -112,12 +222,14 @@ function toPanelMessage(
|
|
|
112
222
|
|
|
113
223
|
const text = describe(message);
|
|
114
224
|
const attachments = extractAttachments(message);
|
|
115
|
-
|
|
225
|
+
const keyboard = extractKeyboard(message);
|
|
226
|
+
if (text === undefined && attachments.length === 0 && !keyboard) return undefined;
|
|
116
227
|
|
|
117
228
|
const date = typeof message.date === "number" ? message.date : Math.floor(Date.now() / 1000);
|
|
118
229
|
const msg: PanelMessage = { direction, text: text ?? "", date };
|
|
119
230
|
if (attachments.length > 0) msg.attachments = attachments;
|
|
120
231
|
if (typeof message.media_group_id === "string") msg.mediaGroupId = message.media_group_id;
|
|
232
|
+
if (keyboard) msg.keyboard = keyboard;
|
|
121
233
|
|
|
122
234
|
return msg;
|
|
123
235
|
}
|
|
@@ -125,8 +237,13 @@ function toPanelMessage(
|
|
|
125
237
|
export interface PanelChat {
|
|
126
238
|
id: number;
|
|
127
239
|
name: string;
|
|
240
|
+
firstName?: string;
|
|
241
|
+
lastName?: string;
|
|
242
|
+
username?: string;
|
|
128
243
|
lastText: string;
|
|
129
244
|
lastDate: number;
|
|
245
|
+
lastAttachmentType?: AttachmentType;
|
|
246
|
+
lastEventType?: PanelMessageEventType;
|
|
130
247
|
}
|
|
131
248
|
|
|
132
249
|
/** options for reading a slice of a conversation. */
|
|
@@ -146,7 +263,7 @@ export interface PanelEvent {
|
|
|
146
263
|
|
|
147
264
|
/** where conversations are kept for the panel to read. implement for persistence. */
|
|
148
265
|
export interface PanelStore {
|
|
149
|
-
record(chat:
|
|
266
|
+
record(chat: PanelChatRecord, message: PanelMessage): void | Promise<void>;
|
|
150
267
|
chats(): PanelChat[] | Promise<PanelChat[]>;
|
|
151
268
|
history(chatId: number, options?: HistoryOptions): PanelMessage[] | Promise<PanelMessage[]>;
|
|
152
269
|
/** optional realtime hook — return an unsubscribe fn. enables the panel's SSE stream. */
|
|
@@ -159,7 +276,7 @@ export class MemoryPanelStore implements PanelStore {
|
|
|
159
276
|
#messages = new Map<number, PanelMessage[]>();
|
|
160
277
|
#listeners = new Set<(event: PanelEvent) => void>();
|
|
161
278
|
|
|
162
|
-
record(chat:
|
|
279
|
+
record(chat: PanelChatRecord, message: PanelMessage): void {
|
|
163
280
|
const list = this.#messages.get(chat.id) ?? [];
|
|
164
281
|
|
|
165
282
|
list.push(message);
|
|
@@ -168,12 +285,25 @@ export class MemoryPanelStore implements PanelStore {
|
|
|
168
285
|
this.#messages.set(chat.id, list);
|
|
169
286
|
|
|
170
287
|
const prev = this.#chats.get(chat.id);
|
|
171
|
-
|
|
288
|
+
const next: PanelChat = {
|
|
172
289
|
id: chat.id,
|
|
173
290
|
name: chat.name ?? prev?.name ?? `chat ${chat.id}`,
|
|
174
291
|
lastText: message.text,
|
|
175
292
|
lastDate: message.date,
|
|
176
|
-
}
|
|
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);
|
|
177
307
|
|
|
178
308
|
for (const fn of this.#listeners) {
|
|
179
309
|
fn({ type: "record", chatId: chat.id, direction: message.direction });
|
|
@@ -186,8 +316,10 @@ export class MemoryPanelStore implements PanelStore {
|
|
|
186
316
|
|
|
187
317
|
history(chatId: number, options?: HistoryOptions): PanelMessage[] {
|
|
188
318
|
let list = this.#messages.get(chatId) ?? [];
|
|
319
|
+
|
|
189
320
|
if (options?.before !== undefined) list = list.filter((m) => m.date < options.before!);
|
|
190
321
|
if (options?.limit !== undefined) list = list.slice(-options.limit);
|
|
322
|
+
|
|
191
323
|
return list;
|
|
192
324
|
}
|
|
193
325
|
|
|
@@ -197,20 +329,164 @@ export class MemoryPanelStore implements PanelStore {
|
|
|
197
329
|
}
|
|
198
330
|
}
|
|
199
331
|
|
|
200
|
-
|
|
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
|
+
};
|
|
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;
|
|
459
|
+
}
|
|
460
|
+
|
|
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. */
|
|
201
486
|
export function recorder(store: PanelStore): Plugin<Context, Record<never, never>> {
|
|
202
487
|
const plugin: Plugin<Context, Record<never, never>> = (composer) =>
|
|
203
488
|
composer.use(async (ctx, next) => {
|
|
204
|
-
|
|
205
|
-
const message = toPanelMessage("in", ctx.message as Record<string, unknown> | undefined);
|
|
206
|
-
|
|
207
|
-
if (message && chat?.type === "private") {
|
|
208
|
-
const name = ctx.from?.username
|
|
209
|
-
? `@${ctx.from.username}`
|
|
210
|
-
: (ctx.from?.first_name ?? `chat ${chat.id}`);
|
|
211
|
-
|
|
212
|
-
await store.record({ id: chat.id, name }, message);
|
|
213
|
-
}
|
|
489
|
+
await recordTelegramUpdate(store, ctx.update);
|
|
214
490
|
|
|
215
491
|
await next();
|
|
216
492
|
});
|
|
@@ -223,12 +499,17 @@ export function recorder(store: PanelStore): Plugin<Context, Record<never, never
|
|
|
223
499
|
* `fileUrl` unlock media (file proxying + operator uploads) and are present on the real
|
|
224
500
|
* `@yaebal/core` `Api`. without them, media routes answer `501`.
|
|
225
501
|
*/
|
|
226
|
-
interface PanelApi {
|
|
502
|
+
export interface PanelApi {
|
|
227
503
|
sendMessage(params: Record<string, unknown>): Promise<unknown>;
|
|
228
504
|
call?<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
229
505
|
fileUrl?(filePath: string): string;
|
|
230
506
|
}
|
|
231
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
|
+
|
|
232
513
|
/** map an attachment kind to its telegram send method + param field. */
|
|
233
514
|
const SEND_METHODS: Record<AttachmentType, { method: string; field: string }> = {
|
|
234
515
|
photo: { method: "sendPhoto", field: "photo" },
|
|
@@ -265,7 +546,7 @@ function recordResult(store: PanelStore, result: unknown): void {
|
|
|
265
546
|
if (chat?.id === undefined || chat.type !== "private") return;
|
|
266
547
|
|
|
267
548
|
const message = toPanelMessage("out", raw);
|
|
268
|
-
if (message) void Promise.resolve(store.record(
|
|
549
|
+
if (message) void Promise.resolve(store.record(chatIdentity(chat.id, raw.chat), message));
|
|
269
550
|
}
|
|
270
551
|
|
|
271
552
|
/**
|
|
@@ -342,7 +623,16 @@ function corsOrigin(cors: PanelOptions["cors"], request: Request): string | unde
|
|
|
342
623
|
}
|
|
343
624
|
|
|
344
625
|
/** fields a panel client may pass through to `sendMessage` alongside `chat_id`/`text`. */
|
|
345
|
-
const SEND_PASSTHROUGH = [
|
|
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;
|
|
346
636
|
|
|
347
637
|
/** normalize a mount prefix: `""` or `/foo` (no trailing slash). */
|
|
348
638
|
function normalizeBase(basePath: string | undefined): string {
|
|
@@ -386,7 +676,9 @@ function createLimiter(config: PanelOptions["rateLimit"]) {
|
|
|
386
676
|
|
|
387
677
|
function defaultClientKey(request: Request): string {
|
|
388
678
|
const fwd = request.headers.get("x-forwarded-for");
|
|
389
|
-
|
|
679
|
+
const forwardedIp = fwd?.split(",")[0]?.trim();
|
|
680
|
+
if (forwardedIp) return forwardedIp;
|
|
681
|
+
|
|
390
682
|
return request.headers.get("x-real-ip") ?? "shared";
|
|
391
683
|
}
|
|
392
684
|
|
|
@@ -407,7 +699,9 @@ function streamResponse(store: PanelStore): Response {
|
|
|
407
699
|
};
|
|
408
700
|
|
|
409
701
|
push(": connected\n\n");
|
|
410
|
-
unsubscribe = store.subscribe?.((event) =>
|
|
702
|
+
unsubscribe = store.subscribe?.((event) =>
|
|
703
|
+
push(`event: record\ndata: ${JSON.stringify(event)}\n\n`),
|
|
704
|
+
);
|
|
411
705
|
ping = setInterval(() => push(": ping\n\n"), 25_000);
|
|
412
706
|
},
|
|
413
707
|
cancel() {
|
|
@@ -566,7 +860,7 @@ export function panelHandler(
|
|
|
566
860
|
const chatId = Number(send[1]);
|
|
567
861
|
const contentType = request.headers.get("content-type") ?? "";
|
|
568
862
|
|
|
569
|
-
// ---- operator file upload (multipart)
|
|
863
|
+
// ---- operator file upload (multipart) -> sendPhoto / sendDocument / sendVoice / ... ----
|
|
570
864
|
if (contentType.includes("multipart/form-data")) {
|
|
571
865
|
if (!api.call) {
|
|
572
866
|
return finish(json({ error: "uploads need an api with call()" }, 501));
|
|
@@ -611,10 +905,13 @@ export function panelHandler(
|
|
|
611
905
|
// record from the api result when it's a real message, else fall back to the text
|
|
612
906
|
if (result && typeof result === "object" && "chat" in result) recordResult(store, result);
|
|
613
907
|
else {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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);
|
|
618
915
|
}
|
|
619
916
|
}
|
|
620
917
|
|