@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/src/serve.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+
3
+ /** options for the node {@link serve} helper. */
4
+ export interface ServeOptions {
5
+ /** port to listen on. */
6
+ port: number;
7
+ /** host/interface to bind. defaults to node's default (all interfaces). */
8
+ host?: string;
9
+ /** invoked once the server is listening. */
10
+ onListen?: (info: { port: number; host?: string }) => void;
11
+ }
12
+
13
+ /** translate a node request into a whatwg `Request` (body streamed for non-GET/HEAD). */
14
+ function toRequest(req: IncomingMessage): Request {
15
+ const host = req.headers.host ?? "localhost";
16
+ const url = `http://${host}${req.url ?? "/"}`;
17
+
18
+ const method = req.method ?? "GET";
19
+ const hasBody = method !== "GET" && method !== "HEAD";
20
+
21
+ return new Request(url, {
22
+ method,
23
+ headers: req.headers as Record<string, string>,
24
+ // node streams are async-iterable, which `Request` accepts as a body source
25
+ body: hasBody ? (req as unknown as ReadableStream) : undefined,
26
+ // required by undici when streaming a request body
27
+ duplex: "half",
28
+ } as RequestInit);
29
+ }
30
+
31
+ /** pipe a whatwg `Response` back out through a node `ServerResponse`. */
32
+ async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
33
+ res.writeHead(response.status, Object.fromEntries(response.headers));
34
+ res.end(Buffer.from(await response.arrayBuffer()));
35
+ }
36
+
37
+ /**
38
+ * start a native node `http` server for a fetch-style handler (e.g. {@link panelHandler}).
39
+ * zero third-party deps — just `node:http`. on bun/deno use their built-in `serve` instead.
40
+ *
41
+ * ```ts
42
+ * import { panelHandler } from "@yaebal/panel";
43
+ * import { serve } from "@yaebal/panel/serve";
44
+ * serve(panelHandler(bot.api, store, { token }), { port: 8080 });
45
+ * ```
46
+ */
47
+ export function serve(
48
+ handler: (request: Request) => Promise<Response> | Response,
49
+ options: ServeOptions,
50
+ ): Server {
51
+ const server = createServer((req, res) => {
52
+ Promise.resolve(handler(toRequest(req)))
53
+ .then((response) => writeResponse(res, response))
54
+ .catch(() => {
55
+ if (!res.headersSent) res.writeHead(500);
56
+ res.end("internal error");
57
+ });
58
+ });
59
+
60
+ server.listen(options.port, options.host, () => {
61
+ options.onListen?.({ port: options.port, host: options.host });
62
+ });
63
+
64
+ return server;
65
+ }
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ // `node:sqlite` (and therefore `SqlitePanelStore`) is only available on node 22.5+.
5
+ // on older runtimes a static import of "./sqlite.js" throws ERR_UNKNOWN_BUILTIN_MODULE
6
+ // at module-evaluation time, which would fail the whole file. import it lazily and
7
+ // skip the suite when the builtin is missing instead.
8
+ let SqlitePanelStore: typeof import("./sqlite.js").SqlitePanelStore | undefined;
9
+ try {
10
+ ({ SqlitePanelStore } = await import("./sqlite.js"));
11
+ } catch {
12
+ SqlitePanelStore = undefined;
13
+ }
14
+
15
+ const skip = SqlitePanelStore ? false : "node:sqlite unavailable (requires node 22.5+)";
16
+ // non-undefined alias for the test bodies (only reached when not skipped)
17
+ const Store = SqlitePanelStore as NonNullable<typeof SqlitePanelStore>;
18
+
19
+ test("SqlitePanelStore: records, lists newest-first, paginates and emits events", { skip }, () => {
20
+ const store = new Store(); // :memory:
21
+ const events: number[] = [];
22
+ store.subscribe((e) => events.push(e.chatId));
23
+
24
+ store.record({ id: 1, name: "@sam" }, { direction: "in", text: "hi", date: 10 });
25
+ store.record({ id: 2, name: "@lee" }, { direction: "in", text: "yo", date: 20 });
26
+ for (let i = 1; i <= 5; i++) {
27
+ store.record({ id: 1 }, { direction: "in", text: `m${i}`, date: 100 + i });
28
+ }
29
+
30
+ // chats sorted by last_date desc; name preserved across nameless records
31
+ const chats = store.chats();
32
+ assert.equal(chats[0]?.id, 1);
33
+ assert.equal(chats[0]?.name, "@sam");
34
+ assert.equal(chats[0]?.lastText, "m5");
35
+
36
+ // pagination
37
+ assert.deepEqual(
38
+ store.history(1, { limit: 2 }).map((m) => m.text),
39
+ ["m4", "m5"],
40
+ );
41
+ assert.deepEqual(
42
+ store.history(1, { before: 103, limit: 2 }).map((m) => m.text),
43
+ ["m1", "m2"],
44
+ );
45
+
46
+ assert.ok(events.length >= 2);
47
+ store.close();
48
+ });
49
+
50
+ test("SqlitePanelStore persists attachments and media_group_id round-trip", { skip }, () => {
51
+ const store = new Store();
52
+
53
+ store.record(
54
+ { id: 1, name: "@u", firstName: "Uma", lastName: "Ray", username: "u" },
55
+ {
56
+ direction: "in",
57
+ text: "[photo]",
58
+ date: 1,
59
+ attachments: [{ type: "photo", fileId: "f1" }],
60
+ mediaGroupId: "G1",
61
+ keyboard: {
62
+ type: "inline",
63
+ rows: [[{ text: "Open", kind: "callback", callbackData: "open" }]],
64
+ },
65
+ },
66
+ );
67
+ store.record(
68
+ { id: 1, name: "@u" },
69
+ {
70
+ direction: "in",
71
+ text: "button clicked: open",
72
+ date: 2,
73
+ event: { type: "callback", title: "button clicked", detail: "open", data: "open" },
74
+ },
75
+ );
76
+
77
+ const chat = store.chats()[0];
78
+ assert.equal(chat?.firstName, "Uma");
79
+ assert.equal(chat?.lastName, "Ray");
80
+ assert.equal(chat?.username, "u");
81
+ assert.equal(chat?.lastAttachmentType, undefined);
82
+ assert.equal(chat?.lastEventType, "callback");
83
+
84
+ const hist = store.history(1);
85
+ assert.deepEqual(hist[0]?.attachments, [{ type: "photo", fileId: "f1" }]);
86
+ assert.equal(hist[0]?.mediaGroupId, "G1");
87
+ assert.deepEqual(hist[0]?.keyboard, {
88
+ type: "inline",
89
+ rows: [[{ text: "Open", kind: "callback", callbackData: "open" }]],
90
+ });
91
+ // event message has no attachments/group keys
92
+ assert.equal(hist[1]?.attachments, undefined);
93
+ assert.equal(hist[1]?.mediaGroupId, undefined);
94
+ assert.equal(hist[1]?.event?.type, "callback");
95
+ store.close();
96
+ });
package/src/sqlite.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type {
3
+ HistoryOptions,
4
+ PanelAttachment,
5
+ PanelChat,
6
+ PanelChatRecord,
7
+ PanelEvent,
8
+ PanelKeyboard,
9
+ PanelMessage,
10
+ PanelMessageEvent,
11
+ PanelStore,
12
+ } from "./index.js";
13
+
14
+ function addColumn(db: DatabaseSync, table: string, definition: string): void {
15
+ try {
16
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
17
+ } catch {
18
+ // Existing sqlite stores from older panel versions simply already have the column.
19
+ }
20
+ }
21
+
22
+ /** options for {@link SqlitePanelStore}. */
23
+ export interface SqlitePanelStoreOptions {
24
+ /** sqlite file path, or `":memory:"` (default). ignored when `db` is provided. */
25
+ path?: string;
26
+ /** bring your own `node:sqlite` database instead of opening one. */
27
+ db?: DatabaseSync;
28
+ }
29
+
30
+ /**
31
+ * a persistent {@link PanelStore} backed by node's built-in `node:sqlite` (node 22.5+).
32
+ * zero third-party deps. import from `@yaebal/panel/sqlite`.
33
+ *
34
+ * ```ts
35
+ * import { SqlitePanelStore } from "@yaebal/panel/sqlite";
36
+ * const store = new SqlitePanelStore({ path: "./panel.db" });
37
+ * ```
38
+ */
39
+ export class SqlitePanelStore implements PanelStore {
40
+ #db: DatabaseSync;
41
+ #listeners = new Set<(event: PanelEvent) => void>();
42
+
43
+ constructor(options: SqlitePanelStoreOptions = {}) {
44
+ this.#db = options.db ?? new DatabaseSync(options.path ?? ":memory:");
45
+ this.#db.exec(`
46
+ CREATE TABLE IF NOT EXISTS panel_chats (
47
+ id INTEGER PRIMARY KEY,
48
+ name TEXT,
49
+ first_name TEXT,
50
+ last_name TEXT,
51
+ username TEXT,
52
+ last_text TEXT NOT NULL,
53
+ last_date INTEGER NOT NULL,
54
+ last_attachment TEXT,
55
+ last_event TEXT
56
+ );
57
+ CREATE TABLE IF NOT EXISTS panel_messages (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ chat_id INTEGER NOT NULL,
60
+ direction TEXT NOT NULL,
61
+ text TEXT NOT NULL,
62
+ date INTEGER NOT NULL,
63
+ attachments TEXT,
64
+ media_group TEXT,
65
+ keyboard TEXT,
66
+ event TEXT
67
+ );
68
+ CREATE INDEX IF NOT EXISTS panel_messages_chat ON panel_messages (chat_id, date);
69
+ `);
70
+
71
+ addColumn(this.#db, "panel_chats", "first_name TEXT");
72
+ addColumn(this.#db, "panel_chats", "last_name TEXT");
73
+ addColumn(this.#db, "panel_chats", "username TEXT");
74
+ addColumn(this.#db, "panel_chats", "last_attachment TEXT");
75
+ addColumn(this.#db, "panel_chats", "last_event TEXT");
76
+ addColumn(this.#db, "panel_messages", "keyboard TEXT");
77
+ addColumn(this.#db, "panel_messages", "event TEXT");
78
+ }
79
+
80
+ record(chat: PanelChatRecord, message: PanelMessage): void {
81
+ this.#db
82
+ .prepare(
83
+ "INSERT INTO panel_messages (chat_id, direction, text, date, attachments, media_group, keyboard, event) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
84
+ )
85
+ .run(
86
+ chat.id,
87
+ message.direction,
88
+ message.text,
89
+ message.date,
90
+ message.attachments ? JSON.stringify(message.attachments) : null,
91
+ message.mediaGroupId ?? null,
92
+ message.keyboard ? JSON.stringify(message.keyboard) : null,
93
+ message.event ? JSON.stringify(message.event) : null,
94
+ );
95
+
96
+ // keep the name when an outgoing message omits it; COALESCE the existing row's value
97
+ this.#db
98
+ .prepare(`
99
+ INSERT INTO panel_chats (
100
+ id, name, first_name, last_name, username, last_text, last_date, last_attachment, last_event
101
+ ) VALUES (
102
+ :id, :name, :firstName, :lastName, :username, :text, :date, :lastAttachment, :lastEvent
103
+ )
104
+ ON CONFLICT(id) DO UPDATE SET
105
+ name = COALESCE(:name, panel_chats.name),
106
+ first_name = COALESCE(:firstName, panel_chats.first_name),
107
+ last_name = COALESCE(:lastName, panel_chats.last_name),
108
+ username = COALESCE(:username, panel_chats.username),
109
+ last_text = :text,
110
+ last_date = :date,
111
+ last_attachment = :lastAttachment,
112
+ last_event = :lastEvent
113
+ `)
114
+ .run({
115
+ id: chat.id,
116
+ name: chat.name ?? null,
117
+ firstName: chat.firstName ?? null,
118
+ lastName: chat.lastName ?? null,
119
+ username: chat.username ?? null,
120
+ text: message.text,
121
+ date: message.date,
122
+ lastAttachment: message.attachments?.[0]?.type ?? null,
123
+ lastEvent: message.event?.type ?? null,
124
+ });
125
+
126
+ // brand-new chat with no name → give it a stable fallback label
127
+ this.#db
128
+ .prepare("UPDATE panel_chats SET name = ? WHERE id = ? AND name IS NULL")
129
+ .run(`chat ${chat.id}`, chat.id);
130
+
131
+ for (const fn of this.#listeners) {
132
+ fn({ type: "record", chatId: chat.id, direction: message.direction });
133
+ }
134
+ }
135
+
136
+ chats(): PanelChat[] {
137
+ const rows = this.#db
138
+ .prepare(
139
+ "SELECT id, name, first_name, last_name, username, last_text, last_date, last_attachment, last_event FROM panel_chats ORDER BY last_date DESC",
140
+ )
141
+ .all() as Array<{
142
+ id: number;
143
+ name: string | null;
144
+ first_name: string | null;
145
+ last_name: string | null;
146
+ username: string | null;
147
+ last_text: string;
148
+ last_date: number;
149
+ last_attachment: PanelChat["lastAttachmentType"] | null;
150
+ last_event: PanelChat["lastEventType"] | null;
151
+ }>;
152
+
153
+ return rows.map((r) => {
154
+ const chat: PanelChat = {
155
+ id: r.id,
156
+ name: r.name ?? `chat ${r.id}`,
157
+ lastText: r.last_text,
158
+ lastDate: r.last_date,
159
+ };
160
+
161
+ if (r.first_name) chat.firstName = r.first_name;
162
+ if (r.last_name) chat.lastName = r.last_name;
163
+ if (r.username) chat.username = r.username;
164
+ if (r.last_attachment) chat.lastAttachmentType = r.last_attachment;
165
+ if (r.last_event) chat.lastEventType = r.last_event;
166
+
167
+ return chat;
168
+ });
169
+ }
170
+
171
+ history(chatId: number, options?: HistoryOptions): PanelMessage[] {
172
+ const before = options?.before ?? Number.MAX_SAFE_INTEGER;
173
+ const limit = options?.limit ?? -1; // sqlite: negative LIMIT = no limit
174
+
175
+ // grab the most recent `limit` rows older than `before`, then return ascending
176
+ const rows = this.#db
177
+ .prepare(`
178
+ SELECT direction, text, date, attachments, media_group, keyboard, event FROM panel_messages
179
+ WHERE chat_id = ? AND date < ?
180
+ ORDER BY date DESC, id DESC LIMIT ?
181
+ `)
182
+ .all(chatId, before, limit) as Array<{
183
+ direction: "in" | "out";
184
+ text: string;
185
+ date: number;
186
+ attachments: string | null;
187
+ media_group: string | null;
188
+ keyboard: string | null;
189
+ event: string | null;
190
+ }>;
191
+
192
+ return rows.reverse().map((r) => {
193
+ const msg: PanelMessage = { direction: r.direction, text: r.text, date: r.date };
194
+
195
+ if (r.attachments) msg.attachments = JSON.parse(r.attachments) as PanelAttachment[];
196
+ if (r.media_group) msg.mediaGroupId = r.media_group;
197
+ if (r.keyboard) msg.keyboard = JSON.parse(r.keyboard) as PanelKeyboard;
198
+ if (r.event) msg.event = JSON.parse(r.event) as PanelMessageEvent;
199
+
200
+ return msg;
201
+ });
202
+ }
203
+
204
+ subscribe(listener: (event: PanelEvent) => void): () => void {
205
+ this.#listeners.add(listener);
206
+ return () => this.#listeners.delete(listener);
207
+ }
208
+
209
+ /** close the underlying database (no-op if you passed your own `db`). */
210
+ close(): void {
211
+ this.#db.close();
212
+ }
213
+ }