@yaebal/panel 0.0.2 → 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/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { media } from "@yaebal/core";
2
- import type { Context, Plugin } from "@yaebal/core";
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]!.type}]`;
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
- if (text === undefined && attachments.length === 0) return undefined;
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: { id: number; name?: string }, message: PanelMessage): void | Promise<void>;
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: { id: number; name?: string }, message: PanelMessage): void {
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
- this.#chats.set(chat.id, {
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
- /** records incoming private-chat text into the store so the panel can show it. */
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
- const chat = ctx.chat;
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({ id: chat.id }, message));
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 = ["parse_mode", "reply_to_message_id", "reply_parameters"] as const;
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
- if (fwd) return fwd.split(",")[0]!.trim();
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) => push(`event: record\ndata: ${JSON.stringify(event)}\n\n`));
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) sendPhoto / sendDocument / sendVoice / ----
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
- await store.record(
615
- { id: chatId },
616
- { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) },
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