@yaebal/panel 0.0.1 → 0.0.2

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/lib/panel-html.js CHANGED
@@ -1,4 +1,4 @@
1
- /** the operator panel ui — a single static page that talks to the panel api. */
1
+ /** the operator panel ui — a single static page: token login, then the live chat view. */
2
2
  export const PANEL_HTML = `<!doctype html>
3
3
  <html lang="en">
4
4
  <head>
@@ -6,37 +6,125 @@ export const PANEL_HTML = `<!doctype html>
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>yaebal panel</title>
8
8
  <style>
9
- :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --text:#e6e8eb; }
9
+ :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --accent-2:#1b87ba; --text:#e6e8eb; --danger:#e5484d; }
10
10
  * { box-sizing: border-box; }
11
- body { margin:0; font:14px/1.5 system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; }
12
- #chats { width:280px; border-right:1px solid var(--line); overflow-y:auto; flex:none; }
13
- #chats h1 { font-size:13px; color:var(--muted); padding:14px 16px; margin:0; letter-spacing:.5px; text-transform:lowercase; }
14
- .chat { padding:10px 16px; border-bottom:1px solid var(--line); cursor:pointer; }
11
+ body { margin:0; font:14px/1.5 system-ui,-apple-system,sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; }
12
+
13
+ /* ---- login ---- */
14
+ #login { margin:auto; width:300px; display:flex; flex-direction:column; gap:14px; padding:24px; }
15
+ #login .brand { text-align:center; font-size:20px; font-weight:600; letter-spacing:-.2px; }
16
+ #login .brand b { color:var(--accent); }
17
+ #login .sub { text-align:center; color:var(--muted); font-size:12px; margin-top:-8px; }
18
+ #login input, #login button { width:100%; height:44px; border-radius:10px; font:inherit; padding:0 14px; }
19
+ #login input { background:var(--panel); border:1px solid var(--line); color:var(--text); text-align:center; }
20
+ #login input:focus { outline:none; border-color:var(--accent); }
21
+ #login button { background:var(--accent); color:#fff; border:0; cursor:pointer; font-weight:600; transition:background .15s; }
22
+ #login button:hover { background:var(--accent-2); }
23
+ #login button:disabled { opacity:.6; cursor:default; }
24
+ #login .err { color:var(--danger); font-size:12px; text-align:center; min-height:16px; }
25
+
26
+ /* ---- app ---- */
27
+ #app { display:none; flex:1; }
28
+ body.authed #login { display:none; }
29
+ body.authed #app { display:flex; }
30
+ #chats { width:280px; border-right:1px solid var(--line); overflow-y:auto; flex:none; display:flex; flex-direction:column; }
31
+ #chats .top { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; }
32
+ #chats .top h1 { font-size:13px; color:var(--muted); margin:0; letter-spacing:.5px; text-transform:lowercase; }
33
+ #chats .top button { background:none; border:0; color:var(--muted); cursor:pointer; font:inherit; font-size:12px; }
34
+ #chats .top button:hover { color:var(--text); }
35
+ .chat { padding:10px 16px; border-top:1px solid var(--line); cursor:pointer; }
15
36
  .chat:hover, .chat.on { background:var(--panel); }
16
37
  .chat .n { font-weight:500; }
17
38
  .chat .l { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18
39
  #main { flex:1; display:flex; flex-direction:column; min-width:0; }
19
40
  #log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:8px; }
41
+ #log .more { align-self:center; background:none; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:4px 14px; cursor:pointer; font:inherit; font-size:12px; }
20
42
  .msg { max-width:70%; padding:8px 12px; border-radius:12px; white-space:pre-wrap; word-break:break-word; }
21
43
  .msg.in { background:var(--panel); align-self:flex-start; }
22
44
  .msg.out { background:var(--accent); color:#fff; align-self:flex-end; }
45
+ .msg img, .msg video { max-width:280px; max-height:320px; border-radius:8px; display:block; }
46
+ .msg audio { width:260px; display:block; }
47
+ .msg .cap { margin-top:6px; }
48
+ .msg .media + .media { margin-top:6px; }
49
+ .msg .doc { display:inline-flex; align-items:center; gap:6px; color:inherit; text-decoration:none; border-bottom:1px dotted currentColor; }
50
+ .msg.album { display:flex; flex-wrap:wrap; gap:4px; max-width:300px; }
51
+ .msg.album img, .msg.album video { max-width:140px; max-height:140px; margin:0; }
52
+ .msg.album .cap { flex-basis:100%; }
23
53
  #composer { display:flex; gap:8px; padding:12px; border-top:1px solid var(--line); }
24
- #composer input { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:9px 12px; font:inherit; }
54
+ #composer input.text { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:9px 12px; font:inherit; }
25
55
  #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 16px; cursor:pointer; font:inherit; }
56
+ #composer .attach { background:var(--panel); border:1px solid var(--line); color:var(--muted); padding:0 12px; }
57
+ #composer .attach:hover { color:var(--text); }
26
58
  #empty { margin:auto; color:var(--muted); }
27
59
  </style>
28
60
  </head>
29
61
  <body>
30
- <div id="chats"><h1>chats</h1></div>
31
- <div id="main"><div id="empty">select a chat</div></div>
62
+ <form id="login">
63
+ <div class="brand"><b>@yaebal</b>/panel</div>
64
+ <div class="sub">operator panel</div>
65
+ <input id="token" type="password" placeholder="access token" autocomplete="off" autofocus />
66
+ <button id="go" type="submit">authorize</button>
67
+ <div class="err" id="err"></div>
68
+ </form>
69
+
70
+ <div id="app">
71
+ <div id="chats"><div class="top"><h1>chats</h1><button id="logout" type="button">log out</button></div></div>
72
+ <div id="main"><div id="empty">select a chat</div></div>
73
+ </div>
74
+
32
75
  <script>
33
- const token = new URLSearchParams(location.search).get("token") || "";
34
- const api = (p, opt = {}) => fetch(p, { ...opt, headers: { ...(opt.headers||{}), authorization: "Bearer " + token } });
35
- let active = null;
76
+ const BASE = "__BASE__";
77
+ const KEY = "yaebal-panel-token" + BASE;
78
+ let token = sessionStorage.getItem(KEY) || "";
79
+ let active = null, oldest = null, es = null;
80
+
36
81
  const el = (t, c, x) => { const e = document.createElement(t); if (c) e.className = c; if (x != null) e.textContent = x; return e; };
82
+ const api = (p, opt = {}) => fetch(BASE + p, { ...opt, headers: { ...(opt.headers||{}), authorization: "Bearer " + token } });
83
+
84
+ /* ---- auth ---- */
85
+ const login = document.getElementById("login");
86
+ login.onsubmit = async (e) => {
87
+ e.preventDefault();
88
+ const go = document.getElementById("go"), err = document.getElementById("err");
89
+ token = document.getElementById("token").value.trim();
90
+ if (!token) return;
91
+ go.disabled = true; err.textContent = "";
92
+ const res = await api("/api/chats").catch(() => null);
93
+ go.disabled = false;
94
+ if (!res || !res.ok) { err.textContent = "invalid token"; return; }
95
+ sessionStorage.setItem(KEY, token);
96
+ enter();
97
+ };
98
+ document.getElementById("logout").onclick = () => {
99
+ sessionStorage.removeItem(KEY); token = ""; active = null;
100
+ if (es) { es.close(); es = null; }
101
+ document.body.classList.remove("authed");
102
+ document.getElementById("token").value = "";
103
+ };
104
+
105
+ function enter() {
106
+ document.body.classList.add("authed");
107
+ loadChats();
108
+ openStream();
109
+ }
37
110
 
111
+ /* ---- realtime: instant via SSE, with a slow polling safety net ---- */
112
+ function openStream() {
113
+ if (es || !window.EventSource) return;
114
+ es = new EventSource(BASE + "/api/stream?token=" + encodeURIComponent(token));
115
+ es.addEventListener("record", (ev) => {
116
+ const e = JSON.parse(ev.data);
117
+ if (active && e.chatId === active) openChat(active, true);
118
+ loadChats();
119
+ });
120
+ es.onerror = () => {}; // EventSource auto-reconnects
121
+ }
122
+ setInterval(() => { if (token) (active ? openChat(active, true) : loadChats()); }, 8000);
123
+
124
+ /* ---- chats ---- */
38
125
  async function loadChats() {
39
- const chats = await (await api("/api/chats")).json();
126
+ const res = await api("/api/chats"); if (!res.ok) return;
127
+ const chats = await res.json();
40
128
  const box = document.getElementById("chats");
41
129
  box.querySelectorAll(".chat").forEach(n => n.remove());
42
130
  for (const c of chats) {
@@ -46,18 +134,79 @@ async function loadChats() {
46
134
  box.append(d);
47
135
  }
48
136
  }
49
- async function openChat(id) {
137
+
138
+ /* ---- media rendering ---- */
139
+ const fileSrc = (att) => BASE + "/api/file?id=" + encodeURIComponent(att.fileId) + "&token=" + encodeURIComponent(token);
140
+ const isPlaceholder = (t) => /^\[[a-z_]+\]$/.test(t);
141
+
142
+ function attEl(att) {
143
+ const src = fileSrc(att);
144
+ if (att.type === "photo" || att.type === "sticker") { const i = el("img", "media"); i.src = src; i.loading = "lazy"; return i; }
145
+ if (att.type === "video" || att.type === "animation" || att.type === "video_note") { const v = el("video", "media"); v.src = src; v.controls = true; return v; }
146
+ if (att.type === "voice" || att.type === "audio") { const a = el("audio", "media"); a.src = src; a.controls = true; return a; }
147
+ const link = el("a", "media doc", "📎 " + (att.fileName || att.type)); link.href = src; link.target = "_blank"; return link;
148
+ }
149
+
150
+ function bubble(m) {
151
+ const b = el("div", "msg " + m.direction);
152
+ const atts = m.attachments || [];
153
+ for (const a of atts) b.append(attEl(a));
154
+ if (m.text && !(atts.length && isPlaceholder(m.text))) b.append(el("div", atts.length ? "cap" : null, m.text));
155
+ return b;
156
+ }
157
+
158
+ /* merge consecutive messages sharing a media_group_id into one album bubble */
159
+ function renderMsgs(msgs) {
160
+ const out = [];
161
+ let group = null, groupId = null;
162
+ for (const m of msgs) {
163
+ if (m.mediaGroupId && m.mediaGroupId === groupId && group) {
164
+ for (const a of m.attachments || []) group.insertBefore(attEl(a), group.querySelector(".cap"));
165
+ if (m.text && !isPlaceholder(m.text)) group.append(el("div", "cap", m.text));
166
+ continue;
167
+ }
168
+ const b = bubble(m);
169
+ if (m.mediaGroupId) { b.classList.add("album"); group = b; groupId = m.mediaGroupId; }
170
+ else { group = null; groupId = null; }
171
+ out.push(b);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ async function openChat(id, keepScroll) {
50
177
  active = id;
51
- await loadChats();
52
- const msgs = await (await api("/api/chats/" + id)).json();
178
+ if (!keepScroll) loadChats();
179
+ const res = await api("/api/chats/" + id + "?limit=200"); if (!res.ok) return;
180
+ const msgs = await res.json();
181
+ oldest = msgs.length ? msgs[0].date : null;
182
+
53
183
  const main = document.getElementById("main");
184
+ const prevTop = keepScroll ? (main.querySelector("#log")?.scrollTop ?? null) : null;
54
185
  main.innerHTML = "";
186
+
55
187
  const log = el("div"); log.id = "log";
56
- for (const m of msgs) log.append(el("div", "msg " + m.direction, m.text));
188
+ if (msgs.length >= 200) {
189
+ const more = el("button", "more", "load earlier"); more.onclick = () => loadEarlier(id, log);
190
+ log.append(more);
191
+ }
192
+ for (const b of renderMsgs(msgs)) log.append(b);
193
+
57
194
  const form = el("form"); form.id = "composer";
58
- const input = el("input"); input.placeholder = "reply…"; input.autocomplete = "off";
195
+ const fileInput = el("input"); fileInput.type = "file"; fileInput.style.display = "none";
196
+ const attach = el("button", "attach", "📎"); attach.type = "button"; attach.title = "send a file";
197
+ attach.onclick = () => fileInput.click();
198
+ const input = el("input", "text"); input.placeholder = "reply…"; input.autocomplete = "off";
59
199
  const btn = el("button", null, "send"); btn.type = "submit";
60
- form.append(input, btn);
200
+ form.append(attach, fileInput, input, btn);
201
+
202
+ fileInput.onchange = async () => {
203
+ const file = fileInput.files && fileInput.files[0]; if (!file) return;
204
+ const fd = new FormData(); fd.append("file", file);
205
+ if (input.value.trim()) fd.append("caption", input.value.trim());
206
+ input.value = ""; fileInput.value = "";
207
+ await api("/api/chats/" + id + "/send", { method: "POST", body: fd }); // browser sets multipart boundary
208
+ openChat(id);
209
+ };
61
210
  form.onsubmit = async (e) => {
62
211
  e.preventDefault();
63
212
  const text = input.value.trim(); if (!text) return;
@@ -66,10 +215,24 @@ async function openChat(id) {
66
215
  openChat(id);
67
216
  };
68
217
  main.append(log, form);
69
- log.scrollTop = log.scrollHeight;
218
+ log.scrollTop = prevTop != null ? prevTop : log.scrollHeight;
70
219
  }
71
- loadChats();
72
- setInterval(() => (active ? openChat(active) : loadChats()), 4000);
220
+
221
+ async function loadEarlier(id, log) {
222
+ if (oldest == null) return;
223
+ const res = await api("/api/chats/" + id + "?limit=200&before=" + oldest); if (!res.ok) return;
224
+ const older = await res.json();
225
+ if (!older.length) { log.querySelector(".more")?.remove(); return; }
226
+ oldest = older[0].date;
227
+ const anchor = log.querySelector(".more")?.nextSibling ?? log.firstChild;
228
+ const frag = document.createDocumentFragment();
229
+ if (older.length >= 200) { const more = el("button", "more", "load earlier"); more.onclick = () => loadEarlier(id, log); frag.append(more); }
230
+ for (const b of renderMsgs(older)) frag.append(b);
231
+ log.querySelector(".more")?.remove();
232
+ log.insertBefore(frag, anchor);
233
+ }
234
+
235
+ if (token) enter();
73
236
  </script>
74
237
  </body>
75
238
  </html>`;
@@ -1 +1 @@
1
- {"version":3,"file":"panel-html.js","sourceRoot":"","sources":["../src/panel-html.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAyElB,CAAC"}
1
+ {"version":3,"file":"panel-html.js","sourceRoot":"","sources":["../src/panel-html.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA4OlB,CAAC"}
package/lib/serve.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { type Server } from "node:http";
2
+ /** options for the node {@link serve} helper. */
3
+ export interface ServeOptions {
4
+ /** port to listen on. */
5
+ port: number;
6
+ /** host/interface to bind. defaults to node's default (all interfaces). */
7
+ host?: string;
8
+ /** invoked once the server is listening. */
9
+ onListen?: (info: {
10
+ port: number;
11
+ host?: string;
12
+ }) => void;
13
+ }
14
+ /**
15
+ * start a native node `http` server for a fetch-style handler (e.g. {@link panelHandler}).
16
+ * zero third-party deps — just `node:http`. on bun/deno use their built-in `serve` instead.
17
+ *
18
+ * ```ts
19
+ * import { panelHandler } from "@yaebal/panel";
20
+ * import { serve } from "@yaebal/panel/serve";
21
+ * serve(panelHandler(bot.api, store, { token }), { port: 8080 });
22
+ * ```
23
+ */
24
+ export declare function serve(handler: (request: Request) => Promise<Response> | Response, options: ServeOptions): Server;
25
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,MAAM,EAAqC,MAAM,WAAW,CAAC;AAEjG,iDAAiD;AACjD,MAAM,WAAW,YAAY;IAC5B,yBAAyB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAC3D;AA0BD;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CACpB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,EAC3D,OAAO,EAAE,YAAY,GACnB,MAAM,CAeR"}
package/lib/serve.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createServer } from "node:http";
2
+ /** translate a node request into a whatwg `Request` (body streamed for non-GET/HEAD). */
3
+ function toRequest(req) {
4
+ const host = req.headers.host ?? "localhost";
5
+ const url = `http://${host}${req.url ?? "/"}`;
6
+ const method = req.method ?? "GET";
7
+ const hasBody = method !== "GET" && method !== "HEAD";
8
+ return new Request(url, {
9
+ method,
10
+ headers: req.headers,
11
+ // node streams are async-iterable, which `Request` accepts as a body source
12
+ body: hasBody ? req : undefined,
13
+ // required by undici when streaming a request body
14
+ duplex: "half",
15
+ });
16
+ }
17
+ /** pipe a whatwg `Response` back out through a node `ServerResponse`. */
18
+ async function writeResponse(res, response) {
19
+ res.writeHead(response.status, Object.fromEntries(response.headers));
20
+ res.end(Buffer.from(await response.arrayBuffer()));
21
+ }
22
+ /**
23
+ * start a native node `http` server for a fetch-style handler (e.g. {@link panelHandler}).
24
+ * zero third-party deps — just `node:http`. on bun/deno use their built-in `serve` instead.
25
+ *
26
+ * ```ts
27
+ * import { panelHandler } from "@yaebal/panel";
28
+ * import { serve } from "@yaebal/panel/serve";
29
+ * serve(panelHandler(bot.api, store, { token }), { port: 8080 });
30
+ * ```
31
+ */
32
+ export function serve(handler, options) {
33
+ const server = createServer((req, res) => {
34
+ Promise.resolve(handler(toRequest(req)))
35
+ .then((response) => writeResponse(res, response))
36
+ .catch(() => {
37
+ if (!res.headersSent)
38
+ res.writeHead(500);
39
+ res.end("internal error");
40
+ });
41
+ });
42
+ server.listen(options.port, options.host, () => {
43
+ options.onListen?.({ port: options.port, host: options.host });
44
+ });
45
+ return server;
46
+ }
47
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0D,YAAY,EAAE,MAAM,WAAW,CAAC;AAYjG,yFAAyF;AACzF,SAAS,SAAS,CAAC,GAAoB;IACtC,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,CAAC;IAC7C,MAAM,GAAG,GAAG,UAAU,IAAI,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,CAAC;IAE9C,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,KAAK,CAAC;IACnC,MAAM,OAAO,GAAG,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,CAAC;IAEtD,OAAO,IAAI,OAAO,CAAC,GAAG,EAAE;QACvB,MAAM;QACN,OAAO,EAAE,GAAG,CAAC,OAAiC;QAC9C,4EAA4E;QAC5E,IAAI,EAAE,OAAO,CAAC,CAAC,CAAE,GAAiC,CAAC,CAAC,CAAC,SAAS;QAC9D,mDAAmD;QACnD,MAAM,EAAE,MAAM;KACC,CAAC,CAAC;AACnB,CAAC;AAED,yEAAyE;AACzE,KAAK,UAAU,aAAa,CAAC,GAAmB,EAAE,QAAkB;IACnE,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IACrE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,KAAK,CACpB,OAA2D,EAC3D,OAAqB;IAErB,MAAM,MAAM,GAAG,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QACxC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;aACtC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;aAChD,KAAK,CAAC,GAAG,EAAE;YACX,IAAI,CAAC,GAAG,CAAC,WAAW;gBAAE,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;YACzC,GAAG,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;QAC3B,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,EAAE;QAC9C,OAAO,CAAC,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAChE,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AACf,CAAC"}
@@ -0,0 +1,32 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { HistoryOptions, PanelChat, PanelEvent, PanelMessage, PanelStore } from "./index.js";
3
+ /** options for {@link SqlitePanelStore}. */
4
+ export interface SqlitePanelStoreOptions {
5
+ /** sqlite file path, or `":memory:"` (default). ignored when `db` is provided. */
6
+ path?: string;
7
+ /** bring your own `node:sqlite` database instead of opening one. */
8
+ db?: DatabaseSync;
9
+ }
10
+ /**
11
+ * a persistent {@link PanelStore} backed by node's built-in `node:sqlite` (node 22.5+).
12
+ * zero third-party deps. import from `@yaebal/panel/sqlite`.
13
+ *
14
+ * ```ts
15
+ * import { SqlitePanelStore } from "@yaebal/panel/sqlite";
16
+ * const store = new SqlitePanelStore({ path: "./panel.db" });
17
+ * ```
18
+ */
19
+ export declare class SqlitePanelStore implements PanelStore {
20
+ #private;
21
+ constructor(options?: SqlitePanelStoreOptions);
22
+ record(chat: {
23
+ id: number;
24
+ name?: string;
25
+ }, message: PanelMessage): void;
26
+ chats(): PanelChat[];
27
+ history(chatId: number, options?: HistoryOptions): PanelMessage[];
28
+ subscribe(listener: (event: PanelEvent) => void): () => void;
29
+ /** close the underlying database (no-op if you passed your own `db`). */
30
+ close(): void;
31
+ }
32
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../src/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EACX,cAAc,EAEd,SAAS,EACT,UAAU,EACV,YAAY,EACZ,UAAU,EACV,MAAM,YAAY,CAAC;AAEpB,4CAA4C;AAC5C,MAAM,WAAW,uBAAuB;IACvC,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,EAAE,CAAC,EAAE,YAAY,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,qBAAa,gBAAiB,YAAW,UAAU;;gBAItC,OAAO,GAAE,uBAA4B;IAsBjD,MAAM,CAAC,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI;IAmCxE,KAAK,IAAI,SAAS,EAAE;IAapB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,YAAY,EAAE;IA6BjE,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI;IAK5D,yEAAyE;IACzE,KAAK,IAAI,IAAI;CAGb"}
package/lib/sqlite.js ADDED
@@ -0,0 +1,97 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * a persistent {@link PanelStore} backed by node's built-in `node:sqlite` (node 22.5+).
4
+ * zero third-party deps. import from `@yaebal/panel/sqlite`.
5
+ *
6
+ * ```ts
7
+ * import { SqlitePanelStore } from "@yaebal/panel/sqlite";
8
+ * const store = new SqlitePanelStore({ path: "./panel.db" });
9
+ * ```
10
+ */
11
+ export class SqlitePanelStore {
12
+ #db;
13
+ #listeners = new Set();
14
+ constructor(options = {}) {
15
+ this.#db = options.db ?? new DatabaseSync(options.path ?? ":memory:");
16
+ this.#db.exec(`
17
+ CREATE TABLE IF NOT EXISTS panel_chats (
18
+ id INTEGER PRIMARY KEY,
19
+ name TEXT,
20
+ last_text TEXT NOT NULL,
21
+ last_date INTEGER NOT NULL
22
+ );
23
+ CREATE TABLE IF NOT EXISTS panel_messages (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ chat_id INTEGER NOT NULL,
26
+ direction TEXT NOT NULL,
27
+ text TEXT NOT NULL,
28
+ date INTEGER NOT NULL,
29
+ attachments TEXT,
30
+ media_group TEXT
31
+ );
32
+ CREATE INDEX IF NOT EXISTS panel_messages_chat ON panel_messages (chat_id, date);
33
+ `);
34
+ }
35
+ record(chat, message) {
36
+ this.#db
37
+ .prepare("INSERT INTO panel_messages (chat_id, direction, text, date, attachments, media_group) VALUES (?, ?, ?, ?, ?, ?)")
38
+ .run(chat.id, message.direction, message.text, message.date, message.attachments ? JSON.stringify(message.attachments) : null, message.mediaGroupId ?? null);
39
+ // keep the name when an outgoing message omits it; COALESCE the existing row's value
40
+ this.#db
41
+ .prepare(`
42
+ INSERT INTO panel_chats (id, name, last_text, last_date) VALUES (:id, :name, :text, :date)
43
+ ON CONFLICT(id) DO UPDATE SET
44
+ name = COALESCE(:name, panel_chats.name),
45
+ last_text = :text,
46
+ last_date = :date
47
+ `)
48
+ .run({ id: chat.id, name: chat.name ?? null, text: message.text, date: message.date });
49
+ // brand-new chat with no name → give it a stable fallback label
50
+ this.#db
51
+ .prepare("UPDATE panel_chats SET name = ? WHERE id = ? AND name IS NULL")
52
+ .run(`chat ${chat.id}`, chat.id);
53
+ for (const fn of this.#listeners) {
54
+ fn({ type: "record", chatId: chat.id, direction: message.direction });
55
+ }
56
+ }
57
+ chats() {
58
+ const rows = this.#db
59
+ .prepare("SELECT id, name, last_text, last_date FROM panel_chats ORDER BY last_date DESC")
60
+ .all();
61
+ return rows.map((r) => ({
62
+ id: r.id,
63
+ name: r.name ?? `chat ${r.id}`,
64
+ lastText: r.last_text,
65
+ lastDate: r.last_date,
66
+ }));
67
+ }
68
+ history(chatId, options) {
69
+ const before = options?.before ?? Number.MAX_SAFE_INTEGER;
70
+ const limit = options?.limit ?? -1; // sqlite: negative LIMIT = no limit
71
+ // grab the most recent `limit` rows older than `before`, then return ascending
72
+ const rows = this.#db
73
+ .prepare(`
74
+ SELECT direction, text, date, attachments, media_group FROM panel_messages
75
+ WHERE chat_id = ? AND date < ?
76
+ ORDER BY date DESC, id DESC LIMIT ?
77
+ `)
78
+ .all(chatId, before, limit);
79
+ return rows.reverse().map((r) => {
80
+ const msg = { direction: r.direction, text: r.text, date: r.date };
81
+ if (r.attachments)
82
+ msg.attachments = JSON.parse(r.attachments);
83
+ if (r.media_group)
84
+ msg.mediaGroupId = r.media_group;
85
+ return msg;
86
+ });
87
+ }
88
+ subscribe(listener) {
89
+ this.#listeners.add(listener);
90
+ return () => this.#listeners.delete(listener);
91
+ }
92
+ /** close the underlying database (no-op if you passed your own `db`). */
93
+ close() {
94
+ this.#db.close();
95
+ }
96
+ }
97
+ //# sourceMappingURL=sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../src/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAkB3C;;;;;;;;GAQG;AACH,MAAM,OAAO,gBAAgB;IAC5B,GAAG,CAAe;IAClB,UAAU,GAAG,IAAI,GAAG,EAA+B,CAAC;IAEpD,YAAY,UAAmC,EAAE;QAChD,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,EAAE,IAAI,IAAI,YAAY,CAAC,OAAO,CAAC,IAAI,IAAI,UAAU,CAAC,CAAC;QACtE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;GAiBb,CAAC,CAAC;IACJ,CAAC;IAED,MAAM,CAAC,IAAmC,EAAE,OAAqB;QAChE,IAAI,CAAC,GAAG;aACN,OAAO,CACP,iHAAiH,CACjH;aACA,GAAG,CACH,IAAI,CAAC,EAAE,EACP,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,IAAI,EACZ,OAAO,CAAC,IAAI,EACZ,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,EAChE,OAAO,CAAC,YAAY,IAAI,IAAI,CAC5B,CAAC;QAEH,qFAAqF;QACrF,IAAI,CAAC,GAAG;aACN,OAAO,CAAC;;;;;;IAMR,CAAC;aACD,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;QAExF,gEAAgE;QAChE,IAAI,CAAC,GAAG;aACN,OAAO,CAAC,+DAA+D,CAAC;aACxE,GAAG,CAAC,QAAQ,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;QAElC,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YAClC,EAAE,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;QACvE,CAAC;IACF,CAAC;IAED,KAAK;QACJ,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG;aACnB,OAAO,CAAC,gFAAgF,CAAC;aACzF,GAAG,EAAsF,CAAC;QAE5F,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACvB,EAAE,EAAE,CAAC,CAAC,EAAE;YACR,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,QAAQ,CAAC,CAAC,EAAE,EAAE;YAC9B,QAAQ,EAAE,CAAC,CAAC,SAAS;YACrB,QAAQ,EAAE,CAAC,CAAC,SAAS;SACrB,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,CAAC,MAAc,EAAE,OAAwB;QAC/C,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM,IAAI,MAAM,CAAC,gBAAgB,CAAC;QAC1D,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,oCAAoC;QAExE,+EAA+E;QAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG;aACnB,OAAO,CAAC;;;;IAIR,CAAC;aACD,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAMzB,CAAC;QAEH,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YAC/B,MAAM,GAAG,GAAiB,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YAEjF,IAAI,CAAC,CAAC,WAAW;gBAAE,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,WAAW,CAAsB,CAAC;YACpF,IAAI,CAAC,CAAC,WAAW;gBAAE,GAAG,CAAC,YAAY,GAAG,CAAC,CAAC,WAAW,CAAC;YAEpD,OAAO,GAAG,CAAC;QACZ,CAAC,CAAC,CAAC;IACJ,CAAC;IAED,SAAS,CAAC,QAAqC;QAC9C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC9B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,yEAAyE;IACzE,KAAK;QACJ,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;IAClB,CAAC;CACD"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sqlite.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.test.d.ts","sourceRoot":"","sources":["../src/sqlite.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { SqlitePanelStore } from "./sqlite.js";
4
+ test("SqlitePanelStore: records, lists newest-first, paginates and emits events", () => {
5
+ const store = new SqlitePanelStore(); // :memory:
6
+ const events = [];
7
+ store.subscribe((e) => events.push(e.chatId));
8
+ store.record({ id: 1, name: "@sam" }, { direction: "in", text: "hi", date: 10 });
9
+ store.record({ id: 2, name: "@lee" }, { direction: "in", text: "yo", date: 20 });
10
+ for (let i = 1; i <= 5; i++) {
11
+ store.record({ id: 1 }, { direction: "in", text: `m${i}`, date: 100 + i });
12
+ }
13
+ // chats sorted by last_date desc; name preserved across nameless records
14
+ const chats = store.chats();
15
+ assert.equal(chats[0]?.id, 1);
16
+ assert.equal(chats[0]?.name, "@sam");
17
+ assert.equal(chats[0]?.lastText, "m5");
18
+ // pagination
19
+ assert.deepEqual(store.history(1, { limit: 2 }).map((m) => m.text), ["m4", "m5"]);
20
+ assert.deepEqual(store.history(1, { before: 103, limit: 2 }).map((m) => m.text), ["m1", "m2"]);
21
+ assert.ok(events.length >= 2);
22
+ store.close();
23
+ });
24
+ test("SqlitePanelStore persists attachments and media_group_id round-trip", () => {
25
+ const store = new SqlitePanelStore();
26
+ store.record({ id: 1, name: "@u" }, {
27
+ direction: "in",
28
+ text: "[photo]",
29
+ date: 1,
30
+ attachments: [{ type: "photo", fileId: "f1" }],
31
+ mediaGroupId: "G1",
32
+ });
33
+ store.record({ id: 1, name: "@u" }, { direction: "out", text: "thanks", date: 2 });
34
+ const hist = store.history(1);
35
+ assert.deepEqual(hist[0]?.attachments, [{ type: "photo", fileId: "f1" }]);
36
+ assert.equal(hist[0]?.mediaGroupId, "G1");
37
+ // plain text message has no attachments/group keys
38
+ assert.equal(hist[1]?.attachments, undefined);
39
+ assert.equal(hist[1]?.mediaGroupId, undefined);
40
+ store.close();
41
+ });
42
+ //# sourceMappingURL=sqlite.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.test.js","sourceRoot":"","sources":["../src/sqlite.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaebal/panel",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "yaebal panel — an operator panel: view chats and reply from the browser.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -9,6 +9,14 @@
9
9
  ".": {
10
10
  "types": "./lib/index.d.ts",
11
11
  "import": "./lib/index.js"
12
+ },
13
+ "./serve": {
14
+ "types": "./lib/serve.d.ts",
15
+ "import": "./lib/serve.js"
16
+ },
17
+ "./sqlite": {
18
+ "types": "./lib/sqlite.d.ts",
19
+ "import": "./lib/sqlite.js"
12
20
  }
13
21
  },
14
22
  "files": [