@yaebal/panel 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neverlane
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @yaebal/panel
2
+
3
+ an operator panel for [yaebal](https://github.com/neverlane/yaebal) bots: view
4
+ incoming private-chat messages and reply from the browser. ships as a
5
+ self-contained `fetch` handler — mount it on any HTTP framework.
6
+
7
+ ## install
8
+
9
+ ```sh
10
+ pnpm add @yaebal/panel
11
+ ```
12
+
13
+ ## usage
14
+
15
+ ```ts
16
+ import { Bot } from "@yaebal/core";
17
+ import { MemoryPanelStore, recorder, panelHandler } from "@yaebal/panel";
18
+
19
+ const bot = new Bot(token);
20
+ const store = new MemoryPanelStore();
21
+
22
+ bot.install(recorder(store)); // log incoming private messages
23
+ bot.start();
24
+
25
+ // serve the panel (auth via a required token)
26
+ const handler = panelHandler(bot.api, store, { token: process.env.PANEL_TOKEN! });
27
+ // handler: (Request) => Promise<Response> — open /?token=<PANEL_TOKEN>
28
+ ```
29
+
30
+ implement `PanelStore` (`record` / `chats` / `history`) to persist conversations
31
+ in redis, postgres, etc. instead of the in-memory default.
32
+
33
+ ---
34
+
35
+ part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
package/lib/index.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Context, Plugin } from "@yaebal/core";
2
+ export { PANEL_HTML } from "./panel-html.js";
3
+ export interface PanelMessage {
4
+ direction: "in" | "out";
5
+ text: string;
6
+ date: number;
7
+ }
8
+ export interface PanelChat {
9
+ id: number;
10
+ name: string;
11
+ lastText: string;
12
+ lastDate: number;
13
+ }
14
+ /** where conversations are kept for the panel to read. implement for persistence. */
15
+ export interface PanelStore {
16
+ record(chat: {
17
+ id: number;
18
+ name?: string;
19
+ }, message: PanelMessage): void | Promise<void>;
20
+ chats(): PanelChat[] | Promise<PanelChat[]>;
21
+ history(chatId: number): PanelMessage[] | Promise<PanelMessage[]>;
22
+ }
23
+ /** defaults to in-memory store. Lost on restart — swap for a persistent one in production. */
24
+ export declare class MemoryPanelStore implements PanelStore {
25
+ #private;
26
+ record(chat: {
27
+ id: number;
28
+ name?: string;
29
+ }, message: PanelMessage): void;
30
+ chats(): PanelChat[];
31
+ history(chatId: number): PanelMessage[];
32
+ }
33
+ /** records incoming private-chat text into the store so the panel can show it. */
34
+ export declare function recorder(store: PanelStore): Plugin<Context, Record<never, never>>;
35
+ interface SendApi {
36
+ sendMessage(params: Record<string, unknown>): Promise<unknown>;
37
+ }
38
+ export interface PanelOptions {
39
+ /** shared secret required to open the panel and call its api. */
40
+ token: string;
41
+ }
42
+ /**
43
+ * a fetch-style handler for the operator panel: serves the ui at `/`, and a small
44
+ * api to list chats, read a conversation, and send a reply. mount it on any
45
+ * fetch-compatible server. open it at `/?token=<your token>`.
46
+ */
47
+ export declare function panelHandler(api: SendApi, store: PanelStore, options: PanelOptions): (request: Request) => Promise<Response>;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAgBpD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,IAAI,GAAG,KAAK,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,SAAS;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IAC1B,MAAM,CAAC,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzF,KAAK,IAAI,SAAS,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IAC5C,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAClE;AAED,8FAA8F;AAC9F,qBAAa,gBAAiB,YAAW,UAAU;;IAIlD,MAAM,CAAC,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI;IAiBxE,KAAK,IAAI,SAAS,EAAE;IAIpB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,EAAE;CAGvC;AAED,kFAAkF;AAClF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAqBjF;AAED,UAAU,OAAO;IAChB,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,YAAY;IAC5B,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;CACd;AAQD;;;;GAIG;AACH,wBAAgB,YAAY,CAC3B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,YAAY,GACnB,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAuDzC"}
package/lib/index.js ADDED
@@ -0,0 +1,106 @@
1
+ import { PANEL_HTML } from "./panel-html.js";
2
+ /** keep at most this many messages per chat in the in-memory store. */
3
+ const MAX_HISTORY = 1000;
4
+ /** constant-time compare (pure js — runs on node, bun, deno and edge/web). */
5
+ function safeEqual(a, b) {
6
+ if (a.length !== b.length)
7
+ return false;
8
+ let diff = 0;
9
+ for (let i = 0; i < a.length; i++)
10
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
11
+ return diff === 0;
12
+ }
13
+ export { PANEL_HTML } from "./panel-html.js";
14
+ /** defaults to in-memory store. Lost on restart — swap for a persistent one in production. */
15
+ export class MemoryPanelStore {
16
+ #chats = new Map();
17
+ #messages = new Map();
18
+ record(chat, message) {
19
+ const list = this.#messages.get(chat.id) ?? [];
20
+ list.push(message);
21
+ if (list.length > MAX_HISTORY)
22
+ list.shift();
23
+ this.#messages.set(chat.id, list);
24
+ const prev = this.#chats.get(chat.id);
25
+ this.#chats.set(chat.id, {
26
+ id: chat.id,
27
+ name: chat.name ?? prev?.name ?? `chat ${chat.id}`,
28
+ lastText: message.text,
29
+ lastDate: message.date,
30
+ });
31
+ }
32
+ chats() {
33
+ return [...this.#chats.values()].sort((a, b) => b.lastDate - a.lastDate);
34
+ }
35
+ history(chatId) {
36
+ return this.#messages.get(chatId) ?? [];
37
+ }
38
+ }
39
+ /** records incoming private-chat text into the store so the panel can show it. */
40
+ export function recorder(store) {
41
+ const plugin = (composer) => composer.use(async (ctx, next) => {
42
+ const text = ctx.text;
43
+ const chat = ctx.chat;
44
+ if (text !== undefined && chat?.type === "private") {
45
+ const name = ctx.from?.username
46
+ ? `@${ctx.from.username}`
47
+ : (ctx.from?.first_name ?? `chat ${chat.id}`);
48
+ await store.record({ id: chat.id, name }, { direction: "in", text, date: Math.floor(Date.now() / 1000) });
49
+ }
50
+ await next();
51
+ });
52
+ return plugin;
53
+ }
54
+ const json = (data, status = 200) => new Response(JSON.stringify(data), {
55
+ status,
56
+ headers: { "content-type": "application/json", "x-content-type-options": "nosniff" },
57
+ });
58
+ /**
59
+ * a fetch-style handler for the operator panel: serves the ui at `/`, and a small
60
+ * api to list chats, read a conversation, and send a reply. mount it on any
61
+ * fetch-compatible server. open it at `/?token=<your token>`.
62
+ */
63
+ export function panelHandler(api, store, options) {
64
+ if (!options.token)
65
+ throw new Error("panelHandler: a non-empty token is required");
66
+ return async (request) => {
67
+ const url = new URL(request.url);
68
+ const provided = url.searchParams.get("token") ??
69
+ request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ??
70
+ "";
71
+ // fail closed: reject empty/missing tokens and use a constant-time compare
72
+ if (!provided || !safeEqual(provided, options.token)) {
73
+ return new Response("unauthorized", { status: 401 });
74
+ }
75
+ if (url.pathname === "/" && request.method === "GET") {
76
+ return new Response(PANEL_HTML, {
77
+ headers: {
78
+ "content-type": "text/html; charset=utf-8",
79
+ // the token rides in the URL on this initial load — keep it out of Referer
80
+ "referrer-policy": "no-referrer",
81
+ "content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
82
+ "x-content-type-options": "nosniff",
83
+ },
84
+ });
85
+ }
86
+ if (url.pathname === "/api/chats" && request.method === "GET") {
87
+ return json(await store.chats());
88
+ }
89
+ const get = url.pathname.match(/^\/api\/chats\/(-?\d+)$/);
90
+ if (get?.[1] && request.method === "GET") {
91
+ return json(await store.history(Number(get[1])));
92
+ }
93
+ const send = url.pathname.match(/^\/api\/chats\/(-?\d+)\/send$/);
94
+ if (send?.[1] && request.method === "POST") {
95
+ const chatId = Number(send[1]);
96
+ const body = (await request.json().catch(() => ({})));
97
+ if (!body.text)
98
+ return json({ error: "text required" }, 400);
99
+ await api.sendMessage({ chat_id: chatId, text: body.text });
100
+ await store.record({ id: chatId }, { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) });
101
+ return json({ ok: true });
102
+ }
103
+ return json({ error: "not found" }, 404);
104
+ };
105
+ }
106
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context } from "@yaebal/core";
4
+ import { MemoryPanelStore, panelHandler, recorder } from "./index.js";
5
+ const noop = async () => { };
6
+ const entry = (c) => c.toMiddleware();
7
+ test("MemoryPanelStore records, preserves names, sorts and reads history", () => {
8
+ const s = new MemoryPanelStore();
9
+ s.record({ id: 1, name: "@a" }, { direction: "in", text: "hi", date: 10 });
10
+ s.record({ id: 1 }, { direction: "out", text: "yo", date: 20 }); // no name → keep "@a"
11
+ s.record({ id: 2, name: "@b" }, { direction: "in", text: "hey", date: 30 });
12
+ assert.equal(s.chats()[0]?.id, 2); // most recent first
13
+ assert.equal(s.chats().find((c) => c.id === 1)?.name, "@a");
14
+ assert.equal(s.history(1).length, 2);
15
+ assert.deepEqual(s.history(2), [{ direction: "in", text: "hey", date: 30 }]);
16
+ });
17
+ test("recorder logs incoming private text into the store", async () => {
18
+ const store = new MemoryPanelStore();
19
+ const api = {};
20
+ const mw = entry(new Composer().install(recorder(store)));
21
+ const ctx = new Context({
22
+ api,
23
+ update: {
24
+ update_id: 1,
25
+ message: {
26
+ message_id: 1,
27
+ date: 0,
28
+ chat: { id: 5, type: "private" },
29
+ from: { id: 5, is_bot: false, first_name: "Sam", username: "sam" },
30
+ text: "hello",
31
+ },
32
+ },
33
+ updateType: "message",
34
+ });
35
+ await mw(ctx, noop);
36
+ const hist = store.history(5);
37
+ assert.equal(hist.length, 1);
38
+ assert.equal(hist[0]?.text, "hello");
39
+ assert.equal(hist[0]?.direction, "in");
40
+ assert.equal(store.chats()[0]?.name, "@sam");
41
+ });
42
+ function fakePanel() {
43
+ const sent = [];
44
+ const api = {
45
+ sendMessage: (p) => {
46
+ sent.push(p);
47
+ return Promise.resolve({ message_id: 1 });
48
+ },
49
+ };
50
+ const store = new MemoryPanelStore();
51
+ store.record({ id: 1, name: "@sam" }, { direction: "in", text: "hi", date: 1 });
52
+ return { handler: panelHandler(api, store, { token: "secret" }), sent, store };
53
+ }
54
+ test("panelHandler refuses to construct with an empty token", () => {
55
+ const store = new MemoryPanelStore();
56
+ const api = { sendMessage: () => Promise.resolve({}) };
57
+ assert.throws(() => panelHandler(api, store, { token: "" }), /non-empty token/);
58
+ });
59
+ test("panel API rejects requests without the token", async () => {
60
+ const { handler } = fakePanel();
61
+ const res = await handler(new Request("http://x/api/chats"));
62
+ assert.equal(res.status, 401);
63
+ });
64
+ test("panel API lists chats and serves the UI with a token", async () => {
65
+ const { handler } = fakePanel();
66
+ const chatsRes = await handler(new Request("http://x/api/chats", { headers: { authorization: "Bearer secret" } }));
67
+ assert.equal(chatsRes.status, 200);
68
+ const chats = (await chatsRes.json());
69
+ assert.equal(chats[0]?.name, "@sam");
70
+ const ui = await handler(new Request("http://x/?token=secret"));
71
+ assert.equal(ui.headers.get("content-type"), "text/html; charset=utf-8");
72
+ assert.match(await ui.text(), /yaebal panel/);
73
+ });
74
+ test("panel send posts via the api and logs an outgoing message", async () => {
75
+ const { handler, sent, store } = fakePanel();
76
+ const res = await handler(new Request("http://x/api/chats/1/send?token=secret", {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({ text: "yo" }),
80
+ }));
81
+ assert.equal(res.status, 200);
82
+ assert.deepEqual(sent, [{ chat_id: 1, text: "yo" }]);
83
+ const last = store.history(1).at(-1);
84
+ assert.equal(last?.direction, "out");
85
+ assert.equal(last?.text, "yo");
86
+ });
87
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAmB,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtE,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE,GAAE,CAAC,CAAC;AAC5B,MAAM,KAAK,GAAG,CAAoB,CAAc,EAAE,EAAE,CACnD,CAAC,CAAC,YAAY,EAAoC,CAAC;AAEpD,IAAI,CAAC,oEAAoE,EAAE,GAAG,EAAE;IAC/E,MAAM,CAAC,GAAG,IAAI,gBAAgB,EAAE,CAAC;IAEjC,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAC3E,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,sBAAsB;IACvF,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAE5E,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,oBAAoB;IACvD,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;IAC5D,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACrC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAC9E,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;IACrE,MAAM,KAAK,GAAG,IAAI,gBAAgB,EAAE,CAAC;IAErC,MAAM,GAAG,GAAG,EAAW,CAAC;IACxB,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,QAAQ,EAAW,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEnE,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC;QACvB,GAAG;QACH,MAAM,EAAE;YACP,SAAS,EAAE,CAAC;YACZ,OAAO,EAAE;gBACR,UAAU,EAAE,CAAC;gBACb,IAAI,EAAE,CAAC;gBACP,IAAI,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE;gBAChC,IAAI,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE;gBAClE,IAAI,EAAE,OAAO;aACb;SACQ;QACV,UAAU,EAAE,SAAS;KACrB,CAAC,CAAC;IAEH,MAAM,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAEpB,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAE9B,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7B,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;IACrC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;IACvC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;AAC9C,CAAC,CAAC,CAAC;AAEH,SAAS,SAAS;IACjB,MAAM,IAAI,GAAmC,EAAE,CAAC;IAChD,MAAM,GAAG,GAAG;QACX,WAAW,EAAE,CAAC,CAA0B,EAAE,EAAE;YAC3C,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACb,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;QAC3C,CAAC;KACD,CAAC;IAEF,MAAM,KAAK,GAAG,IAAI,gBAAgB,EAAE,CAAC;IACrC,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;IAEhF,OAAO,EAAE,OAAO,EAAE,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAChF,CAAC;AAED,IAAI,CAAC,uDAAuD,EAAE,GAAG,EAAE;IAClE,MAAM,KAAK,GAAG,IAAI,gBAAgB,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;IAEvD,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,EAAE,iBAAiB,CAAC,CAAC;AACjF,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;IAC/D,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAAC;IAChC,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAC;IAE7D,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAC/B,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sDAAsD,EAAE,KAAK,IAAI,EAAE;IACvE,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC7B,IAAI,OAAO,CAAC,oBAAoB,EAAE,EAAE,OAAO,EAAE,EAAE,aAAa,EAAE,eAAe,EAAE,EAAE,CAAC,CAClF,CAAC;IACF,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAEnC,MAAM,KAAK,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA4B,CAAC;IACjE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;IAErC,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAAC,CAAC;IAChE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,0BAA0B,CAAC,CAAC;IACzE,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,cAAc,CAAC,CAAC;AAC/C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;IAC5E,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,SAAS,EAAE,CAAC;IAE7C,MAAM,GAAG,GAAG,MAAM,OAAO,CACxB,IAAI,OAAO,CAAC,wCAAwC,EAAE;QACrD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;KACpC,CAAC,CACF,CAAC;IACF,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC9B,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAErD,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACrC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IACrC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AAChC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** the operator panel ui — a single static page that talks to the panel api. */
2
+ export declare const PANEL_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>yaebal panel</title>\n<style>\n :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --text:#e6e8eb; }\n * { box-sizing: border-box; }\n body { margin:0; font:14px/1.5 system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; }\n #chats { width:280px; border-right:1px solid var(--line); overflow-y:auto; flex:none; }\n #chats h1 { font-size:13px; color:var(--muted); padding:14px 16px; margin:0; letter-spacing:.5px; text-transform:lowercase; }\n .chat { padding:10px 16px; border-bottom:1px solid var(--line); cursor:pointer; }\n .chat:hover, .chat.on { background:var(--panel); }\n .chat .n { font-weight:500; }\n .chat .l { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n #main { flex:1; display:flex; flex-direction:column; min-width:0; }\n #log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:8px; }\n .msg { max-width:70%; padding:8px 12px; border-radius:12px; white-space:pre-wrap; word-break:break-word; }\n .msg.in { background:var(--panel); align-self:flex-start; }\n .msg.out { background:var(--accent); color:#fff; align-self:flex-end; }\n #composer { display:flex; gap:8px; padding:12px; border-top:1px solid var(--line); }\n #composer input { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:9px 12px; font:inherit; }\n #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 16px; cursor:pointer; font:inherit; }\n #empty { margin:auto; color:var(--muted); }\n</style>\n</head>\n<body>\n<div id=\"chats\"><h1>chats</h1></div>\n<div id=\"main\"><div id=\"empty\">select a chat</div></div>\n<script>\nconst token = new URLSearchParams(location.search).get(\"token\") || \"\";\nconst api = (p, opt = {}) => fetch(p, { ...opt, headers: { ...(opt.headers||{}), authorization: \"Bearer \" + token } });\nlet active = null;\nconst el = (t, c, x) => { const e = document.createElement(t); if (c) e.className = c; if (x != null) e.textContent = x; return e; };\n\nasync function loadChats() {\n const chats = await (await api(\"/api/chats\")).json();\n const box = document.getElementById(\"chats\");\n box.querySelectorAll(\".chat\").forEach(n => n.remove());\n for (const c of chats) {\n const d = el(\"div\", \"chat\" + (c.id === active ? \" on\" : \"\"));\n d.append(el(\"div\", \"n\", c.name), el(\"div\", \"l\", c.lastText));\n d.onclick = () => openChat(c.id);\n box.append(d);\n }\n}\nasync function openChat(id) {\n active = id;\n await loadChats();\n const msgs = await (await api(\"/api/chats/\" + id)).json();\n const main = document.getElementById(\"main\");\n main.innerHTML = \"\";\n const log = el(\"div\"); log.id = \"log\";\n for (const m of msgs) log.append(el(\"div\", \"msg \" + m.direction, m.text));\n const form = el(\"form\"); form.id = \"composer\";\n const input = el(\"input\"); input.placeholder = \"reply\u2026\"; input.autocomplete = \"off\";\n const btn = el(\"button\", null, \"send\"); btn.type = \"submit\";\n form.append(input, btn);\n form.onsubmit = async (e) => {\n e.preventDefault();\n const text = input.value.trim(); if (!text) return;\n input.value = \"\";\n await api(\"/api/chats/\" + id + \"/send\", { method:\"POST\", headers:{\"content-type\":\"application/json\"}, body: JSON.stringify({ text }) });\n openChat(id);\n };\n main.append(log, form);\n log.scrollTop = log.scrollHeight;\n}\nloadChats();\nsetInterval(() => (active ? openChat(active) : loadChats()), 4000);\n</script>\n</body>\n</html>";
3
+ //# sourceMappingURL=panel-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel-html.d.ts","sourceRoot":"","sources":["../src/panel-html.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,eAAO,MAAM,UAAU,uxHAyEf,CAAC"}
@@ -0,0 +1,76 @@
1
+ /** the operator panel ui — a single static page that talks to the panel api. */
2
+ export const PANEL_HTML = `<!doctype html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>yaebal panel</title>
8
+ <style>
9
+ :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --text:#e6e8eb; }
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; }
15
+ .chat:hover, .chat.on { background:var(--panel); }
16
+ .chat .n { font-weight:500; }
17
+ .chat .l { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18
+ #main { flex:1; display:flex; flex-direction:column; min-width:0; }
19
+ #log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:8px; }
20
+ .msg { max-width:70%; padding:8px 12px; border-radius:12px; white-space:pre-wrap; word-break:break-word; }
21
+ .msg.in { background:var(--panel); align-self:flex-start; }
22
+ .msg.out { background:var(--accent); color:#fff; align-self:flex-end; }
23
+ #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; }
25
+ #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 16px; cursor:pointer; font:inherit; }
26
+ #empty { margin:auto; color:var(--muted); }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="chats"><h1>chats</h1></div>
31
+ <div id="main"><div id="empty">select a chat</div></div>
32
+ <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;
36
+ const el = (t, c, x) => { const e = document.createElement(t); if (c) e.className = c; if (x != null) e.textContent = x; return e; };
37
+
38
+ async function loadChats() {
39
+ const chats = await (await api("/api/chats")).json();
40
+ const box = document.getElementById("chats");
41
+ box.querySelectorAll(".chat").forEach(n => n.remove());
42
+ for (const c of chats) {
43
+ const d = el("div", "chat" + (c.id === active ? " on" : ""));
44
+ d.append(el("div", "n", c.name), el("div", "l", c.lastText));
45
+ d.onclick = () => openChat(c.id);
46
+ box.append(d);
47
+ }
48
+ }
49
+ async function openChat(id) {
50
+ active = id;
51
+ await loadChats();
52
+ const msgs = await (await api("/api/chats/" + id)).json();
53
+ const main = document.getElementById("main");
54
+ main.innerHTML = "";
55
+ const log = el("div"); log.id = "log";
56
+ for (const m of msgs) log.append(el("div", "msg " + m.direction, m.text));
57
+ const form = el("form"); form.id = "composer";
58
+ const input = el("input"); input.placeholder = "reply…"; input.autocomplete = "off";
59
+ const btn = el("button", null, "send"); btn.type = "submit";
60
+ form.append(input, btn);
61
+ form.onsubmit = async (e) => {
62
+ e.preventDefault();
63
+ const text = input.value.trim(); if (!text) return;
64
+ input.value = "";
65
+ await api("/api/chats/" + id + "/send", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ text }) });
66
+ openChat(id);
67
+ };
68
+ main.append(log, form);
69
+ log.scrollTop = log.scrollHeight;
70
+ }
71
+ loadChats();
72
+ setInterval(() => (active ? openChat(active) : loadChats()), 4000);
73
+ </script>
74
+ </body>
75
+ </html>`;
76
+ //# sourceMappingURL=panel-html.js.map
@@ -0,0 +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"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@yaebal/panel",
3
+ "version": "0.0.1",
4
+ "description": "yaebal panel — an operator panel: view chats and reply from the browser.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "@yaebal/core": "0.0.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "latest"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "keywords": [
28
+ "telegram",
29
+ "telegram-bot",
30
+ "yaebal",
31
+ "panel",
32
+ "dashboard",
33
+ "live-chat"
34
+ ],
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/neverlane/yaebal",
39
+ "directory": "packages/panel"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "test": "node --test lib"
48
+ }
49
+ }
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context, type Middleware } from "@yaebal/core";
4
+ import { MemoryPanelStore, panelHandler, recorder } from "./index.js";
5
+
6
+ const noop = async () => {};
7
+ const entry = <C extends Context>(c: Composer<C>) =>
8
+ c.toMiddleware() as unknown as Middleware<Context>;
9
+
10
+ test("MemoryPanelStore records, preserves names, sorts and reads history", () => {
11
+ const s = new MemoryPanelStore();
12
+
13
+ s.record({ id: 1, name: "@a" }, { direction: "in", text: "hi", date: 10 });
14
+ s.record({ id: 1 }, { direction: "out", text: "yo", date: 20 }); // no name → keep "@a"
15
+ s.record({ id: 2, name: "@b" }, { direction: "in", text: "hey", date: 30 });
16
+
17
+ assert.equal(s.chats()[0]?.id, 2); // most recent first
18
+ assert.equal(s.chats().find((c) => c.id === 1)?.name, "@a");
19
+ assert.equal(s.history(1).length, 2);
20
+ assert.deepEqual(s.history(2), [{ direction: "in", text: "hey", date: 30 }]);
21
+ });
22
+
23
+ test("recorder logs incoming private text into the store", async () => {
24
+ const store = new MemoryPanelStore();
25
+
26
+ const api = {} as never;
27
+ const mw = entry(new Composer<Context>().install(recorder(store)));
28
+
29
+ const ctx = new Context({
30
+ api,
31
+ update: {
32
+ update_id: 1,
33
+ message: {
34
+ message_id: 1,
35
+ date: 0,
36
+ chat: { id: 5, type: "private" },
37
+ from: { id: 5, is_bot: false, first_name: "Sam", username: "sam" },
38
+ text: "hello",
39
+ },
40
+ } as never,
41
+ updateType: "message",
42
+ });
43
+
44
+ await mw(ctx, noop);
45
+
46
+ const hist = store.history(5);
47
+
48
+ assert.equal(hist.length, 1);
49
+ assert.equal(hist[0]?.text, "hello");
50
+ assert.equal(hist[0]?.direction, "in");
51
+ assert.equal(store.chats()[0]?.name, "@sam");
52
+ });
53
+
54
+ function fakePanel() {
55
+ const sent: Array<Record<string, unknown>> = [];
56
+ const api = {
57
+ sendMessage: (p: Record<string, unknown>) => {
58
+ sent.push(p);
59
+ return Promise.resolve({ message_id: 1 });
60
+ },
61
+ };
62
+
63
+ const store = new MemoryPanelStore();
64
+ store.record({ id: 1, name: "@sam" }, { direction: "in", text: "hi", date: 1 });
65
+
66
+ return { handler: panelHandler(api, store, { token: "secret" }), sent, store };
67
+ }
68
+
69
+ test("panelHandler refuses to construct with an empty token", () => {
70
+ const store = new MemoryPanelStore();
71
+ const api = { sendMessage: () => Promise.resolve({}) };
72
+
73
+ assert.throws(() => panelHandler(api, store, { token: "" }), /non-empty token/);
74
+ });
75
+
76
+ test("panel API rejects requests without the token", async () => {
77
+ const { handler } = fakePanel();
78
+ const res = await handler(new Request("http://x/api/chats"));
79
+
80
+ assert.equal(res.status, 401);
81
+ });
82
+
83
+ test("panel API lists chats and serves the UI with a token", async () => {
84
+ const { handler } = fakePanel();
85
+
86
+ const chatsRes = await handler(
87
+ new Request("http://x/api/chats", { headers: { authorization: "Bearer secret" } }),
88
+ );
89
+ assert.equal(chatsRes.status, 200);
90
+
91
+ const chats = (await chatsRes.json()) as Array<{ name: string }>;
92
+ assert.equal(chats[0]?.name, "@sam");
93
+
94
+ const ui = await handler(new Request("http://x/?token=secret"));
95
+ assert.equal(ui.headers.get("content-type"), "text/html; charset=utf-8");
96
+ assert.match(await ui.text(), /yaebal panel/);
97
+ });
98
+
99
+ test("panel send posts via the api and logs an outgoing message", async () => {
100
+ const { handler, sent, store } = fakePanel();
101
+
102
+ const res = await handler(
103
+ new Request("http://x/api/chats/1/send?token=secret", {
104
+ method: "POST",
105
+ headers: { "content-type": "application/json" },
106
+ body: JSON.stringify({ text: "yo" }),
107
+ }),
108
+ );
109
+ assert.equal(res.status, 200);
110
+ assert.deepEqual(sent, [{ chat_id: 1, text: "yo" }]);
111
+
112
+ const last = store.history(1).at(-1);
113
+ assert.equal(last?.direction, "out");
114
+ assert.equal(last?.text, "yo");
115
+ });
package/src/index.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { Context, Plugin } from "@yaebal/core";
2
+ import { PANEL_HTML } from "./panel-html.js";
3
+
4
+ /** keep at most this many messages per chat in the in-memory store. */
5
+ const MAX_HISTORY = 1000;
6
+
7
+ /** constant-time compare (pure js — runs on node, bun, deno and edge/web). */
8
+ function safeEqual(a: string, b: string): boolean {
9
+ if (a.length !== b.length) return false;
10
+
11
+ let diff = 0;
12
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
13
+
14
+ return diff === 0;
15
+ }
16
+
17
+ export { PANEL_HTML } from "./panel-html.js";
18
+
19
+ export interface PanelMessage {
20
+ direction: "in" | "out";
21
+ text: string;
22
+ date: number;
23
+ }
24
+
25
+ export interface PanelChat {
26
+ id: number;
27
+ name: string;
28
+ lastText: string;
29
+ lastDate: number;
30
+ }
31
+
32
+ /** where conversations are kept for the panel to read. implement for persistence. */
33
+ export interface PanelStore {
34
+ record(chat: { id: number; name?: string }, message: PanelMessage): void | Promise<void>;
35
+ chats(): PanelChat[] | Promise<PanelChat[]>;
36
+ history(chatId: number): PanelMessage[] | Promise<PanelMessage[]>;
37
+ }
38
+
39
+ /** defaults to in-memory store. Lost on restart — swap for a persistent one in production. */
40
+ export class MemoryPanelStore implements PanelStore {
41
+ #chats = new Map<number, PanelChat>();
42
+ #messages = new Map<number, PanelMessage[]>();
43
+
44
+ record(chat: { id: number; name?: string }, message: PanelMessage): void {
45
+ const list = this.#messages.get(chat.id) ?? [];
46
+
47
+ list.push(message);
48
+ if (list.length > MAX_HISTORY) list.shift();
49
+
50
+ this.#messages.set(chat.id, list);
51
+
52
+ const prev = this.#chats.get(chat.id);
53
+ this.#chats.set(chat.id, {
54
+ id: chat.id,
55
+ name: chat.name ?? prev?.name ?? `chat ${chat.id}`,
56
+ lastText: message.text,
57
+ lastDate: message.date,
58
+ });
59
+ }
60
+
61
+ chats(): PanelChat[] {
62
+ return [...this.#chats.values()].sort((a, b) => b.lastDate - a.lastDate);
63
+ }
64
+
65
+ history(chatId: number): PanelMessage[] {
66
+ return this.#messages.get(chatId) ?? [];
67
+ }
68
+ }
69
+
70
+ /** records incoming private-chat text into the store so the panel can show it. */
71
+ export function recorder(store: PanelStore): Plugin<Context, Record<never, never>> {
72
+ const plugin: Plugin<Context, Record<never, never>> = (composer) =>
73
+ composer.use(async (ctx, next) => {
74
+ const text = ctx.text;
75
+ const chat = ctx.chat;
76
+
77
+ if (text !== undefined && chat?.type === "private") {
78
+ const name = ctx.from?.username
79
+ ? `@${ctx.from.username}`
80
+ : (ctx.from?.first_name ?? `chat ${chat.id}`);
81
+
82
+ await store.record(
83
+ { id: chat.id, name },
84
+ { direction: "in", text, date: Math.floor(Date.now() / 1000) },
85
+ );
86
+ }
87
+
88
+ await next();
89
+ });
90
+
91
+ return plugin;
92
+ }
93
+
94
+ interface SendApi {
95
+ sendMessage(params: Record<string, unknown>): Promise<unknown>;
96
+ }
97
+
98
+ export interface PanelOptions {
99
+ /** shared secret required to open the panel and call its api. */
100
+ token: string;
101
+ }
102
+
103
+ const json = (data: unknown, status = 200): Response =>
104
+ new Response(JSON.stringify(data), {
105
+ status,
106
+ headers: { "content-type": "application/json", "x-content-type-options": "nosniff" },
107
+ });
108
+
109
+ /**
110
+ * a fetch-style handler for the operator panel: serves the ui at `/`, and a small
111
+ * api to list chats, read a conversation, and send a reply. mount it on any
112
+ * fetch-compatible server. open it at `/?token=<your token>`.
113
+ */
114
+ export function panelHandler(
115
+ api: SendApi,
116
+ store: PanelStore,
117
+ options: PanelOptions,
118
+ ): (request: Request) => Promise<Response> {
119
+ if (!options.token) throw new Error("panelHandler: a non-empty token is required");
120
+
121
+ return async (request) => {
122
+ const url = new URL(request.url);
123
+ const provided =
124
+ url.searchParams.get("token") ??
125
+ request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ??
126
+ "";
127
+
128
+ // fail closed: reject empty/missing tokens and use a constant-time compare
129
+ if (!provided || !safeEqual(provided, options.token)) {
130
+ return new Response("unauthorized", { status: 401 });
131
+ }
132
+
133
+ if (url.pathname === "/" && request.method === "GET") {
134
+ return new Response(PANEL_HTML, {
135
+ headers: {
136
+ "content-type": "text/html; charset=utf-8",
137
+ // the token rides in the URL on this initial load — keep it out of Referer
138
+ "referrer-policy": "no-referrer",
139
+ "content-security-policy":
140
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
141
+ "x-content-type-options": "nosniff",
142
+ },
143
+ });
144
+ }
145
+
146
+ if (url.pathname === "/api/chats" && request.method === "GET") {
147
+ return json(await store.chats());
148
+ }
149
+
150
+ const get = url.pathname.match(/^\/api\/chats\/(-?\d+)$/);
151
+ if (get?.[1] && request.method === "GET") {
152
+ return json(await store.history(Number(get[1])));
153
+ }
154
+
155
+ const send = url.pathname.match(/^\/api\/chats\/(-?\d+)\/send$/);
156
+ if (send?.[1] && request.method === "POST") {
157
+ const chatId = Number(send[1]);
158
+ const body = (await request.json().catch(() => ({}))) as { text?: string };
159
+
160
+ if (!body.text) return json({ error: "text required" }, 400);
161
+
162
+ await api.sendMessage({ chat_id: chatId, text: body.text });
163
+ await store.record(
164
+ { id: chatId },
165
+ { direction: "out", text: body.text, date: Math.floor(Date.now() / 1000) },
166
+ );
167
+
168
+ return json({ ok: true });
169
+ }
170
+
171
+ return json({ error: "not found" }, 404);
172
+ };
173
+ }
@@ -0,0 +1,75 @@
1
+ /** the operator panel ui — a single static page that talks to the panel api. */
2
+ export const PANEL_HTML = `<!doctype html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>yaebal panel</title>
8
+ <style>
9
+ :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --text:#e6e8eb; }
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; }
15
+ .chat:hover, .chat.on { background:var(--panel); }
16
+ .chat .n { font-weight:500; }
17
+ .chat .l { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18
+ #main { flex:1; display:flex; flex-direction:column; min-width:0; }
19
+ #log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:8px; }
20
+ .msg { max-width:70%; padding:8px 12px; border-radius:12px; white-space:pre-wrap; word-break:break-word; }
21
+ .msg.in { background:var(--panel); align-self:flex-start; }
22
+ .msg.out { background:var(--accent); color:#fff; align-self:flex-end; }
23
+ #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; }
25
+ #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 16px; cursor:pointer; font:inherit; }
26
+ #empty { margin:auto; color:var(--muted); }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="chats"><h1>chats</h1></div>
31
+ <div id="main"><div id="empty">select a chat</div></div>
32
+ <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;
36
+ const el = (t, c, x) => { const e = document.createElement(t); if (c) e.className = c; if (x != null) e.textContent = x; return e; };
37
+
38
+ async function loadChats() {
39
+ const chats = await (await api("/api/chats")).json();
40
+ const box = document.getElementById("chats");
41
+ box.querySelectorAll(".chat").forEach(n => n.remove());
42
+ for (const c of chats) {
43
+ const d = el("div", "chat" + (c.id === active ? " on" : ""));
44
+ d.append(el("div", "n", c.name), el("div", "l", c.lastText));
45
+ d.onclick = () => openChat(c.id);
46
+ box.append(d);
47
+ }
48
+ }
49
+ async function openChat(id) {
50
+ active = id;
51
+ await loadChats();
52
+ const msgs = await (await api("/api/chats/" + id)).json();
53
+ const main = document.getElementById("main");
54
+ main.innerHTML = "";
55
+ const log = el("div"); log.id = "log";
56
+ for (const m of msgs) log.append(el("div", "msg " + m.direction, m.text));
57
+ const form = el("form"); form.id = "composer";
58
+ const input = el("input"); input.placeholder = "reply…"; input.autocomplete = "off";
59
+ const btn = el("button", null, "send"); btn.type = "submit";
60
+ form.append(input, btn);
61
+ form.onsubmit = async (e) => {
62
+ e.preventDefault();
63
+ const text = input.value.trim(); if (!text) return;
64
+ input.value = "";
65
+ await api("/api/chats/" + id + "/send", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ text }) });
66
+ openChat(id);
67
+ };
68
+ main.append(log, form);
69
+ log.scrollTop = log.scrollHeight;
70
+ }
71
+ loadChats();
72
+ setInterval(() => (active ? openChat(active) : loadChats()), 4000);
73
+ </script>
74
+ </body>
75
+ </html>`;