@rozek/nanoclaw 0.0.22 → 0.0.24

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 (38) hide show
  1. package/container/Dockerfile +1 -1
  2. package/dist/channels/index.d.ts +1 -0
  3. package/dist/channels/index.d.ts.map +1 -1
  4. package/dist/channels/index.js +1 -0
  5. package/dist/channels/index.js.map +1 -1
  6. package/dist/channels/telegram.d.ts +20 -0
  7. package/dist/channels/telegram.d.ts.map +1 -0
  8. package/dist/channels/telegram.js +226 -0
  9. package/dist/channels/telegram.js.map +1 -0
  10. package/dist/channels/telegram.test.d.ts +2 -0
  11. package/dist/channels/telegram.test.d.ts.map +1 -0
  12. package/dist/channels/telegram.test.js +624 -0
  13. package/dist/channels/telegram.test.js.map +1 -0
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +18 -5
  17. package/dist/config.js.map +1 -1
  18. package/dist/container-runner.d.ts.map +1 -1
  19. package/dist/container-runner.js +16 -20
  20. package/dist/container-runner.js.map +1 -1
  21. package/dist/container-runner.test.js +4 -10
  22. package/dist/container-runner.test.js.map +1 -1
  23. package/dist/container-runtime.d.ts +9 -0
  24. package/dist/container-runtime.d.ts.map +1 -1
  25. package/dist/container-runtime.js +27 -0
  26. package/dist/container-runtime.js.map +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -21
  29. package/dist/index.js.map +1 -1
  30. package/dist/timezone.d.ts +10 -0
  31. package/dist/timezone.d.ts.map +1 -1
  32. package/dist/timezone.js +21 -1
  33. package/dist/timezone.js.map +1 -1
  34. package/dist/timezone.test.js +33 -1
  35. package/dist/timezone.test.js.map +1 -1
  36. package/package.json +3 -2
  37. package/setup/index.ts +1 -0
  38. package/setup/timezone.ts +67 -0
@@ -0,0 +1,624 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ // --- Mocks ---
3
+ // Mock registry (registerChannel runs at import time)
4
+ vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
5
+ // Mock env reader (used by the factory, not needed in unit tests)
6
+ vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
7
+ // Mock config
8
+ vi.mock('../config.js', () => ({
9
+ ASSISTANT_NAME: 'Andy',
10
+ TRIGGER_PATTERN: /^@Andy\b/i,
11
+ }));
12
+ // Mock logger
13
+ vi.mock('../logger.js', () => ({
14
+ logger: {
15
+ debug: vi.fn(),
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ error: vi.fn(),
19
+ },
20
+ }));
21
+ const botRef = vi.hoisted(() => ({ current: null }));
22
+ vi.mock('grammy', () => ({
23
+ Bot: class MockBot {
24
+ token;
25
+ commandHandlers = new Map();
26
+ filterHandlers = new Map();
27
+ errorHandler = null;
28
+ api = {
29
+ sendMessage: vi.fn().mockResolvedValue(undefined),
30
+ sendChatAction: vi.fn().mockResolvedValue(undefined),
31
+ };
32
+ constructor(token) {
33
+ this.token = token;
34
+ botRef.current = this;
35
+ }
36
+ command(name, handler) {
37
+ this.commandHandlers.set(name, handler);
38
+ }
39
+ on(filter, handler) {
40
+ const existing = this.filterHandlers.get(filter) || [];
41
+ existing.push(handler);
42
+ this.filterHandlers.set(filter, existing);
43
+ }
44
+ catch(handler) {
45
+ this.errorHandler = handler;
46
+ }
47
+ start(opts) {
48
+ opts.onStart({ username: 'andy_ai_bot', id: 12345 });
49
+ }
50
+ stop() { }
51
+ },
52
+ }));
53
+ import { TelegramChannel } from './telegram.js';
54
+ // --- Test helpers ---
55
+ function createTestOpts(overrides) {
56
+ return {
57
+ onMessage: vi.fn(),
58
+ onChatMetadata: vi.fn(),
59
+ registeredGroups: vi.fn(() => ({
60
+ 'tg:100200300': {
61
+ name: 'Test Group',
62
+ folder: 'test-group',
63
+ trigger: '@Andy',
64
+ added_at: '2024-01-01T00:00:00.000Z',
65
+ },
66
+ })),
67
+ ...overrides,
68
+ };
69
+ }
70
+ function createTextCtx(overrides) {
71
+ const chatId = overrides.chatId ?? 100200300;
72
+ const chatType = overrides.chatType ?? 'group';
73
+ return {
74
+ chat: {
75
+ id: chatId,
76
+ type: chatType,
77
+ title: overrides.chatTitle ?? 'Test Group',
78
+ },
79
+ from: {
80
+ id: overrides.fromId ?? 99001,
81
+ first_name: overrides.firstName ?? 'Alice',
82
+ username: overrides.username ?? 'alice_user',
83
+ },
84
+ message: {
85
+ text: overrides.text,
86
+ date: overrides.date ?? Math.floor(Date.now() / 1000),
87
+ message_id: overrides.messageId ?? 1,
88
+ entities: overrides.entities ?? [],
89
+ },
90
+ me: { username: 'andy_ai_bot' },
91
+ reply: vi.fn(),
92
+ };
93
+ }
94
+ function createMediaCtx(overrides) {
95
+ const chatId = overrides.chatId ?? 100200300;
96
+ return {
97
+ chat: {
98
+ id: chatId,
99
+ type: overrides.chatType ?? 'group',
100
+ title: 'Test Group',
101
+ },
102
+ from: {
103
+ id: overrides.fromId ?? 99001,
104
+ first_name: overrides.firstName ?? 'Alice',
105
+ username: 'alice_user',
106
+ },
107
+ message: {
108
+ date: overrides.date ?? Math.floor(Date.now() / 1000),
109
+ message_id: overrides.messageId ?? 1,
110
+ caption: overrides.caption,
111
+ ...(overrides.extra || {}),
112
+ },
113
+ me: { username: 'andy_ai_bot' },
114
+ };
115
+ }
116
+ function currentBot() {
117
+ return botRef.current;
118
+ }
119
+ async function triggerTextMessage(ctx) {
120
+ const handlers = currentBot().filterHandlers.get('message:text') || [];
121
+ for (const h of handlers)
122
+ await h(ctx);
123
+ }
124
+ async function triggerMediaMessage(filter, ctx) {
125
+ const handlers = currentBot().filterHandlers.get(filter) || [];
126
+ for (const h of handlers)
127
+ await h(ctx);
128
+ }
129
+ // --- Tests ---
130
+ describe('TelegramChannel', () => {
131
+ beforeEach(() => {
132
+ vi.clearAllMocks();
133
+ });
134
+ afterEach(() => {
135
+ vi.restoreAllMocks();
136
+ });
137
+ // --- Connection lifecycle ---
138
+ describe('connection lifecycle', () => {
139
+ it('resolves connect() when bot starts', async () => {
140
+ const opts = createTestOpts();
141
+ const channel = new TelegramChannel('test-token', opts);
142
+ await channel.connect();
143
+ expect(channel.isConnected()).toBe(true);
144
+ });
145
+ it('registers command and message handlers on connect', async () => {
146
+ const opts = createTestOpts();
147
+ const channel = new TelegramChannel('test-token', opts);
148
+ await channel.connect();
149
+ expect(currentBot().commandHandlers.has('chatid')).toBe(true);
150
+ expect(currentBot().commandHandlers.has('ping')).toBe(true);
151
+ expect(currentBot().filterHandlers.has('message:text')).toBe(true);
152
+ expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
153
+ expect(currentBot().filterHandlers.has('message:video')).toBe(true);
154
+ expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
155
+ expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
156
+ expect(currentBot().filterHandlers.has('message:document')).toBe(true);
157
+ expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
158
+ expect(currentBot().filterHandlers.has('message:location')).toBe(true);
159
+ expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
160
+ });
161
+ it('registers error handler on connect', async () => {
162
+ const opts = createTestOpts();
163
+ const channel = new TelegramChannel('test-token', opts);
164
+ await channel.connect();
165
+ expect(currentBot().errorHandler).not.toBeNull();
166
+ });
167
+ it('disconnects cleanly', async () => {
168
+ const opts = createTestOpts();
169
+ const channel = new TelegramChannel('test-token', opts);
170
+ await channel.connect();
171
+ expect(channel.isConnected()).toBe(true);
172
+ await channel.disconnect();
173
+ expect(channel.isConnected()).toBe(false);
174
+ });
175
+ it('isConnected() returns false before connect', () => {
176
+ const opts = createTestOpts();
177
+ const channel = new TelegramChannel('test-token', opts);
178
+ expect(channel.isConnected()).toBe(false);
179
+ });
180
+ });
181
+ // --- Text message handling ---
182
+ describe('text message handling', () => {
183
+ it('delivers message for registered group', async () => {
184
+ const opts = createTestOpts();
185
+ const channel = new TelegramChannel('test-token', opts);
186
+ await channel.connect();
187
+ const ctx = createTextCtx({ text: 'Hello everyone' });
188
+ await triggerTextMessage(ctx);
189
+ expect(opts.onChatMetadata).toHaveBeenCalledWith('tg:100200300', expect.any(String), 'Test Group', 'telegram', true);
190
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
191
+ id: '1',
192
+ chat_jid: 'tg:100200300',
193
+ sender: '99001',
194
+ sender_name: 'Alice',
195
+ content: 'Hello everyone',
196
+ is_from_me: false,
197
+ }));
198
+ });
199
+ it('only emits metadata for unregistered chats', async () => {
200
+ const opts = createTestOpts();
201
+ const channel = new TelegramChannel('test-token', opts);
202
+ await channel.connect();
203
+ const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
204
+ await triggerTextMessage(ctx);
205
+ expect(opts.onChatMetadata).toHaveBeenCalledWith('tg:999999', expect.any(String), 'Test Group', 'telegram', true);
206
+ expect(opts.onMessage).not.toHaveBeenCalled();
207
+ });
208
+ it('skips bot commands (/chatid, /ping) but passes other / messages through', async () => {
209
+ const opts = createTestOpts();
210
+ const channel = new TelegramChannel('test-token', opts);
211
+ await channel.connect();
212
+ // Bot commands should be skipped
213
+ const ctx1 = createTextCtx({ text: '/chatid' });
214
+ await triggerTextMessage(ctx1);
215
+ expect(opts.onMessage).not.toHaveBeenCalled();
216
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
217
+ const ctx2 = createTextCtx({ text: '/ping' });
218
+ await triggerTextMessage(ctx2);
219
+ expect(opts.onMessage).not.toHaveBeenCalled();
220
+ // Non-bot /commands should flow through
221
+ const ctx3 = createTextCtx({ text: '/remote-control' });
222
+ await triggerTextMessage(ctx3);
223
+ expect(opts.onMessage).toHaveBeenCalledTimes(1);
224
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '/remote-control' }));
225
+ });
226
+ it('extracts sender name from first_name', async () => {
227
+ const opts = createTestOpts();
228
+ const channel = new TelegramChannel('test-token', opts);
229
+ await channel.connect();
230
+ const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
231
+ await triggerTextMessage(ctx);
232
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ sender_name: 'Bob' }));
233
+ });
234
+ it('falls back to username when first_name missing', async () => {
235
+ const opts = createTestOpts();
236
+ const channel = new TelegramChannel('test-token', opts);
237
+ await channel.connect();
238
+ const ctx = createTextCtx({ text: 'Hi' });
239
+ ctx.from.first_name = undefined;
240
+ await triggerTextMessage(ctx);
241
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ sender_name: 'alice_user' }));
242
+ });
243
+ it('falls back to user ID when name and username missing', async () => {
244
+ const opts = createTestOpts();
245
+ const channel = new TelegramChannel('test-token', opts);
246
+ await channel.connect();
247
+ const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
248
+ ctx.from.first_name = undefined;
249
+ ctx.from.username = undefined;
250
+ await triggerTextMessage(ctx);
251
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ sender_name: '42' }));
252
+ });
253
+ it('uses sender name as chat name for private chats', async () => {
254
+ const opts = createTestOpts({
255
+ registeredGroups: vi.fn(() => ({
256
+ 'tg:100200300': {
257
+ name: 'Private',
258
+ folder: 'private',
259
+ trigger: '@Andy',
260
+ added_at: '2024-01-01T00:00:00.000Z',
261
+ },
262
+ })),
263
+ });
264
+ const channel = new TelegramChannel('test-token', opts);
265
+ await channel.connect();
266
+ const ctx = createTextCtx({
267
+ text: 'Hello',
268
+ chatType: 'private',
269
+ firstName: 'Alice',
270
+ });
271
+ await triggerTextMessage(ctx);
272
+ expect(opts.onChatMetadata).toHaveBeenCalledWith('tg:100200300', expect.any(String), 'Alice', // Private chats use sender name
273
+ 'telegram', false);
274
+ });
275
+ it('uses chat title as name for group chats', async () => {
276
+ const opts = createTestOpts();
277
+ const channel = new TelegramChannel('test-token', opts);
278
+ await channel.connect();
279
+ const ctx = createTextCtx({
280
+ text: 'Hello',
281
+ chatType: 'supergroup',
282
+ chatTitle: 'Project Team',
283
+ });
284
+ await triggerTextMessage(ctx);
285
+ expect(opts.onChatMetadata).toHaveBeenCalledWith('tg:100200300', expect.any(String), 'Project Team', 'telegram', true);
286
+ });
287
+ it('converts message.date to ISO timestamp', async () => {
288
+ const opts = createTestOpts();
289
+ const channel = new TelegramChannel('test-token', opts);
290
+ await channel.connect();
291
+ const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
292
+ const ctx = createTextCtx({ text: 'Hello', date: unixTime });
293
+ await triggerTextMessage(ctx);
294
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
295
+ timestamp: '2024-01-01T00:00:00.000Z',
296
+ }));
297
+ });
298
+ });
299
+ // --- @mention translation ---
300
+ describe('@mention translation', () => {
301
+ it('translates @bot_username mention to trigger format', async () => {
302
+ const opts = createTestOpts();
303
+ const channel = new TelegramChannel('test-token', opts);
304
+ await channel.connect();
305
+ const ctx = createTextCtx({
306
+ text: '@andy_ai_bot what time is it?',
307
+ entities: [{ type: 'mention', offset: 0, length: 12 }],
308
+ });
309
+ await triggerTextMessage(ctx);
310
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
311
+ content: '@Andy @andy_ai_bot what time is it?',
312
+ }));
313
+ });
314
+ it('does not translate if message already matches trigger', async () => {
315
+ const opts = createTestOpts();
316
+ const channel = new TelegramChannel('test-token', opts);
317
+ await channel.connect();
318
+ const ctx = createTextCtx({
319
+ text: '@Andy @andy_ai_bot hello',
320
+ entities: [{ type: 'mention', offset: 6, length: 12 }],
321
+ });
322
+ await triggerTextMessage(ctx);
323
+ // Should NOT double-prepend — already starts with @Andy
324
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
325
+ content: '@Andy @andy_ai_bot hello',
326
+ }));
327
+ });
328
+ it('does not translate mentions of other bots', async () => {
329
+ const opts = createTestOpts();
330
+ const channel = new TelegramChannel('test-token', opts);
331
+ await channel.connect();
332
+ const ctx = createTextCtx({
333
+ text: '@some_other_bot hi',
334
+ entities: [{ type: 'mention', offset: 0, length: 15 }],
335
+ });
336
+ await triggerTextMessage(ctx);
337
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
338
+ content: '@some_other_bot hi', // No translation
339
+ }));
340
+ });
341
+ it('handles mention in middle of message', async () => {
342
+ const opts = createTestOpts();
343
+ const channel = new TelegramChannel('test-token', opts);
344
+ await channel.connect();
345
+ const ctx = createTextCtx({
346
+ text: 'hey @andy_ai_bot check this',
347
+ entities: [{ type: 'mention', offset: 4, length: 12 }],
348
+ });
349
+ await triggerTextMessage(ctx);
350
+ // Bot is mentioned, message doesn't match trigger → prepend trigger
351
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
352
+ content: '@Andy hey @andy_ai_bot check this',
353
+ }));
354
+ });
355
+ it('handles message with no entities', async () => {
356
+ const opts = createTestOpts();
357
+ const channel = new TelegramChannel('test-token', opts);
358
+ await channel.connect();
359
+ const ctx = createTextCtx({ text: 'plain message' });
360
+ await triggerTextMessage(ctx);
361
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
362
+ content: 'plain message',
363
+ }));
364
+ });
365
+ it('ignores non-mention entities', async () => {
366
+ const opts = createTestOpts();
367
+ const channel = new TelegramChannel('test-token', opts);
368
+ await channel.connect();
369
+ const ctx = createTextCtx({
370
+ text: 'check https://example.com',
371
+ entities: [{ type: 'url', offset: 6, length: 19 }],
372
+ });
373
+ await triggerTextMessage(ctx);
374
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({
375
+ content: 'check https://example.com',
376
+ }));
377
+ });
378
+ });
379
+ // --- Non-text messages ---
380
+ describe('non-text messages', () => {
381
+ it('stores photo with placeholder', async () => {
382
+ const opts = createTestOpts();
383
+ const channel = new TelegramChannel('test-token', opts);
384
+ await channel.connect();
385
+ const ctx = createMediaCtx({});
386
+ await triggerMediaMessage('message:photo', ctx);
387
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Photo]' }));
388
+ });
389
+ it('stores photo with caption', async () => {
390
+ const opts = createTestOpts();
391
+ const channel = new TelegramChannel('test-token', opts);
392
+ await channel.connect();
393
+ const ctx = createMediaCtx({ caption: 'Look at this' });
394
+ await triggerMediaMessage('message:photo', ctx);
395
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Photo] Look at this' }));
396
+ });
397
+ it('stores video with placeholder', async () => {
398
+ const opts = createTestOpts();
399
+ const channel = new TelegramChannel('test-token', opts);
400
+ await channel.connect();
401
+ const ctx = createMediaCtx({});
402
+ await triggerMediaMessage('message:video', ctx);
403
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Video]' }));
404
+ });
405
+ it('stores voice message with placeholder', async () => {
406
+ const opts = createTestOpts();
407
+ const channel = new TelegramChannel('test-token', opts);
408
+ await channel.connect();
409
+ const ctx = createMediaCtx({});
410
+ await triggerMediaMessage('message:voice', ctx);
411
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Voice message]' }));
412
+ });
413
+ it('stores audio with placeholder', async () => {
414
+ const opts = createTestOpts();
415
+ const channel = new TelegramChannel('test-token', opts);
416
+ await channel.connect();
417
+ const ctx = createMediaCtx({});
418
+ await triggerMediaMessage('message:audio', ctx);
419
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Audio]' }));
420
+ });
421
+ it('stores document with filename', async () => {
422
+ const opts = createTestOpts();
423
+ const channel = new TelegramChannel('test-token', opts);
424
+ await channel.connect();
425
+ const ctx = createMediaCtx({
426
+ extra: { document: { file_name: 'report.pdf' } },
427
+ });
428
+ await triggerMediaMessage('message:document', ctx);
429
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Document: report.pdf]' }));
430
+ });
431
+ it('stores document with fallback name when filename missing', async () => {
432
+ const opts = createTestOpts();
433
+ const channel = new TelegramChannel('test-token', opts);
434
+ await channel.connect();
435
+ const ctx = createMediaCtx({ extra: { document: {} } });
436
+ await triggerMediaMessage('message:document', ctx);
437
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Document: file]' }));
438
+ });
439
+ it('stores sticker with emoji', async () => {
440
+ const opts = createTestOpts();
441
+ const channel = new TelegramChannel('test-token', opts);
442
+ await channel.connect();
443
+ const ctx = createMediaCtx({
444
+ extra: { sticker: { emoji: '😂' } },
445
+ });
446
+ await triggerMediaMessage('message:sticker', ctx);
447
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Sticker 😂]' }));
448
+ });
449
+ it('stores location with placeholder', async () => {
450
+ const opts = createTestOpts();
451
+ const channel = new TelegramChannel('test-token', opts);
452
+ await channel.connect();
453
+ const ctx = createMediaCtx({});
454
+ await triggerMediaMessage('message:location', ctx);
455
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Location]' }));
456
+ });
457
+ it('stores contact with placeholder', async () => {
458
+ const opts = createTestOpts();
459
+ const channel = new TelegramChannel('test-token', opts);
460
+ await channel.connect();
461
+ const ctx = createMediaCtx({});
462
+ await triggerMediaMessage('message:contact', ctx);
463
+ expect(opts.onMessage).toHaveBeenCalledWith('tg:100200300', expect.objectContaining({ content: '[Contact]' }));
464
+ });
465
+ it('ignores non-text messages from unregistered chats', async () => {
466
+ const opts = createTestOpts();
467
+ const channel = new TelegramChannel('test-token', opts);
468
+ await channel.connect();
469
+ const ctx = createMediaCtx({ chatId: 999999 });
470
+ await triggerMediaMessage('message:photo', ctx);
471
+ expect(opts.onMessage).not.toHaveBeenCalled();
472
+ });
473
+ });
474
+ // --- sendMessage ---
475
+ describe('sendMessage', () => {
476
+ it('sends message via bot API', async () => {
477
+ const opts = createTestOpts();
478
+ const channel = new TelegramChannel('test-token', opts);
479
+ await channel.connect();
480
+ await channel.sendMessage('tg:100200300', 'Hello');
481
+ expect(currentBot().api.sendMessage).toHaveBeenCalledWith('100200300', 'Hello', { parse_mode: 'Markdown' });
482
+ });
483
+ it('strips tg: prefix from JID', async () => {
484
+ const opts = createTestOpts();
485
+ const channel = new TelegramChannel('test-token', opts);
486
+ await channel.connect();
487
+ await channel.sendMessage('tg:-1001234567890', 'Group message');
488
+ expect(currentBot().api.sendMessage).toHaveBeenCalledWith('-1001234567890', 'Group message', { parse_mode: 'Markdown' });
489
+ });
490
+ it('splits messages exceeding 4096 characters', async () => {
491
+ const opts = createTestOpts();
492
+ const channel = new TelegramChannel('test-token', opts);
493
+ await channel.connect();
494
+ const longText = 'x'.repeat(5000);
495
+ await channel.sendMessage('tg:100200300', longText);
496
+ expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
497
+ expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(1, '100200300', 'x'.repeat(4096), { parse_mode: 'Markdown' });
498
+ expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(2, '100200300', 'x'.repeat(904), { parse_mode: 'Markdown' });
499
+ });
500
+ it('sends exactly one message at 4096 characters', async () => {
501
+ const opts = createTestOpts();
502
+ const channel = new TelegramChannel('test-token', opts);
503
+ await channel.connect();
504
+ const exactText = 'y'.repeat(4096);
505
+ await channel.sendMessage('tg:100200300', exactText);
506
+ expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
507
+ });
508
+ it('handles send failure gracefully', async () => {
509
+ const opts = createTestOpts();
510
+ const channel = new TelegramChannel('test-token', opts);
511
+ await channel.connect();
512
+ currentBot().api.sendMessage.mockRejectedValueOnce(new Error('Network error'));
513
+ // Should not throw
514
+ await expect(channel.sendMessage('tg:100200300', 'Will fail')).resolves.toBeUndefined();
515
+ });
516
+ it('does nothing when bot is not initialized', async () => {
517
+ const opts = createTestOpts();
518
+ const channel = new TelegramChannel('test-token', opts);
519
+ // Don't connect — bot is null
520
+ await channel.sendMessage('tg:100200300', 'No bot');
521
+ // No error, no API call
522
+ });
523
+ });
524
+ // --- ownsJid ---
525
+ describe('ownsJid', () => {
526
+ it('owns tg: JIDs', () => {
527
+ const channel = new TelegramChannel('test-token', createTestOpts());
528
+ expect(channel.ownsJid('tg:123456')).toBe(true);
529
+ });
530
+ it('owns tg: JIDs with negative IDs (groups)', () => {
531
+ const channel = new TelegramChannel('test-token', createTestOpts());
532
+ expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
533
+ });
534
+ it('does not own WhatsApp group JIDs', () => {
535
+ const channel = new TelegramChannel('test-token', createTestOpts());
536
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
537
+ });
538
+ it('does not own WhatsApp DM JIDs', () => {
539
+ const channel = new TelegramChannel('test-token', createTestOpts());
540
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
541
+ });
542
+ it('does not own unknown JID formats', () => {
543
+ const channel = new TelegramChannel('test-token', createTestOpts());
544
+ expect(channel.ownsJid('random-string')).toBe(false);
545
+ });
546
+ });
547
+ // --- setTyping ---
548
+ describe('setTyping', () => {
549
+ it('sends typing action when isTyping is true', async () => {
550
+ const opts = createTestOpts();
551
+ const channel = new TelegramChannel('test-token', opts);
552
+ await channel.connect();
553
+ await channel.setTyping('tg:100200300', true);
554
+ expect(currentBot().api.sendChatAction).toHaveBeenCalledWith('100200300', 'typing');
555
+ });
556
+ it('does nothing when isTyping is false', async () => {
557
+ const opts = createTestOpts();
558
+ const channel = new TelegramChannel('test-token', opts);
559
+ await channel.connect();
560
+ await channel.setTyping('tg:100200300', false);
561
+ expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
562
+ });
563
+ it('does nothing when bot is not initialized', async () => {
564
+ const opts = createTestOpts();
565
+ const channel = new TelegramChannel('test-token', opts);
566
+ // Don't connect
567
+ await channel.setTyping('tg:100200300', true);
568
+ // No error, no API call
569
+ });
570
+ it('handles typing indicator failure gracefully', async () => {
571
+ const opts = createTestOpts();
572
+ const channel = new TelegramChannel('test-token', opts);
573
+ await channel.connect();
574
+ currentBot().api.sendChatAction.mockRejectedValueOnce(new Error('Rate limited'));
575
+ await expect(channel.setTyping('tg:100200300', true)).resolves.toBeUndefined();
576
+ });
577
+ });
578
+ // --- Bot commands ---
579
+ describe('bot commands', () => {
580
+ it('/chatid replies with chat ID and metadata', async () => {
581
+ const opts = createTestOpts();
582
+ const channel = new TelegramChannel('test-token', opts);
583
+ await channel.connect();
584
+ const handler = currentBot().commandHandlers.get('chatid');
585
+ const ctx = {
586
+ chat: { id: 100200300, type: 'group' },
587
+ from: { first_name: 'Alice' },
588
+ reply: vi.fn(),
589
+ };
590
+ await handler(ctx);
591
+ expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining('tg:100200300'), expect.objectContaining({ parse_mode: 'Markdown' }));
592
+ });
593
+ it('/chatid shows chat type', async () => {
594
+ const opts = createTestOpts();
595
+ const channel = new TelegramChannel('test-token', opts);
596
+ await channel.connect();
597
+ const handler = currentBot().commandHandlers.get('chatid');
598
+ const ctx = {
599
+ chat: { id: 555, type: 'private' },
600
+ from: { first_name: 'Bob' },
601
+ reply: vi.fn(),
602
+ };
603
+ await handler(ctx);
604
+ expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining('private'), expect.any(Object));
605
+ });
606
+ it('/ping replies with bot status', async () => {
607
+ const opts = createTestOpts();
608
+ const channel = new TelegramChannel('test-token', opts);
609
+ await channel.connect();
610
+ const handler = currentBot().commandHandlers.get('ping');
611
+ const ctx = { reply: vi.fn() };
612
+ await handler(ctx);
613
+ expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
614
+ });
615
+ });
616
+ // --- Channel properties ---
617
+ describe('channel properties', () => {
618
+ it('has name "telegram"', () => {
619
+ const channel = new TelegramChannel('test-token', createTestOpts());
620
+ expect(channel.name).toBe('telegram');
621
+ });
622
+ });
623
+ });
624
+ //# sourceMappingURL=telegram.test.js.map