@yaebal/morda 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.
@@ -0,0 +1,191 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context, type Middleware } from "@yaebal/core";
4
+ import { MemoryStorage } from "@yaebal/session";
5
+ import { type DialogDef, type DialogState, back, button, dialogs, switchTo } from "./index.js";
6
+
7
+ const noop = async () => {};
8
+ const entry = <C extends Context>(c: Composer<C>) =>
9
+ c.toMiddleware() as unknown as Middleware<Context>;
10
+
11
+ interface Call {
12
+ method: string;
13
+ // biome-ignore lint/suspicious/noExplicitAny: test recorder captures arbitrary params
14
+ params: any;
15
+ }
16
+
17
+ function fakeApi() {
18
+ const calls: Call[] = [];
19
+ let nextId = 100;
20
+
21
+ const api = {
22
+ sendMessage(params: Record<string, unknown>) {
23
+ calls.push({ method: "sendMessage", params });
24
+ return Promise.resolve({ message_id: nextId++ });
25
+ },
26
+ answerCallbackQuery(params: Record<string, unknown>) {
27
+ calls.push({ method: "answerCallbackQuery", params });
28
+ return Promise.resolve(true);
29
+ },
30
+ call(method: string, params: Record<string, unknown>) {
31
+ calls.push({ method, params });
32
+ return Promise.resolve(true);
33
+ },
34
+ } as never;
35
+
36
+ return { api, calls };
37
+ }
38
+
39
+ const msgCtx = (api: never, text: string, chatId: number) =>
40
+ new Context({
41
+ api,
42
+ update: {
43
+ update_id: 1,
44
+ message: {
45
+ message_id: 1,
46
+ date: 0,
47
+ chat: { id: chatId, type: "private" },
48
+ from: { id: chatId, is_bot: false, first_name: "u" },
49
+ text,
50
+ },
51
+ } as never,
52
+ updateType: "message",
53
+ });
54
+
55
+ const cbCtx = (api: never, data: string, chatId: number, messageId: number) =>
56
+ new Context({
57
+ api,
58
+ update: {
59
+ update_id: 1,
60
+ callback_query: {
61
+ id: "cb",
62
+ from: { id: chatId, is_bot: false, first_name: "u" },
63
+ message: { message_id: messageId, date: 0, chat: { id: chatId, type: "private" } },
64
+ data,
65
+ },
66
+ } as never,
67
+ updateType: "callback_query",
68
+ });
69
+
70
+ const def: DialogDef = {
71
+ main: () => ({
72
+ text: "main",
73
+ keyboard: [
74
+ [switchTo("settings →", "settings")],
75
+ [button("ping", { id: "ping", onClick: (c) => c.answerCallbackQuery({ text: "pong" }) })],
76
+ ],
77
+ }),
78
+ settings: () => ({ text: "Settings", keyboard: [[back("← Back")]] }),
79
+ };
80
+
81
+ // read a button's callback_data straight from a recorded keyboard — no coupling
82
+ // to morda's internal encoding.
83
+ // biome-ignore lint/suspicious/noExplicitAny: reaching into recorded params
84
+ const dataAt = (params: any, row: number, col: number): string =>
85
+ params.reply_markup.inline_keyboard[row][col].callback_data;
86
+
87
+ test("start → push(settings) → back navigates and edits in place", async () => {
88
+ const { api, calls } = fakeApi();
89
+
90
+ const storage = new MemoryStorage<DialogState>();
91
+ const mw = entry(
92
+ new Composer<Context>()
93
+ .install(dialogs(def, { storage }))
94
+ .command("go", (ctx) => ctx.dialog.start("main")),
95
+ );
96
+
97
+ await mw(msgCtx(api, "/go", 1), noop);
98
+
99
+ const sent = calls.find((c) => c.method === "sendMessage");
100
+ assert.equal(sent?.params.text, "main");
101
+
102
+ const state = await storage.get("1");
103
+ assert.deepEqual(state?.stack, ["main"]);
104
+
105
+ // press "settings →"
106
+ calls.length = 0;
107
+
108
+ await mw(cbCtx(api, dataAt(sent?.params, 0, 0), 1, state?.messageId ?? 0), noop);
109
+ const edit1 = calls.find((c) => c.method === "editMessageText");
110
+
111
+ assert.equal(edit1?.params.text, "Settings");
112
+ assert.deepEqual((await storage.get("1"))?.stack, ["main", "settings"]);
113
+
114
+ // press "← back"
115
+ calls.length = 0;
116
+
117
+ await mw(cbCtx(api, dataAt(edit1?.params, 0, 0), 1, state?.messageId ?? 0), noop);
118
+ const edit2 = calls.find((c) => c.method === "editMessageText");
119
+
120
+ assert.equal(edit2?.params.text, "main");
121
+ assert.deepEqual((await storage.get("1"))?.stack, ["main"]);
122
+ });
123
+
124
+ test("onClick runs without navigating", async () => {
125
+ const { api, calls } = fakeApi();
126
+
127
+ const storage = new MemoryStorage<DialogState>();
128
+ const mw = entry(
129
+ new Composer<Context>()
130
+ .install(dialogs(def, { storage }))
131
+ .command("go", (ctx) => ctx.dialog.start("main")),
132
+ );
133
+
134
+ await mw(msgCtx(api, "/go", 1), noop);
135
+ const sent = calls.find((c) => c.method === "sendMessage");
136
+ calls.length = 0;
137
+
138
+ // press "ping" (row 1)
139
+ await mw(cbCtx(api, dataAt(sent?.params, 1, 0), 1, 100), noop);
140
+
141
+ assert.ok(calls.some((c) => c.method === "answerCallbackQuery" && c.params.text === "pong"));
142
+ assert.equal(
143
+ calls.find((c) => c.method === "editMessageText"),
144
+ undefined,
145
+ );
146
+ assert.deepEqual((await storage.get("1"))?.stack, ["main"]); // unchanged
147
+ });
148
+
149
+ test("a press whose window is not the stack top is ignored", async () => {
150
+ const { api, calls } = fakeApi();
151
+
152
+ const storage = new MemoryStorage<DialogState>();
153
+ const mw = entry(
154
+ new Composer<Context>()
155
+ .install(dialogs(def, { storage }))
156
+ .command("go", (ctx) => ctx.dialog.start("main")),
157
+ );
158
+
159
+ await mw(msgCtx(api, "/go", 1), noop);
160
+ const sent = calls.find((c) => c.method === "sendMessage");
161
+ // navigate to settings → stack top is now "settings"
162
+ await mw(cbCtx(api, dataAt(sent?.params, 0, 0), 1, 100), noop);
163
+ calls.length = 0;
164
+
165
+ // press the OLD "ping" button from main (payload.w = "main") while on "settings"
166
+ await mw(cbCtx(api, dataAt(sent?.params, 1, 0), 1, 100), noop);
167
+ assert.equal(
168
+ calls.some((c) => c.method === "answerCallbackQuery" && c.params.text === "pong"),
169
+ false, // stale onClick did NOT run
170
+ );
171
+ assert.deepEqual((await storage.get("1"))?.stack, ["main", "settings"]); // unchanged
172
+ });
173
+
174
+ test("back() at the root closes the dialog", async () => {
175
+ const { api, calls } = fakeApi();
176
+
177
+ const storage = new MemoryStorage<DialogState>();
178
+ const mw = entry(
179
+ new Composer<Context>()
180
+ .install(dialogs(def, { storage }))
181
+ .command("go", (ctx) => ctx.dialog.start("main"))
182
+ .command("close", (ctx) => ctx.dialog.back()),
183
+ );
184
+
185
+ await mw(msgCtx(api, "/go", 2), noop);
186
+ calls.length = 0;
187
+ await mw(msgCtx(api, "/close", 2), noop);
188
+
189
+ assert.ok(calls.some((c) => c.method === "deleteMessage"));
190
+ assert.equal(await storage.get("2"), undefined);
191
+ });
package/src/index.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { callbackData } from "@yaebal/callback-data";
2
+ import type { Context, Plugin } from "@yaebal/core";
3
+ import { InlineKeyboard, type InlineKeyboardMarkup } from "@yaebal/keyboard";
4
+ import { MemoryStorage, type StorageAdapter } from "@yaebal/session";
5
+
6
+ /** context inside a dialog: the base context plus the navigation control. */
7
+ export type DialogContext = Context & { dialog: DialogControl };
8
+
9
+ /** a single button. `onClick` may navigate via `ctx.dialog`. */
10
+ export interface Button {
11
+ id: string;
12
+ label: string;
13
+ onClick?: (ctx: DialogContext) => unknown;
14
+ }
15
+
16
+ /** what a window renders to: a message text and rows of buttons. */
17
+ export interface WindowView {
18
+ text: string;
19
+ keyboard?: Button[][];
20
+ }
21
+
22
+ /** a window is rendered on demand, so its text/buttons can depend on context. */
23
+ export type WindowRender = (ctx: DialogContext) => WindowView | Promise<WindowView>;
24
+
25
+ /** a dialog is a flat map of named windows. */
26
+ export type DialogDef = Record<string, WindowRender>;
27
+
28
+ /** navigation control exposed as `ctx.dialog`. */
29
+ export interface DialogControl {
30
+ /** open a window in a fresh message + stack. */
31
+ start(windowId: string): Promise<void>;
32
+ /** push a window onto the stack and edit the message. */
33
+ push(windowId: string): Promise<void>;
34
+ /** replace the top window and edit the message. */
35
+ replace(windowId: string): Promise<void>;
36
+ /** pop the stack; closing the dialog (deleting the message) at the root. */
37
+ back(): Promise<void>;
38
+ /** re-render the current window in place (after mutating external state). */
39
+ rerender(): Promise<void>;
40
+ }
41
+
42
+ /** persisted per-chat navigation state. */
43
+ export interface DialogState {
44
+ stack: string[];
45
+ messageId: number;
46
+ chatId: number;
47
+ }
48
+
49
+ export interface DialogsOptions {
50
+ /** where to persist navigation state. defaults to in-memory (lost on restart). */
51
+ storage?: StorageAdapter<DialogState>;
52
+ /** fired when a window leaves the stack (popped, replaced, or dialog restarted).
53
+ * the JSX layer uses this to drop a window's hook state so it re-mounts fresh. */
54
+ onLeave?: (chatId: number, windowId: string) => void;
55
+ }
56
+
57
+ /** A button that navigates to another window. */
58
+ export function switchTo(label: string, windowId: string): Button {
59
+ return { id: `to:${windowId}`, label, onClick: (ctx) => ctx.dialog.push(windowId) };
60
+ }
61
+
62
+ /** A button that pops the stack. */
63
+ export function back(label = "← Назад"): Button {
64
+ return { id: "back", label, onClick: (ctx) => ctx.dialog.back() };
65
+ }
66
+
67
+ /** A plain action button. */
68
+ export function button(
69
+ label: string,
70
+ opts: { id: string; onClick?: (ctx: DialogContext) => unknown },
71
+ ): Button {
72
+ return { id: opts.id, label, onClick: opts.onClick };
73
+ }
74
+
75
+ function findButton(view: WindowView, id: string): Button | undefined {
76
+ for (const row of view.keyboard ?? []) {
77
+ for (const b of row) if (b.id === id) return b;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Dialogs plugin. Renders declarative windows, routes button presses by stable
84
+ * id, and maintains a per-chat navigation stack — no manual `editMessageText`
85
+ * or callback_data wrangling.
86
+ *
87
+ * ponytail: builder-API has no auto re-render on state change — call
88
+ * `ctx.dialog.rerender()` after mutating data a window reads. The JSX/hooks
89
+ * layer (M2b) automates this via `useState`.
90
+ */
91
+ export function dialogs(
92
+ def: DialogDef,
93
+ options: DialogsOptions = {},
94
+ ): Plugin<Context, { dialog: DialogControl }> {
95
+ const storage = options.storage ?? new MemoryStorage<DialogState>();
96
+ const cd = callbackData("dlg", { w: String, b: String });
97
+
98
+ const renderKeyboard = (windowId: string, view: WindowView): InlineKeyboardMarkup => {
99
+ const kb = new InlineKeyboard();
100
+ for (const row of view.keyboard ?? []) {
101
+ for (const b of row) kb.text(b.label, cd.pack({ w: windowId, b: b.id }));
102
+ kb.row();
103
+ }
104
+ return kb.build();
105
+ };
106
+
107
+ const renderWindow = async (w: string, ctx: DialogContext): Promise<WindowView> => {
108
+ const window = def[w];
109
+ if (!window) throw new Error(`morda: unknown window "${w}"`);
110
+ return window(ctx);
111
+ };
112
+
113
+ const control = (ctx: Context): DialogControl => {
114
+ const dctx = ctx as DialogContext;
115
+ const chatId = ctx.chat?.id;
116
+ const key = chatId?.toString();
117
+
118
+ const load = async (): Promise<DialogState | undefined> =>
119
+ key === undefined ? undefined : storage.get(key);
120
+
121
+ const edit = async (st: DialogState): Promise<void> => {
122
+ const w = st.stack[st.stack.length - 1];
123
+ if (w === undefined || key === undefined) return;
124
+ const view = await renderWindow(w, dctx);
125
+ await ctx.api.call("editMessageText", {
126
+ chat_id: st.chatId,
127
+ message_id: st.messageId,
128
+ text: view.text,
129
+ reply_markup: renderKeyboard(w, view),
130
+ });
131
+ await storage.set(key, st);
132
+ };
133
+
134
+ return {
135
+ async start(w) {
136
+ if (chatId === undefined || key === undefined) {
137
+ throw new Error("morda: start() requires a chat");
138
+ }
139
+ const old = await storage.get(key);
140
+ if (old) for (const win of old.stack) options.onLeave?.(chatId, win);
141
+ const view = await renderWindow(w, dctx);
142
+ const msg = await ctx.send(view.text, { reply_markup: renderKeyboard(w, view) });
143
+ await storage.set(key, { stack: [w], messageId: msg.message_id, chatId });
144
+ },
145
+ async push(w) {
146
+ const st = await load();
147
+ if (!st) throw new Error("morda: push() with no active dialog");
148
+ st.stack.push(w);
149
+ await edit(st);
150
+ },
151
+ async replace(w) {
152
+ const st = await load();
153
+ if (!st) throw new Error("morda: replace() with no active dialog");
154
+ const oldTop = st.stack[st.stack.length - 1];
155
+ if (oldTop !== undefined && chatId !== undefined) options.onLeave?.(chatId, oldTop);
156
+ st.stack[st.stack.length - 1] = w;
157
+ await edit(st);
158
+ },
159
+ async back() {
160
+ const st = await load();
161
+ if (!st || key === undefined) return;
162
+ const popped = st.stack.pop();
163
+ if (popped !== undefined && chatId !== undefined) options.onLeave?.(chatId, popped);
164
+ if (st.stack.length === 0) {
165
+ await ctx.api.call("deleteMessage", {
166
+ chat_id: st.chatId,
167
+ message_id: st.messageId,
168
+ });
169
+ await storage.delete(key);
170
+ return;
171
+ }
172
+ await edit(st);
173
+ },
174
+ async rerender() {
175
+ const st = await load();
176
+ if (st) await edit(st);
177
+ },
178
+ };
179
+ };
180
+
181
+ // the Out type flows from `derive`, so no unsafe plugin cast is needed
182
+ const plugin: Plugin<Context, { dialog: DialogControl }> = (composer) =>
183
+ composer
184
+ // expose ctx.dialog on every update
185
+ .derive((ctx) => ({ dialog: control(ctx) }))
186
+ // route dialog button presses; pass through everything else
187
+ .use(async (ctx, next) => {
188
+ const data = ctx.callbackQuery?.data;
189
+ if (data === undefined || !cd.filter(data)) return next();
190
+ const payload = cd.unpack(data);
191
+ if (!payload) return next();
192
+ // clear the loading spinner; harmless if onClick answers again below
193
+ const answer = () => ctx.answerCallbackQuery().catch(() => {});
194
+ // stale-press guard: ignore presses whose window isn't the live stack top
195
+ // (e.g. a double-tap on an old keyboard before the edit landed).
196
+ const key = ctx.chat?.id?.toString();
197
+ const state = key === undefined ? undefined : await storage.get(key);
198
+ if (!state || state.stack[state.stack.length - 1] !== payload.w) {
199
+ await answer();
200
+ return;
201
+ }
202
+ const window = def[payload.w];
203
+ if (window) {
204
+ const view = await window(ctx as DialogContext);
205
+ const btn = findButton(view, payload.b);
206
+ if (btn?.onClick) await btn.onClick(ctx as DialogContext);
207
+ }
208
+ await answer();
209
+ });
210
+ return plugin;
211
+ }