@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/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
|
+
}
|