@treenity/mods 3.0.1 → 3.0.3

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.
Files changed (125) hide show
  1. package/agent/client.ts +2 -0
  2. package/agent/guardian.ts +492 -0
  3. package/agent/seed.ts +74 -0
  4. package/agent/server.ts +4 -0
  5. package/agent/service.ts +644 -0
  6. package/agent/types.ts +184 -0
  7. package/agent/view.tsx +431 -0
  8. package/board/view.tsx +1 -1
  9. package/brahman/helpers.ts +7 -7
  10. package/brahman/service.ts +24 -24
  11. package/brahman/types.ts +21 -21
  12. package/brahman/views/action-cards.tsx +33 -23
  13. package/brahman/views/bot-view.tsx +3 -2
  14. package/brahman/views/chat-editor.tsx +119 -124
  15. package/brahman/views/menu-editor.tsx +75 -89
  16. package/brahman/views/page-layout.tsx +10 -8
  17. package/brahman/views/tstring-input.tsx +25 -15
  18. package/canary/service.ts +18 -18
  19. package/dist/board/view.js +1 -1
  20. package/dist/board/view.js.map +1 -1
  21. package/dist/brahman/helpers.d.ts +1 -1
  22. package/dist/brahman/helpers.d.ts.map +1 -1
  23. package/dist/brahman/helpers.js +6 -6
  24. package/dist/brahman/helpers.js.map +1 -1
  25. package/dist/brahman/service.js +24 -24
  26. package/dist/brahman/service.js.map +1 -1
  27. package/dist/brahman/types.d.ts +1 -1
  28. package/dist/brahman/types.d.ts.map +1 -1
  29. package/dist/brahman/types.js +21 -21
  30. package/dist/brahman/types.js.map +1 -1
  31. package/dist/brahman/views/action-cards.d.ts.map +1 -1
  32. package/dist/brahman/views/action-cards.js +7 -4
  33. package/dist/brahman/views/action-cards.js.map +1 -1
  34. package/dist/brahman/views/bot-view.d.ts.map +1 -1
  35. package/dist/brahman/views/bot-view.js +2 -1
  36. package/dist/brahman/views/bot-view.js.map +1 -1
  37. package/dist/brahman/views/chat-editor.d.ts.map +1 -1
  38. package/dist/brahman/views/chat-editor.js +27 -18
  39. package/dist/brahman/views/chat-editor.js.map +1 -1
  40. package/dist/brahman/views/menu-editor.d.ts.map +1 -1
  41. package/dist/brahman/views/menu-editor.js +12 -16
  42. package/dist/brahman/views/menu-editor.js.map +1 -1
  43. package/dist/brahman/views/page-layout.d.ts.map +1 -1
  44. package/dist/brahman/views/page-layout.js +1 -1
  45. package/dist/brahman/views/page-layout.js.map +1 -1
  46. package/dist/brahman/views/tstring-input.d.ts.map +1 -1
  47. package/dist/brahman/views/tstring-input.js +7 -3
  48. package/dist/brahman/views/tstring-input.js.map +1 -1
  49. package/dist/canary/service.js +18 -18
  50. package/dist/canary/service.js.map +1 -1
  51. package/dist/doc/fs-codec.js +1 -1
  52. package/dist/doc/fs-codec.js.map +1 -1
  53. package/dist/doc/renderers.d.ts.map +1 -1
  54. package/dist/doc/renderers.js +2 -1
  55. package/dist/doc/renderers.js.map +1 -1
  56. package/dist/doc/toolbar.d.ts.map +1 -1
  57. package/dist/doc/toolbar.js +5 -5
  58. package/dist/doc/toolbar.js.map +1 -1
  59. package/dist/launcher/types.js +2 -2
  60. package/dist/launcher/types.js.map +1 -1
  61. package/dist/launcher/view.js +2 -2
  62. package/dist/launcher/view.js.map +1 -1
  63. package/dist/mindmap/branch.d.ts +10 -0
  64. package/dist/mindmap/branch.d.ts.map +1 -1
  65. package/dist/mindmap/branch.js +42 -9
  66. package/dist/mindmap/branch.js.map +1 -1
  67. package/dist/mindmap/sidebar.d.ts.map +1 -1
  68. package/dist/mindmap/sidebar.js +4 -3
  69. package/dist/mindmap/sidebar.js.map +1 -1
  70. package/dist/mindmap/view.d.ts.map +1 -1
  71. package/dist/mindmap/view.js +35 -4
  72. package/dist/mindmap/view.js.map +1 -1
  73. package/dist/sensor-demo/service.js +6 -5
  74. package/dist/sensor-demo/service.js.map +1 -1
  75. package/dist/sensor-generator/action.js +1 -1
  76. package/dist/sensor-generator/action.js.map +1 -1
  77. package/dist/sim/service.js +41 -41
  78. package/dist/sim/service.js.map +1 -1
  79. package/dist/table/view.js.map +1 -1
  80. package/dist/todo/types.js +2 -2
  81. package/dist/todo/types.js.map +1 -1
  82. package/dist/todo/view.js +6 -4
  83. package/dist/todo/view.js.map +1 -1
  84. package/dist/whisper/inbox.js +3 -3
  85. package/dist/whisper/inbox.js.map +1 -1
  86. package/dist/whisper/route.d.ts +1 -1
  87. package/dist/whisper/route.d.ts.map +1 -1
  88. package/dist/whisper/route.js +13 -13
  89. package/dist/whisper/route.js.map +1 -1
  90. package/doc/CLAUDE.md +1 -1
  91. package/doc/fs-codec.ts +1 -1
  92. package/doc/renderers.tsx +4 -3
  93. package/doc/toolbar.tsx +12 -9
  94. package/launcher/types.ts +2 -2
  95. package/launcher/view.tsx +12 -8
  96. package/mcp/mcp-server.ts +393 -0
  97. package/mcp/server.ts +2 -0
  98. package/mcp/service.ts +18 -0
  99. package/mcp/types.ts +6 -0
  100. package/mindmap/branch.tsx +121 -22
  101. package/mindmap/mindmap.css +52 -0
  102. package/mindmap/sidebar.tsx +9 -6
  103. package/mindmap/view.tsx +40 -4
  104. package/package.json +30 -3
  105. package/sensor-demo/service.ts +6 -5
  106. package/sensor-generator/action.ts +1 -1
  107. package/sim/service.ts +41 -41
  108. package/table/view.tsx +7 -2
  109. package/todo/types.ts +2 -2
  110. package/todo/view.tsx +9 -10
  111. package/whisper/inbox.ts +3 -3
  112. package/whisper/route.ts +13 -13
  113. package/board/board.test.ts +0 -212
  114. package/brahman/brahman.test.ts +0 -855
  115. package/dist/mindmap/radial-tree.d.ts +0 -14
  116. package/dist/mindmap/radial-tree.d.ts.map +0 -1
  117. package/dist/mindmap/radial-tree.js +0 -184
  118. package/dist/mindmap/radial-tree.js.map +0 -1
  119. package/dist/mindmap/use-tree-data.d.ts +0 -14
  120. package/dist/mindmap/use-tree-data.d.ts.map +0 -1
  121. package/dist/mindmap/use-tree-data.js +0 -95
  122. package/dist/mindmap/use-tree-data.js.map +0 -1
  123. package/doc/fs-codec.test.ts +0 -119
  124. package/doc/markdown.test.ts +0 -152
  125. package/sim/sim.test.ts +0 -282
@@ -1,855 +0,0 @@
1
- // Brahman bot tests — fake Grammy, real store, full signal flow
2
- // Tests: template engine, keyboards, tag filtering, page/action execution, middleware
3
-
4
- import { type NodeData, resolve } from '@treenity/core';
5
- import { registerType } from '@treenity/core/comp';
6
- import type { ServiceCtx } from '@treenity/core/contexts/service';
7
- import { serverNodeHandle } from '@treenity/core/server/actions';
8
- import { createMemoryTree, type Tree } from '@treenity/core/tree';
9
- import assert from 'node:assert/strict';
10
- import { after, before, describe, it } from 'node:test';
11
- import { buildReplyMarkup, checkTags, formatTString, renderTemplate } from './helpers';
12
- import { setBotFactory } from './service';
13
- import { type MenuRow, type TString } from './types';
14
-
15
- // Force registration of brahman types + action handlers
16
- import './types';
17
- import './service';
18
-
19
- // ── FakeBot ──
20
-
21
- type Handler = (ctx: any) => Promise<void>;
22
- type Middleware = (ctx: any, next: () => Promise<void>) => Promise<void>;
23
-
24
- class FakeBot {
25
- middlewares: Middleware[] = [];
26
- commands = new Map<string, Handler>();
27
- events = new Map<string, Handler>();
28
- errorHandler: ((err: unknown) => void) | null = null;
29
-
30
- use(mw: Middleware) { this.middlewares.push(mw); }
31
- command(name: string, handler: Handler) { this.commands.set(name, handler); }
32
- on(filter: string, handler: Handler) { this.events.set(filter, handler); }
33
- catch(handler: (err: unknown) => void) { this.errorHandler = handler; }
34
- started = false;
35
- botInfo = { id: 1, is_bot: true, first_name: 'TestBot', username: 'test_bot' };
36
- async init() {}
37
- async start() { this.started = true; }
38
- async stop() { this.started = false; }
39
-
40
- async dispatch(ctx: any, type: 'command' | 'callback' | 'text', commandName?: string) {
41
- // Run middleware chain, then the handler
42
- let handlerCalled = false;
43
- // Grammy routes /commands through message:text if no explicit bot.command() handler
44
- const handler = type === 'command'
45
- ? (this.commands.get(commandName!) ?? this.events.get('message:text'))
46
- : type === 'callback' ? this.events.get('callback_query:data')
47
- : this.events.get('message:text');
48
-
49
- const runMiddleware = async (i: number): Promise<void> => {
50
- if (i < this.middlewares.length) {
51
- await this.middlewares[i](ctx, () => runMiddleware(i + 1));
52
- } else if (handler && !handlerCalled) {
53
- handlerCalled = true;
54
- await handler(ctx);
55
- }
56
- };
57
-
58
- try {
59
- await runMiddleware(0);
60
- } catch (e) {
61
- if (this.errorHandler) this.errorHandler(e);
62
- else throw e;
63
- }
64
- }
65
- }
66
-
67
- // ── FakeContext ──
68
-
69
- type Sent = { method: string; args: unknown[] };
70
-
71
- function createFakeCtx(opts: {
72
- userId?: number;
73
- text?: string;
74
- callbackData?: string;
75
- firstName?: string;
76
- }): any {
77
- const sent: Sent[] = [];
78
- let msgCounter = 100;
79
-
80
- return {
81
- _sent: sent,
82
- from: {
83
- id: opts.userId ?? 12345,
84
- first_name: opts.firstName ?? 'Test',
85
- last_name: 'User',
86
- username: 'testuser',
87
- language_code: 'en',
88
- },
89
- chat: { id: opts.userId ?? 12345 },
90
- message: opts.text != null ? {
91
- text: opts.text,
92
- message_id: ++msgCounter,
93
- } : undefined,
94
- callbackQuery: opts.callbackData != null ? {
95
- data: opts.callbackData,
96
- message: { message_id: ++msgCounter },
97
- } : undefined,
98
- reply: async (text: string, o?: unknown) => {
99
- const mid = ++msgCounter;
100
- sent.push({ method: 'reply', args: [text, o] });
101
- return { message_id: mid };
102
- },
103
- editMessageText: async (text: string, o?: unknown) => {
104
- sent.push({ method: 'editMessageText', args: [text, o] });
105
- },
106
- deleteMessage: async () => {
107
- sent.push({ method: 'deleteMessage', args: [] });
108
- },
109
- answerCallbackQuery: async (text?: string) => {
110
- sent.push({ method: 'answerCallbackQuery', args: [text] });
111
- },
112
- api: {
113
- sendMessage: async (chatId: unknown, text: string, o?: unknown) => {
114
- sent.push({ method: 'sendMessage', args: [chatId, text, o] });
115
- return { message_id: ++msgCounter };
116
- },
117
- deleteMessage: async (chatId: unknown, msgId: unknown) => {
118
- sent.push({ method: 'api.deleteMessage', args: [chatId, msgId] });
119
- },
120
- forwardMessage: async (to: unknown, from: unknown, msgId: unknown) => {
121
- sent.push({ method: 'forwardMessage', args: [to, from, msgId] });
122
- },
123
- },
124
- replyWithPhoto: async (id: unknown) => { sent.push({ method: 'replyWithPhoto', args: [id] }); },
125
- replyWithVideo: async (id: unknown) => { sent.push({ method: 'replyWithVideo', args: [id] }); },
126
- replyWithAudio: async (id: unknown) => { sent.push({ method: 'replyWithAudio', args: [id] }); },
127
- replyWithVoice: async (id: unknown) => { sent.push({ method: 'replyWithVoice', args: [id] }); },
128
- replyWithDocument: async (id: unknown) => { sent.push({ method: 'replyWithDocument', args: [id] }); },
129
- };
130
- }
131
-
132
- // ── Tree seeding ──
133
-
134
- const BOT = '/test-bot';
135
-
136
- async function seedTestBot(store: Tree) {
137
- // Bot node
138
- await store.set({ $path: BOT, $type: 'brahman.bot', token: 'fake:token', langs: 'en,ru' } as NodeData);
139
-
140
- // Dirs
141
- for (const d of ['pages', 'users', 'sessions'])
142
- await store.set({ $path: `${BOT}/${d}`, $type: 'dir' } as NodeData);
143
-
144
- // /start page — fields on node directly (getComponent returns node when $type matches)
145
- await store.set({
146
- $path: `${BOT}/pages/start`, $type: 'brahman.page',
147
- command: '/start', positions: [`${BOT}/pages/start/_actions/welcome`],
148
- } as NodeData);
149
-
150
- await store.set({
151
- $path: `${BOT}/pages/start/_actions/welcome`, $type: 'brahman.action.message',
152
- text: { en: 'Welcome, {user.firstName}!', ru: 'Привет, {user.firstName}!' },
153
- menuType: 'keyboard',
154
- rows: [{
155
- buttons: [
156
- { id: 1, title: { en: 'Help', ru: 'Помощь' } as TString, action: { type: 'brahman.action.page', target: `${BOT}/pages/help` } },
157
- { id: 2, title: { en: 'About', ru: 'О нас' } as TString, action: { type: 'brahman.action.page', target: `${BOT}/pages/about` } },
158
- ],
159
- }],
160
- } as NodeData);
161
-
162
- // /help page
163
- await store.set({
164
- $path: `${BOT}/pages/help`, $type: 'brahman.page',
165
- command: '/help', positions: [`${BOT}/pages/help/_actions/msg`],
166
- } as NodeData);
167
-
168
- await store.set({
169
- $path: `${BOT}/pages/help/_actions/msg`, $type: 'brahman.action.message',
170
- text: { en: 'Help page' }, menuType: 'none', rows: [],
171
- } as NodeData);
172
-
173
- // /about page with setvalue + message
174
- await store.set({
175
- $path: `${BOT}/pages/about`, $type: 'brahman.page',
176
- command: '', positions: [`${BOT}/pages/about/_actions/setval`, `${BOT}/pages/about/_actions/msg`],
177
- } as NodeData);
178
-
179
- await store.set({
180
- $path: `${BOT}/pages/about/_actions/setval`, $type: 'brahman.action.setvalue',
181
- value: '"visited"', saveTo: 'aboutStatus',
182
- } as NodeData);
183
-
184
- await store.set({
185
- $path: `${BOT}/pages/about/_actions/msg`, $type: 'brahman.action.message',
186
- text: { en: 'About us. Status: {aboutStatus}' }, menuType: 'none', rows: [],
187
- } as NodeData);
188
- }
189
-
190
- // ── Start the service with FakeBot ──
191
-
192
- async function startTestBot(store: Tree): Promise<FakeBot> {
193
- const fakeBot = new FakeBot();
194
- const handler = resolve('brahman.bot', 'service') as any;
195
- assert.ok(handler, 'brahman.bot service handler must be registered');
196
-
197
- const botNode = await store.get(BOT);
198
- assert.ok(botNode, 'bot node must exist');
199
-
200
- setBotFactory(() => fakeBot);
201
- const svcCtx: ServiceCtx = {
202
- store,
203
- subscribe: () => () => {},
204
- };
205
-
206
- await handler(botNode, svcCtx);
207
- return fakeBot;
208
- }
209
-
210
- // ── Tests ──
211
-
212
- describe('brahman template engine', () => {
213
- it('formatTString: exact lang', () => {
214
- assert.equal(formatTString({ en: 'Hello', ru: 'Привет' }, 'en'), 'Hello');
215
- assert.equal(formatTString({ en: 'Hello', ru: 'Привет' }, 'ru'), 'Привет');
216
- });
217
-
218
- it('formatTString: fallback chain', () => {
219
- assert.equal(formatTString({ ru: 'Только ру' }, 'de'), 'Только ру');
220
- assert.equal(formatTString({ en: 'Only en' }, 'de'), 'Only en');
221
- assert.equal(formatTString({ fr: 'Bonjour' }, 'de'), 'Bonjour');
222
- });
223
-
224
- it('formatTString: empty/undefined', () => {
225
- assert.equal(formatTString(undefined, 'en'), '');
226
- assert.equal(formatTString({}, 'en'), '');
227
- });
228
-
229
- it('renderTemplate: simple interpolation', () => {
230
- assert.equal(renderTemplate('{name}', { name: 'Bob' }), 'Bob');
231
- assert.equal(renderTemplate('Hi {{name}}!', { name: 'Alice' }), 'Hi Alice!');
232
- });
233
-
234
- it('renderTemplate: dot-path', () => {
235
- assert.equal(
236
- renderTemplate('{user.firstName}', { user: { firstName: 'Test' } }),
237
- 'Test',
238
- );
239
- });
240
-
241
- it('renderTemplate: ifEquals', () => {
242
- const tpl = '{{#ifEquals lang "en"}}English{{else}}Other{{/ifEquals}}';
243
- assert.equal(renderTemplate(tpl, { lang: 'en' }), 'English');
244
- assert.equal(renderTemplate(tpl, { lang: 'ru' }), 'Other');
245
- });
246
-
247
- it('renderTemplate: tag helper', () => {
248
- const tpl = '{{#tag admin}}Admin content{{else}}Normal{{/tag}}';
249
- assert.equal(renderTemplate(tpl, { userTags: ['admin'] }), 'Admin content');
250
- assert.equal(renderTemplate(tpl, { userTags: [] }), 'Normal');
251
- });
252
-
253
- it('renderTemplate: eval', () => {
254
- assert.equal(renderTemplate('{{eval 2 + 3}}', {}), '5');
255
- assert.equal(renderTemplate('{{eval data.x * 2}}', { x: 10 }), '20');
256
- });
257
-
258
- it('renderTemplate: toFixed', () => {
259
- assert.equal(renderTemplate('{{toFixed price 2}}', { price: 3.14159 }), '3.14');
260
- });
261
-
262
- it('renderTemplate: is', () => {
263
- assert.equal(renderTemplate('{{is active}}', { active: true }), 'true');
264
- assert.equal(renderTemplate('{{is active}}', { active: false }), '');
265
- });
266
-
267
- it('renderTemplate: switch', () => {
268
- assert.equal(renderTemplate('{{switch lang ru Привет en Hello}}', { lang: 'en' }), 'Hello');
269
- assert.equal(renderTemplate('{{switch lang ru Привет en Hello}}', { lang: 'ru' }), 'Привет');
270
- assert.equal(renderTemplate('{{switch lang ru Привет en Hello}}', { lang: 'de' }), '');
271
- });
272
- });
273
-
274
- describe('brahman checkTags', () => {
275
- it('empty tags = always show', () => {
276
- assert.ok(checkTags([], []));
277
- assert.ok(checkTags(['admin'], []));
278
- });
279
-
280
- it('include tags (OR logic)', () => {
281
- assert.ok(checkTags(['admin'], ['admin']));
282
- assert.ok(checkTags(['admin', 'vip'], ['vip']));
283
- assert.ok(!checkTags([], ['admin']));
284
- assert.ok(!checkTags(['user'], ['admin']));
285
- });
286
-
287
- it('exclude tags (!prefix)', () => {
288
- assert.ok(checkTags([], ['!banned']));
289
- assert.ok(!checkTags(['banned'], ['!banned']));
290
- });
291
-
292
- it('mixed include + exclude', () => {
293
- assert.ok(checkTags(['admin'], ['admin', '!banned']));
294
- assert.ok(!checkTags(['admin', 'banned'], ['admin', '!banned']));
295
- assert.ok(!checkTags(['banned'], ['admin', '!banned']));
296
- });
297
- });
298
-
299
- describe('brahman buildReplyMarkup', () => {
300
- const rows: MenuRow[] = [{
301
- buttons: [
302
- { id: 1, title: { en: 'Btn1' }, action: { type: 'brahman.action.page', target: '/p1' } },
303
- { id: 2, title: { en: 'Btn2' }, url: 'https://example.com' },
304
- ],
305
- }];
306
-
307
- it('none returns undefined', () => {
308
- assert.equal(buildReplyMarkup(rows, 'none', 'en'), undefined);
309
- });
310
-
311
- it('remove returns remove_keyboard', () => {
312
- const r = buildReplyMarkup(rows, 'remove', 'en') as any;
313
- assert.ok(r.reply_markup.remove_keyboard);
314
- });
315
-
316
- it('force_reply returns force_reply', () => {
317
- const r = buildReplyMarkup(rows, 'force_reply', 'en') as any;
318
- assert.ok(r.reply_markup.force_reply);
319
- });
320
-
321
- it('keyboard builds reply keyboard', () => {
322
- const r = buildReplyMarkup(rows, 'keyboard', 'en') as any;
323
- assert.ok(r.reply_markup);
324
- });
325
-
326
- it('inline builds inline keyboard', () => {
327
- const r = buildReplyMarkup(rows, 'inline', 'en') as any;
328
- assert.ok(r.reply_markup);
329
- });
330
-
331
- it('filters by tags', () => {
332
- const taggedRows: MenuRow[] = [{
333
- buttons: [
334
- { id: 1, title: { en: 'Admin only' }, tags: ['admin'] },
335
- { id: 2, title: { en: 'Public' } },
336
- ],
337
- }];
338
- // non-admin: only Public button should remain
339
- const r = buildReplyMarkup(taggedRows, 'keyboard', 'en', []) as any;
340
- assert.ok(r.reply_markup);
341
- const kbText = JSON.stringify(r.reply_markup);
342
- assert.ok(kbText.includes('Public'), 'should include Public button');
343
- assert.ok(!kbText.includes('Admin only'), 'should exclude Admin only button');
344
- });
345
- });
346
-
347
- describe('brahman full signal flow', () => {
348
- let store: Tree;
349
- let bot: FakeBot;
350
-
351
- before(async () => {
352
- store = createMemoryTree();
353
- await seedTestBot(store);
354
- bot = await startTestBot(store);
355
- });
356
-
357
- after(() => setBotFactory(undefined));
358
-
359
- it('/start command → session created → welcome message sent', async () => {
360
- const ctx = createFakeCtx({ userId: 100, text: '/start' });
361
- await bot.dispatch(ctx, 'command', 'start');
362
-
363
- // Session should be created
364
- const session = await store.get(`${BOT}/sessions/100`);
365
- assert.ok(session, 'session created');
366
-
367
- // User should be created
368
- const user = await store.get(`${BOT}/users/100`);
369
- assert.ok(user, 'user created');
370
-
371
- // Welcome message sent with template resolved
372
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
373
- assert.ok(replies.length > 0, 'at least one reply');
374
- assert.ok(
375
- (replies[0].args[0] as string).includes('Welcome, Test!'),
376
- `expected "Welcome, Test!" in "${replies[0].args[0]}"`,
377
- );
378
- });
379
-
380
- it('/help command → help page message', async () => {
381
- const ctx = createFakeCtx({ userId: 100, text: '/help' });
382
- await bot.dispatch(ctx, 'command', 'help');
383
-
384
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
385
- assert.ok(replies.length > 0);
386
- assert.equal(replies[0].args[0], 'Help page');
387
- });
388
-
389
- it('text message matching keyboard button → navigates to page', async () => {
390
- // First send /start to get the keyboard and set history
391
- const ctx1 = createFakeCtx({ userId: 200, text: '/start' });
392
- await bot.dispatch(ctx1, 'command', 'start');
393
-
394
- // Now send "Help" text matching the keyboard button
395
- const ctx2 = createFakeCtx({ userId: 200, text: 'Help' });
396
- await bot.dispatch(ctx2, 'text');
397
-
398
- const replies = ctx2._sent.filter((s: Sent) => s.method === 'reply');
399
- assert.ok(replies.length > 0, 'reply sent after button match');
400
- assert.equal(replies[0].args[0], 'Help page');
401
- });
402
-
403
- it('callback_query page: prefix → navigates', async () => {
404
- // First /start so middleware creates session
405
- const ctx1 = createFakeCtx({ userId: 300, text: '/start' });
406
- await bot.dispatch(ctx1, 'command', 'start');
407
-
408
- // callback_query with page: prefix
409
- const ctx2 = createFakeCtx({ userId: 300, callbackData: `page:${BOT}/pages/help` });
410
- await bot.dispatch(ctx2, 'callback');
411
-
412
- const replies = ctx2._sent.filter((s: Sent) => s.method === 'reply');
413
- assert.ok(replies.length > 0, 'page navigated via callback');
414
- assert.equal(replies[0].args[0], 'Help page');
415
-
416
- // answerCallbackQuery should be called
417
- assert.ok(ctx2._sent.some((s: Sent) => s.method === 'answerCallbackQuery'));
418
- });
419
-
420
- it('setvalue action updates session, message reads it', async () => {
421
- // /start first
422
- const ctx1 = createFakeCtx({ userId: 400, text: '/start' });
423
- await bot.dispatch(ctx1, 'command', 'start');
424
-
425
- // Navigate to about page (which runs setvalue + message)
426
- const ctx2 = createFakeCtx({ userId: 400, callbackData: `page:${BOT}/pages/about` });
427
- await bot.dispatch(ctx2, 'callback');
428
-
429
- const replies = ctx2._sent.filter((s: Sent) => s.method === 'reply');
430
- assert.ok(replies.length > 0);
431
- assert.ok(
432
- (replies[0].args[0] as string).includes('Status: visited'),
433
- `expected "Status: visited" in "${replies[0].args[0]}"`,
434
- );
435
- });
436
-
437
- it('maintenance mode → reply maintenance text, no page execution', async () => {
438
- const mStore = createMemoryTree();
439
- // Bot with maintenance set
440
- await mStore.set({
441
- $path: BOT, $type: 'brahman.bot',
442
- token: 'fake:token', langs: 'en', maintenance: 'Bot is under maintenance',
443
- } as NodeData);
444
-
445
- for (const d of ['pages', 'users', 'sessions'])
446
- await mStore.set({ $path: `${BOT}/${d}`, $type: 'dir' } as NodeData);
447
-
448
- await mStore.set({
449
- $path: `${BOT}/pages/start`, $type: 'brahman.page',
450
- command: '/start', positions: [],
451
- } as NodeData);
452
-
453
- const mBot = await startTestBot(mStore);
454
- const ctx = createFakeCtx({ userId: 500, text: '/start' });
455
- await mBot.dispatch(ctx, 'command', 'start');
456
-
457
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
458
- assert.equal(replies.length, 1);
459
- assert.equal(replies[0].args[0], 'Bot is under maintenance');
460
- });
461
-
462
- it('banned user → silently dropped', async () => {
463
- // Create a banned user
464
- await store.set({
465
- $path: `${BOT}/users/600`, $type: 'brahman.user',
466
- tid: 600, firstName: 'Bad', lastName: 'User', username: 'bad',
467
- lang: 'en', isAdmin: false, blocked: false, banned: true, tags: [],
468
- } as NodeData);
469
-
470
- await store.set({
471
- $path: `${BOT}/sessions/600`, $type: 'brahman.session',
472
- tid: 600, data: {}, history: [], callbacks: {},
473
- } as NodeData);
474
-
475
- const ctx = createFakeCtx({ userId: 600, text: '/start' });
476
- await bot.dispatch(ctx, 'command', 'start');
477
-
478
- // No replies — banned user is silently ignored
479
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
480
- assert.equal(replies.length, 0, 'banned user gets no reply');
481
- });
482
-
483
- it('unknown text → fallback to /start', async () => {
484
- const ctx1 = createFakeCtx({ userId: 700, text: '/start' });
485
- await bot.dispatch(ctx1, 'command', 'start');
486
-
487
- const ctx2 = createFakeCtx({ userId: 700, text: 'random gibberish' });
488
- await bot.dispatch(ctx2, 'text');
489
-
490
- // Should fallback to /start page
491
- const replies = ctx2._sent.filter((s: Sent) => s.method === 'reply');
492
- assert.ok(replies.length > 0, 'fallback reply sent');
493
- assert.ok(
494
- (replies[0].args[0] as string).includes('Welcome'),
495
- 'fallback goes to /start',
496
- );
497
- });
498
-
499
- it('session persists across calls', async () => {
500
- // Visit about page → sets aboutStatus in session
501
- const ctx1 = createFakeCtx({ userId: 800, text: '/start' });
502
- await bot.dispatch(ctx1, 'command', 'start');
503
-
504
- const ctx2 = createFakeCtx({ userId: 800, callbackData: `page:${BOT}/pages/about` });
505
- await bot.dispatch(ctx2, 'callback');
506
-
507
- // Check session was saved with aboutStatus
508
- const sessionNode = await store.get(`${BOT}/sessions/800`);
509
- assert.ok(sessionNode);
510
- // With flat storage, data is directly on the node
511
- const sessionData = (sessionNode as any).data;
512
- assert.equal(sessionData?.aboutStatus, 'visited');
513
- });
514
-
515
- it('history tracks visited pages across calls', async () => {
516
- const ctx1 = createFakeCtx({ userId: 900, text: '/start' });
517
- await bot.dispatch(ctx1, 'command', 'start');
518
-
519
- const ctx2 = createFakeCtx({ userId: 900, text: '/help' });
520
- await bot.dispatch(ctx2, 'command', 'help');
521
-
522
- const sessionNode = await store.get(`${BOT}/sessions/900`);
523
- assert.ok(sessionNode);
524
- const history = (sessionNode as any).history;
525
- assert.ok(Array.isArray(history));
526
- assert.ok(history.length >= 2, `expected >=2 history entries, got ${history.length}`);
527
- assert.ok(history.includes(`${BOT}/pages/start`));
528
- assert.ok(history.includes(`${BOT}/pages/help`));
529
- });
530
- });
531
-
532
- describe('brahman bot start/stop actions', () => {
533
- it('registers start and stop actions', () => {
534
- assert.ok(resolve('brahman.bot', 'action:start'));
535
- assert.ok(resolve('brahman.bot', 'action:stop'));
536
- });
537
-
538
- it('action:start delegates to autostart via ctx.nc', async () => {
539
- const handler = resolve('brahman.bot', 'action:start')!;
540
- const node = { $path: '/b', $type: 'brahman.bot', token: 't' } as NodeData;
541
- const store = createMemoryTree();
542
- // No autostart node in store → executeAction throws NOT_FOUND
543
- await assert.rejects(
544
- () => handler({ node, store, signal: AbortSignal.timeout(1000), nc: serverNodeHandle(store) }, undefined) as Promise<void>,
545
- );
546
- });
547
-
548
- it('action:stop delegates to autostart via ctx.nc', async () => {
549
- const handler = resolve('brahman.bot', 'action:stop')!;
550
- const node = { $path: '/b', $type: 'brahman.bot', token: 't' } as NodeData;
551
- const store = createMemoryTree();
552
- await assert.rejects(
553
- () => handler({ node, store, signal: AbortSignal.timeout(1000), nc: serverNodeHandle(store) }, undefined) as Promise<void>,
554
- );
555
- });
556
-
557
- it('service skips bot.start() when running=false', async () => {
558
- const s = createMemoryTree();
559
- await s.set({ $path: '/b2', $type: 'brahman.bot', token: 'fake:t', langs: 'en', running: false } as NodeData);
560
- for (const d of ['pages', 'users', 'sessions'])
561
- await s.set({ $path: `/b2/${d}`, $type: 'dir' } as NodeData);
562
-
563
- const fb = new FakeBot();
564
- setBotFactory(() => fb);
565
-
566
- const handler = resolve('brahman.bot', 'service') as any;
567
- const handle = await handler(await s.get('/b2'), { store: s, subscribe: () => () => {} });
568
-
569
- assert.equal(fb.started, false, 'bot should not be started when running=false');
570
-
571
- await handle.stop();
572
- setBotFactory(undefined);
573
- });
574
-
575
- it('service starts bot when running=true (default)', async () => {
576
- const s = createMemoryTree();
577
- await s.set({ $path: '/b3', $type: 'brahman.bot', token: 'fake:t', langs: 'en' } as NodeData);
578
- for (const d of ['pages', 'users', 'sessions'])
579
- await s.set({ $path: `/b3/${d}`, $type: 'dir' } as NodeData);
580
-
581
- const fb = new FakeBot();
582
- setBotFactory(() => fb);
583
-
584
- const handler = resolve('brahman.bot', 'service') as any;
585
- const handle = await handler(await s.get('/b3'), { store: s, subscribe: () => () => {} });
586
-
587
- assert.equal(fb.started, true, 'bot should be started by default');
588
-
589
- await handle.stop();
590
- setBotFactory(undefined);
591
- });
592
-
593
- it('init() fills alias and name from botInfo', async () => {
594
- const s = createMemoryTree();
595
- await s.set({ $path: '/b4', $type: 'brahman.bot', token: 'fake:t', langs: 'en' } as NodeData);
596
- for (const d of ['pages', 'users', 'sessions'])
597
- await s.set({ $path: `/b4/${d}`, $type: 'dir' } as NodeData);
598
-
599
- const fb = new FakeBot();
600
- fb.botInfo = { id: 42, is_bot: true, first_name: 'MyBot', username: 'my_cool_bot' };
601
- setBotFactory(() => fb);
602
-
603
- const handler = resolve('brahman.bot', 'service') as any;
604
- const handle = await handler(await s.get('/b4'), { store: s, subscribe: () => () => {} });
605
-
606
- const node = await s.get('/b4');
607
- assert.equal((node as any).alias, '@my_cool_bot');
608
- assert.equal((node as any).name, 'MyBot');
609
-
610
- await handle.stop();
611
- setBotFactory(undefined);
612
- });
613
-
614
- it('init() does not overwrite existing alias/name', async () => {
615
- const s = createMemoryTree();
616
- await s.set({ $path: '/b5', $type: 'brahman.bot', token: 'fake:t', langs: 'en', alias: '@custom', name: 'Custom' } as NodeData);
617
- for (const d of ['pages', 'users', 'sessions'])
618
- await s.set({ $path: `/b5/${d}`, $type: 'dir' } as NodeData);
619
-
620
- const fb = new FakeBot();
621
- fb.botInfo = { id: 42, is_bot: true, first_name: 'MyBot', username: 'my_cool_bot' };
622
- setBotFactory(() => fb);
623
-
624
- const handler = resolve('brahman.bot', 'service') as any;
625
- const handle = await handler(await s.get('/b5'), { store: s, subscribe: () => () => {} });
626
-
627
- const node = await s.get('/b5');
628
- assert.equal((node as any).alias, '@custom');
629
- assert.equal((node as any).name, 'Custom');
630
-
631
- await handle.stop();
632
- setBotFactory(undefined);
633
- });
634
- });
635
-
636
- describe('brahman persistent wait', () => {
637
- let store: Tree;
638
- let bot: FakeBot;
639
-
640
- before(async () => {
641
- store = createMemoryTree();
642
- await seedTestBot(store);
643
-
644
- // Add a page with question + follow-up message
645
- await store.set({
646
- $path: `${BOT}/pages/ask`, $type: 'brahman.page',
647
- command: '/ask',
648
- positions: [`${BOT}/pages/ask/_actions/q`, `${BOT}/pages/ask/_actions/reply`],
649
- } as NodeData);
650
-
651
- await store.set({
652
- $path: `${BOT}/pages/ask/_actions/q`, $type: 'brahman.action.question',
653
- text: { en: 'What is your name?' }, inputType: 'text', saveTo: 'userName', deleteMessages: false,
654
- } as NodeData);
655
-
656
- await store.set({
657
- $path: `${BOT}/pages/ask/_actions/reply`, $type: 'brahman.action.message',
658
- text: { en: 'Hello, {userName}!' }, menuType: 'none', rows: [],
659
- } as NodeData);
660
-
661
- bot = await startTestBot(store);
662
- });
663
-
664
- after(() => setBotFactory(undefined));
665
-
666
- it('question action sets session.wait, next message resolves it', async () => {
667
- // Trigger /ask — question sends prompt, sets session.wait, stops before reply action
668
- const ctx1 = createFakeCtx({ userId: 1100, text: '/ask' });
669
- await bot.dispatch(ctx1, 'command', 'ask');
670
-
671
- // Prompt was sent
672
- const replies1 = ctx1._sent.filter((s: Sent) => s.method === 'reply');
673
- assert.ok(replies1.some((r: Sent) => (r.args[0] as string).includes('What is your name?')));
674
-
675
- // session.wait persisted in store
676
- const session1 = await store.get(`${BOT}/sessions/1100`);
677
- assert.ok(session1);
678
- const wait = (session1 as any).data?.wait;
679
- assert.ok(wait, 'session.wait should be set');
680
- assert.equal(wait.type, 'text');
681
- assert.equal(wait.saveTo, 'userName');
682
- assert.deepEqual(wait.remaining, [`${BOT}/pages/ask/_actions/reply`]);
683
-
684
- // User answers — middleware resolves wait, runs remaining reply action
685
- const ctx2 = createFakeCtx({ userId: 1100, text: 'Alice' });
686
- await bot.dispatch(ctx2, 'text');
687
-
688
- // Reply action should have run with the answer
689
- const replies2 = ctx2._sent.filter((s: Sent) => s.method === 'reply');
690
- assert.ok(
691
- replies2.some((r: Sent) => (r.args[0] as string).includes('Hello, Alice!')),
692
- `expected "Hello, Alice!" in replies: ${replies2.map((r: Sent) => r.args[0])}`,
693
- );
694
-
695
- // wait state cleared
696
- const session2 = await store.get(`${BOT}/sessions/1100`);
697
- assert.equal((session2 as any).data?.wait, undefined, 'wait should be cleared after answer');
698
- assert.equal((session2 as any).data?.userName, 'Alice', 'answer saved to session');
699
- });
700
-
701
- it('wait survives simulated restart (new bot instance, same store)', async () => {
702
- // Trigger /ask on fresh user
703
- const ctx1 = createFakeCtx({ userId: 1200, text: '/ask' });
704
- await bot.dispatch(ctx1, 'command', 'ask');
705
-
706
- // Verify wait is set in store
707
- const session1 = await store.get(`${BOT}/sessions/1200`);
708
- assert.ok((session1 as any).data?.wait, 'wait persisted');
709
-
710
- // "Restart" — create a new bot instance with the same store
711
- const bot2 = await startTestBot(store);
712
-
713
- // Answer comes to new bot instance
714
- const ctx2 = createFakeCtx({ userId: 1200, text: 'Bob' });
715
- await bot2.dispatch(ctx2, 'text');
716
-
717
- const replies = ctx2._sent.filter((s: Sent) => s.method === 'reply');
718
- assert.ok(
719
- replies.some((r: Sent) => (r.args[0] as string).includes('Hello, Bob!')),
720
- 'answer resolved after restart',
721
- );
722
-
723
- const session2 = await store.get(`${BOT}/sessions/1200`);
724
- assert.equal((session2 as any).data?.userName, 'Bob');
725
- assert.equal((session2 as any).data?.wait, undefined, 'wait cleared after restart resolve');
726
- });
727
-
728
- it('non-matching message type does not resolve wait', async () => {
729
-
730
- // Set up a wait for photo type
731
- await store.set({
732
- $path: `${BOT}/pages/photo`, $type: 'brahman.page',
733
- command: '/photo', positions: [`${BOT}/pages/photo/_actions/q`],
734
- } as NodeData);
735
- await store.set({
736
- $path: `${BOT}/pages/photo/_actions/q`, $type: 'brahman.action.question',
737
- text: { en: 'Send a photo' }, inputType: 'photo', saveTo: 'photoId', deleteMessages: false,
738
- } as NodeData);
739
-
740
- const ctx1 = createFakeCtx({ userId: 1300, text: '/photo' });
741
- await bot.dispatch(ctx1, 'command', 'photo');
742
-
743
- // Text message should NOT resolve a photo wait — falls through to normal routing
744
- const ctx2 = createFakeCtx({ userId: 1300, text: 'not a photo' });
745
- await bot.dispatch(ctx2, 'text');
746
-
747
- const session = await store.get(`${BOT}/sessions/1300`);
748
- assert.ok((session as any).data?.wait, 'wait should still be pending (wrong type)');
749
- });
750
- });
751
-
752
- // ── Test target for CallAction ──
753
-
754
- class _TestTarget {
755
- value = 0;
756
- bump() {
757
- this.value += 1;
758
- return this.value;
759
- }
760
- }
761
- registerType('test.brahman.target', _TestTarget);
762
-
763
- describe('brahman.action.call', () => {
764
- it('calls tree action via server executeAction and saves result', async () => {
765
- const store = createMemoryTree();
766
- await seedTestBot(store);
767
- const bot = await startTestBot(store);
768
-
769
- // Target node with test type
770
- await store.set({
771
- $path: '/targets/counter', $type: 'test.brahman.target',
772
- value: 10,
773
- } as NodeData);
774
-
775
- // Page with CallAction → message showing result
776
- await store.set({
777
- $path: `${BOT}/pages/call-test`, $type: 'brahman.page',
778
- command: '/calltest',
779
- positions: [`${BOT}/pages/call-test/_actions/call`, `${BOT}/pages/call-test/_actions/result`],
780
- } as NodeData);
781
-
782
- await store.set({
783
- $path: `${BOT}/pages/call-test/_actions/call`, $type: 'brahman.action.call',
784
- path: '/targets/counter', action: 'bump', saveTo: 'bumpResult',
785
- } as NodeData);
786
-
787
- await store.set({
788
- $path: `${BOT}/pages/call-test/_actions/result`, $type: 'brahman.action.message',
789
- text: { en: 'Bumped: {bumpResult}' }, menuType: 'none', rows: [],
790
- } as NodeData);
791
-
792
- const ctx = createFakeCtx({ userId: 1400, text: '/calltest' });
793
- await bot.dispatch(ctx, 'command', 'calltest');
794
-
795
- // Result message shows bumped value
796
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
797
- assert.ok(
798
- replies.some((r: Sent) => (r.args[0] as string).includes('Bumped: 11')),
799
- `expected "Bumped: 11" in replies: ${replies.map((r: Sent) => r.args[0])}`,
800
- );
801
-
802
- // Target node mutated via Immer draft
803
- const target = await store.get('/targets/counter');
804
- assert.equal((target as any).value, 11);
805
-
806
- setBotFactory(undefined);
807
- });
808
-
809
- it('formats path template from session vars', async () => {
810
- const store = createMemoryTree();
811
- await seedTestBot(store);
812
- const bot = await startTestBot(store);
813
-
814
- await store.set({
815
- $path: '/items/abc', $type: 'test.brahman.target', value: 5,
816
- } as NodeData);
817
-
818
- // Page: setvalue to set itemId → call with templated path
819
- await store.set({
820
- $path: `${BOT}/pages/tcall`, $type: 'brahman.page',
821
- command: '/tcall',
822
- positions: [
823
- `${BOT}/pages/tcall/_actions/setid`,
824
- `${BOT}/pages/tcall/_actions/call`,
825
- `${BOT}/pages/tcall/_actions/msg`,
826
- ],
827
- } as NodeData);
828
-
829
- await store.set({
830
- $path: `${BOT}/pages/tcall/_actions/setid`, $type: 'brahman.action.setvalue',
831
- value: '"abc"', saveTo: 'itemId',
832
- } as NodeData);
833
-
834
- await store.set({
835
- $path: `${BOT}/pages/tcall/_actions/call`, $type: 'brahman.action.call',
836
- path: '/items/{itemId}', action: 'bump', saveTo: 'res',
837
- } as NodeData);
838
-
839
- await store.set({
840
- $path: `${BOT}/pages/tcall/_actions/msg`, $type: 'brahman.action.message',
841
- text: { en: 'Result: {res}' }, menuType: 'none', rows: [],
842
- } as NodeData);
843
-
844
- const ctx = createFakeCtx({ userId: 1500, text: '/tcall' });
845
- await bot.dispatch(ctx, 'command', 'tcall');
846
-
847
- const replies = ctx._sent.filter((s: Sent) => s.method === 'reply');
848
- assert.ok(
849
- replies.some((r: Sent) => (r.args[0] as string).includes('Result: 6')),
850
- `expected "Result: 6" in replies: ${replies.map((r: Sent) => r.args[0])}`,
851
- );
852
-
853
- setBotFactory(undefined);
854
- });
855
- });