-
-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathtelegram-bot-forum.js
More file actions
1302 lines (1146 loc) · 52.5 KB
/
telegram-bot-forum.js
File metadata and controls
1302 lines (1146 loc) · 52.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ─── Telegram Bot Forum Module ───────────────────────────────────────────────
// Extracted from telegram-bot.js — handles all Forum Mode (supergroup) logic.
// Uses composition pattern: receives an API facade, NOT the bot instance.
// State scoped to (chatId, threadId, userId) — never touches Direct Mode context.
'use strict';
class TelegramBotForum {
/**
* @param {Object} api - Facade object from TelegramBot
* @param {import('better-sqlite3').Database} api.db
* @param {Object} api.log
* @param {Function} api.callApi - Telegram API call
* @param {Function} api.sendMessage - Send message with HTML, auto-retry
* @param {Function} api.editScreen - Edit inline screen
* @param {Function} api.showScreen - Send new screen
* @param {Function} api.t - i18n translation
* @param {Function} api.escHtml - HTML escape
* @param {Function} api.sanitize - Content sanitization
* @param {Function} api.mdToHtml - Markdown to HTML
* @param {Function} api.chunkForTelegram - Message chunking
* @param {Function} api.timeAgo - Time formatting
* @param {Object} api.stmts - Prepared SQL statements
* @param {Function} api.emit - EventEmitter emit (for send_message, stop_task)
* @param {Function} api.getDirectContext - Get direct mode ctx (for shared commands)
* @param {Function} api.saveDeviceContext - Persist ctx to SQLite
* @param {Function} api.botId - Returns bot's own user ID
* @param {Function} api.cmdStatus - Shared /status command
* @param {Function} api.cmdFiles - Shared /files command
* @param {Function} api.cmdCat - Shared /cat command
* @param {Function} api.cmdLast - Shared /last command
* @param {Function} api.cmdFull - Shared /full command
* @param {Function} api.cmdDiff - Shared /diff command
* @param {Function} api.cmdLog - Shared /log command
* @param {Function} api.cmdStop - Shared /stop command
* @param {Function} api.handleMediaMessage - Shared media handler
*/
constructor(api) {
this._api = api;
this._forumTopics = new Map(); // chatId:threadId -> { type, workdir, chatId }
this._forumContext = new Map(); // chatId:threadId:userId -> forum-scoped state
this._loadTopicsFromDb();
}
// ─── Forum-Scoped State (FORUM-02 fix) ──────────────────────────────────
/**
* Get forum-scoped context for a specific topic + user.
* Uses composite key chatId:threadId:userId to isolate state from Direct Mode.
*/
_getForumContext(chatId, threadId, userId) {
const key = `${chatId}:${threadId}:${userId}`;
if (!this._forumContext.has(key)) {
this._forumContext.set(key, {
sessionId: null,
projectWorkdir: null,
pendingAttachments: [],
isStreaming: false,
streamMsgId: null,
});
}
return this._forumContext.get(key);
}
// ─── Topic Cache ────────────────────────────────────────────────────────
_loadTopicsFromDb() {
try {
const rows = this._api.stmts.getForumTopics
? this._api.db.prepare('SELECT * FROM forum_topics').all()
: [];
for (const row of rows) {
const key = `${row.chat_id}:${row.thread_id}`;
this._forumTopics.set(key, {
type: row.type,
workdir: row.workdir,
chatId: row.chat_id,
});
}
} catch (err) {
this._api.log.warn(`[forum] Failed to load topics cache: ${err.message}`);
}
}
/**
* Get topic info by threadId (from cache or DB).
* Public accessor — called by parent bot for topic guard.
*/
getTopicInfo(chatId, threadId) {
const key = `${chatId}:${threadId}`;
if (this._forumTopics.has(key)) return this._forumTopics.get(key);
const row = this._api.stmts.getForumTopic.get(threadId, chatId);
if (row) {
const info = { type: row.type, workdir: row.workdir, chatId };
this._forumTopics.set(key, info);
return info;
}
return null;
}
/**
* Generate a deep link URL to a specific forum topic.
* Format: https://t.me/c/{internal_id}/{thread_id}
*/
_topicLink(chatId, threadId) {
const idStr = String(chatId);
const internalId = idStr.startsWith('-100') ? idStr.slice(4) : idStr.replace('-', '');
return `https://t.me/c/${internalId}/${threadId}`;
}
// ─── Public Entry Points ───────────────────────────────────────────────
/**
* Main handler for messages in a forum supergroup.
* Routes based on which topic the message is in.
* Called from TelegramBot._handleUpdate.
*/
async handleMessage(msg, threadId) {
const chatId = msg.chat.id;
const userId = msg.from.id;
const text = (msg.text || '').trim();
// General topic (no thread_id) — handle basic commands
if (!threadId) {
if (text.startsWith('/')) return this._handleForumGeneralCommand(msg);
return;
}
const topicInfo = this.getTopicInfo(chatId, threadId);
if (!topicInfo) {
// Unknown topic — either user-created or the General topic
if (text.startsWith('/')) return this._handleForumGeneralCommand(msg);
if (text) await this._api.sendMessage(chatId, this._api.t('forum_unknown_topic'));
return;
}
switch (topicInfo.type) {
case 'project':
return this._handleForumProjectMessage(msg, topicInfo.workdir, threadId);
case 'tasks':
return this._handleForumTaskMessage(msg, threadId);
case 'activity':
if ((msg.text || '').trim()) {
await this._api.sendMessage(chatId, this._api.t('forum_activity_readonly'));
}
return;
default:
return;
}
}
/**
* Callback entry point — routes fs:/fm:/fa: callbacks.
* Called from TelegramBot._handleCallback.
*/
async handleCallback(chatId, userId, data, threadId, msgId) {
// Route by prefix — check ft: and fo: before f: to avoid prefix collision (Pitfall 3)
if (data.startsWith('ft:')) return this.handleTaskCallback(chatId, userId, data, threadId);
if (data.startsWith('fo:')) return this.handleOnboardingCallback(chatId, userId, data, msgId);
if (data.startsWith('fs:')) return this.handleSessionCallback(chatId, userId, data, threadId);
if (data.startsWith('fm:')) return this.handleActionCallback(chatId, userId, data, threadId);
if (data.startsWith('fa:')) return this.handleActivityCallback(chatId, userId, data, threadId);
}
/**
* /connect command in a supergroup — pair the forum group.
*/
async handleConnect(msg) {
const userId = msg.from.id;
const chatId = msg.chat.id;
// Must be a supergroup with Topics enabled
if (msg.chat.type !== 'supergroup' || !msg.chat.is_forum) {
return this._api.sendMessage(chatId, this._api.t('forum_not_supergroup'));
}
// User must already be paired via private chat
const device = this._api.stmts.getDevice.get(userId);
if (!device) {
return this._api.sendMessage(chatId, this._api.t('forum_not_paired'));
}
// Check bot has manage_topics permission
try {
const botMember = await this._api.callApi('getChatMember', { chat_id: chatId, user_id: this._api.botId() });
const canManage = botMember?.can_manage_topics || botMember?.status === 'creator';
if (!canManage) {
return this._api.sendMessage(chatId, this._api.t('forum_not_admin'));
}
} catch {
return this._api.sendMessage(chatId, this._api.t('forum_not_admin'));
}
// Save forum_chat_id (or keep existing)
const alreadyConnected = device?.forum_chat_id === chatId;
if (!alreadyConnected) {
this._api.stmts.setForumChatId.run(chatId, userId);
}
// Send status message BEFORE the actual creation
await this._api.sendMessage(chatId, this._api.t(alreadyConnected ? 'forum_syncing' : 'forum_connected'));
try {
await this._createForumStructure(chatId);
// Set forum-scoped commands (FORUM-07) — show only relevant commands in supergroup
await this._setForumCommands(chatId);
} catch (err) {
if (!alreadyConnected) {
this._api.stmts.setForumChatId.run(null, userId);
}
this._api.log.error(`[forum] Connect failed: ${err.message}`);
await this._api.sendMessage(chatId, this._api.t('forum_not_admin'));
}
}
/**
* /forum command in private chat — show setup instructions.
*/
async cmdForum(chatId, userId) {
const device = this._api.stmts.getDevice.get(userId);
const navButtons = { reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('btn_back_menu'), callback_data: 'm:menu' }],
] }) };
if (device?.forum_chat_id) {
return this._api.sendMessage(chatId, this._api.t('forum_already'), navButtons);
}
await this._api.sendMessage(chatId, this._api.t('forum_instructions'), navButtons);
}
/**
* Disconnect forum — remove pairing, clean up DB + cache.
*/
async cmdForumDisconnect(chatId, userId) {
const device = this._api.stmts.getDevice.get(userId);
const forumChatId = device?.forum_chat_id;
// Clear forum_chat_id from device
this._api.stmts.setForumChatId.run(null, userId);
// Clean up forum_topics in DB and _forumTopics cache
if (forumChatId) {
this._api.stmts.deleteForumTopicsByChatId.run(forumChatId);
for (const key of this._forumTopics.keys()) {
if (key.startsWith(`${forumChatId}:`)) this._forumTopics.delete(key);
}
}
await this._api.sendMessage(chatId, this._api.t('forum_disconnected'));
}
/**
* Create a single project topic in the forum.
* Public — called by parent bot for auto-create from activity callback.
*/
async createProjectTopic(chatId, workdir, displayName) {
const name = displayName || workdir.split('/').filter(Boolean).pop() || workdir;
try {
const topic = await this._api.callApi('createForumTopic', {
chat_id: chatId,
name: `📁 ${name}`,
});
this._api.stmts.addForumTopic.run(topic.message_thread_id, chatId, 'project', workdir);
this._forumTopics.set(`${chatId}:${topic.message_thread_id}`, { type: 'project', workdir, chatId });
// Pin project info (pass thread_id explicitly)
const pinMsg = await this._api.sendMessage(chatId, this._api.t('forum_topic_project', {
name: this._api.escHtml(name),
path: this._api.escHtml(workdir),
}), {
message_thread_id: topic.message_thread_id,
reply_markup: JSON.stringify({ inline_keyboard: [[
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },
{ text: this._api.t('fm_btn_new'), callback_data: 'fm:new' },
{ text: this._api.t('fm_btn_info'), callback_data: 'fm:info' },
]] }),
});
if (pinMsg) {
this._api.callApi('pinChatMessage', { chat_id: chatId, message_id: pinMsg.message_id, disable_notification: true }).catch(() => {});
}
return topic.message_thread_id;
} catch (err) {
this._api.log.error(`[forum] Failed to create project topic for ${name}: ${err.message}`);
return null;
}
}
/**
* Send a notification to the Activity topic in the forum.
*/
async notifyActivity(forumChatId, text, sessionId) {
const topics = this._api.stmts.getForumTopics.all(forumChatId);
const activityTopic = topics.find(t => t.type === 'activity');
if (!activityTopic) return false;
// Build action buttons — find project topic for this session
const options = { message_thread_id: activityTopic.thread_id, parse_mode: 'HTML' };
if (sessionId) {
const session = this._api.db.prepare('SELECT workdir FROM sessions WHERE id = ?').get(sessionId);
if (session?.workdir) {
const urlRow = [];
// Always use callback so the bot can switch session + show chat preview
urlRow.push({ text: this._api.t('fm_btn_open_chat'), callback_data: `fa:open:${sessionId}` });
// Action buttons row — Continue session or start New session
const actionRow = [
{ text: this._api.t('fm_btn_continue'), callback_data: `fa:continue:${sessionId}` },
{ text: this._api.t('fm_btn_new'), callback_data: `fa:new:${session.workdir}` },
];
options.reply_markup = JSON.stringify({ inline_keyboard: [urlRow, actionRow] });
}
}
await this._api.sendMessage(forumChatId, text, options);
return true;
}
/**
* Send ask_user notification to Forum Activity topic with project link.
*/
async notifyAskUser(forumChatId, text, session, answerRows) {
const topics = this._api.stmts.getForumTopics.all(forumChatId);
const activityTopic = topics.find(t => t.type === 'activity');
if (!activityTopic) return;
// Clone rows and add "Go to chat" URL button if project topic exists
const rows = answerRows.map(r => [...r]);
if (session?.workdir) {
const projectTopic = topics.find(t => t.type === 'project' && t.workdir === session.workdir);
if (projectTopic) {
const topicUrl = this._topicLink(forumChatId, projectTopic.thread_id);
rows.push([{ text: this._api.t('ask_notify_go_to_chat'), url: topicUrl }]);
}
}
await this._api.sendMessage(forumChatId, text, {
message_thread_id: activityTopic.thread_id,
parse_mode: 'HTML',
reply_markup: JSON.stringify({ inline_keyboard: rows }),
});
}
// ─── Forum-Scoped Commands (FORUM-07) ──────────────────────────────────
/**
* Set forum-scoped bot commands via setMyCommands.
* Shows only forum-relevant commands when in the supergroup.
*/
async _setForumCommands(chatId) {
try {
await this._api.callApi('setMyCommands', {
commands: JSON.stringify([
{ command: 'help', description: this._api.t('fm_cmd_help_desc') },
{ command: 'status', description: this._api.t('fm_cmd_status_desc') },
{ command: 'new', description: this._api.t('fm_cmd_new_desc') },
{ command: 'stop', description: this._api.t('fm_cmd_stop_desc') },
]),
scope: JSON.stringify({ type: 'chat', chat_id: chatId }),
});
} catch (err) {
this._api.log.warn(`[forum] Failed to set forum commands: ${err.message}`);
}
}
// ─── Internal Methods ──────────────────────────────────────────────────
/**
* Create the initial forum structure: Tasks + Activity topics.
* Project topics are created on demand.
*/
async _createForumStructure(chatId) {
try {
// Check if topics already exist
const existing = this._api.stmts.getForumTopics.all(chatId);
const hasTasksTopic = existing.some(t => t.type === 'tasks');
const hasActivityTopic = existing.some(t => t.type === 'activity');
if (!hasTasksTopic) {
this._api.log.info(`[forum] Creating Tasks topic in forum ${chatId}`);
const tasksTopic = await this._api.callApi('createForumTopic', {
chat_id: chatId,
name: '📋 Tasks',
});
this._api.log.info(`[forum] Tasks topic created: thread_id=${tasksTopic.message_thread_id}`);
this._api.stmts.addForumTopic.run(tasksTopic.message_thread_id, chatId, 'tasks', null);
this._forumTopics.set(`${chatId}:${tasksTopic.message_thread_id}`, { type: 'tasks', workdir: null, chatId });
// Pin instructions in Tasks topic
const pinMsg = await this._api.sendMessage(chatId, this._api.t('forum_topic_tasks'), {
message_thread_id: tasksTopic.message_thread_id,
});
if (pinMsg) {
this._api.callApi('pinChatMessage', { chat_id: chatId, message_id: pinMsg.message_id, disable_notification: true }).catch(() => {});
}
}
if (!hasActivityTopic) {
this._api.log.info(`[forum] Creating Activity topic in forum ${chatId}`);
const activityTopic = await this._api.callApi('createForumTopic', {
chat_id: chatId,
name: '🔔 Activity',
});
this._api.log.info(`[forum] Activity topic created: thread_id=${activityTopic.message_thread_id}`);
this._api.stmts.addForumTopic.run(activityTopic.message_thread_id, chatId, 'activity', null);
this._forumTopics.set(`${chatId}:${activityTopic.message_thread_id}`, { type: 'activity', workdir: null, chatId });
// Pin instructions in Activity topic
const pinMsg = await this._api.sendMessage(chatId, this._api.t('forum_topic_activity'), {
message_thread_id: activityTopic.message_thread_id,
});
if (pinMsg) {
this._api.callApi('pinChatMessage', { chat_id: chatId, message_id: pinMsg.message_id, disable_notification: true }).catch(() => {});
}
}
// Notify in General topic (no thread_id — goes to General)
this._api.log.info(`[forum] Forum structure created, sending confirmation`);
await this._api.sendMessage(chatId, this._api.t('forum_created_topics'));
// Create topics for existing projects
await this._syncProjectTopics(chatId);
} catch (err) {
this._api.log.error(`[forum] Failed to create forum structure: ${err.message}`);
throw err; // Re-throw so handleConnect can rollback
}
}
/**
* Create forum topics for projects that don't have one yet.
*/
async _syncProjectTopics(chatId) {
try {
const projectsData = require('fs').readFileSync(
require('path').join(process.cwd(), 'data', 'projects.json'), 'utf8'
);
const projects = JSON.parse(projectsData);
if (!Array.isArray(projects)) return;
for (const project of projects) {
const workdir = typeof project === 'string' ? project : (project?.workdir || project?.path);
if (!workdir) continue;
const existing = this._api.stmts.getForumTopicByWorkdir.get(chatId, 'project', workdir);
if (existing) continue;
const name = (typeof project === 'object' && project?.name) || null;
await this.createProjectTopic(chatId, workdir, name);
}
} catch {
// projects.json may not exist — that's fine
}
}
/**
* Handle commands in the General topic or unknown topics.
*/
async _handleForumGeneralCommand(msg) {
const chatId = msg.chat.id;
const text = (msg.text || '').trim();
const cmd = text.split(/\s+/)[0].toLowerCase().replace(/@\w+$/, '');
if (cmd === '/status') return this._api.cmdStatus(chatId, msg.from.id);
if (cmd === '/help') return this._api.sendMessage(chatId, this._api.t('forum_help_general'));
// Unknown command feedback
if (cmd.startsWith('/')) {
return this._api.sendMessage(chatId, this._api.t('forum_unknown_cmd', { cmd: this._api.escHtml(cmd) }));
}
}
/**
* Handle messages in a Project topic — send to Claude.
* threadId is passed explicitly (FORUM-03).
*/
async _handleForumProjectMessage(msg, workdir, threadId) {
const chatId = msg.chat.id;
const userId = msg.from.id;
const text = (msg.text || '').trim();
// Use direct mode context for shared commands + session persistence
const ctx = this._api.getDirectContext(userId);
// Set project context + validate session belongs to this project
ctx.projectWorkdir = workdir;
if (ctx.sessionId) {
const sess = this._api.db.prepare('SELECT workdir FROM sessions WHERE id = ?').get(ctx.sessionId);
if (!sess || sess.workdir !== workdir) {
// Session from different project — restore last session for this project, or clear
const lastForProject = this._api.stmts.getSessionsByWorkdir.all(workdir);
ctx.sessionId = lastForProject.length ? lastForProject[0].id : null;
this._api.saveDeviceContext(userId);
}
}
// Persistent keyboard buttons send their label text into the topic — intercept before Claude
// Exact match first (emoji + localized label)
if (text === this._api.t('kb_menu')) return this._forumShowInfo(chatId, userId, workdir, threadId);
if (text === this._api.t('kb_status')) return this._api.cmdStatus(chatId, userId);
if (text.startsWith(this._api.t('kb_write'))) return; // In forum mode, just type directly in the topic
if (text.startsWith(this._api.t('kb_project_prefix'))) return; // Project button ignored in forum mode
// Fallback: match keyboard button text from ANY configured language
// Handles: different language setting, emoji encoding, manual typing
{
const low = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').trim();
const menuWords = ['menu', 'меню'];
const statusWords = ['status', 'статус'];
const writeWords = ['write', 'написати', 'написать'];
if (menuWords.includes(low)) return this._forumShowInfo(chatId, userId, workdir, threadId);
if (statusWords.includes(low)) return this._api.cmdStatus(chatId, userId);
if (writeWords.some(w => low.startsWith(w))) return; // Ignore write button in forum
}
// Intercept: if there's a pending ask_user question, text resolves it (same as private chat)
if (ctx.state === 'AWAITING_ASK_RESPONSE') {
const requestId = ctx.stateData?.askRequestId;
const origAskMsgId = ctx.stateData?.askMsgId;
const origAskChatId = ctx.stateData?.askChatId;
ctx.state = 'IDLE';
ctx.stateData = null;
this._api.emit('ask_user_response', { requestId, answer: text });
await this._api.sendMessage(chatId, this._api.t('ask_answered'));
// Clean up original ask message (remove stale buttons)
if (origAskMsgId && origAskChatId) {
this._api.callApi('editMessageText', {
chat_id: origAskChatId,
message_id: origAskMsgId,
text: this._api.t('ask_answered'),
}).catch(() => {});
}
return;
}
// Handle project-specific commands
if (text.startsWith('/')) {
const [rawCmd, ...argParts] = text.split(/\s+/);
const cmd = rawCmd.toLowerCase().replace(/@\w+$/, '');
const args = argParts.join(' ');
switch (cmd) {
case '/new':
return this._forumNewSession(chatId, userId, workdir);
case '/history':
return this._forumShowHistory(chatId, userId, workdir);
case '/session': {
const idx = parseInt(args) - 1;
if (isNaN(idx) || idx < 0) return this._api.sendMessage(chatId, '💡 /session <i>N</i>');
return this._forumSwitchSession(chatId, userId, workdir, idx);
}
case '/files':
return this._api.cmdFiles(chatId, userId, argParts.length ? argParts : ['.']);
case '/cat':
return this._api.cmdCat(chatId, userId, argParts);
case '/last':
return this._api.cmdLast(chatId, userId, argParts);
case '/full':
return this._api.cmdFull(chatId, userId);
case '/diff':
return this._api.cmdDiff(chatId, userId);
case '/log':
return this._api.cmdLog(chatId, userId, argParts);
case '/stop':
return this._api.cmdStop(chatId, userId);
case '/status':
return this._api.cmdStatus(chatId, userId);
case '/help':
return this._api.sendMessage(chatId, this._api.t('forum_help_project'));
case '/info':
return this._forumShowInfo(chatId, userId, workdir, threadId);
default:
// Unknown command — treat as message to Claude
break;
}
}
if (!text) {
if (msg.photo || msg.document) {
return this._api.handleMediaMessage(msg);
}
return this._api.sendMessage(chatId, this._api.t('forum_no_text'));
}
// Ensure session exists for this project — show orientation on first interaction
if (!ctx.sessionId) {
const existing = this._api.stmts.getSessionsByWorkdir.all(workdir);
if (existing.length > 0) {
// Restore last session + show orientation
ctx.sessionId = existing[0].id;
this._api.saveDeviceContext(userId);
const title = (existing[0].title || this._api.t('chat_untitled')).substring(0, 40);
const buttons = [
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },
{ text: this._api.t('fm_btn_new'), callback_data: 'fm:new' },
];
await this._api.sendMessage(chatId,
`📌 ${this._api.escHtml(title)} (${existing[0].msg_count || 0} msg)\n` +
(existing.length > 1 ? `📜 +${existing.length - 1} sessions\n` : ''),
{ reply_markup: JSON.stringify({ inline_keyboard: [buttons] }) }
);
} else {
await this._forumNewSession(chatId, userId, workdir, true);
}
}
// Collect attachments from forum-scoped context (not direct mode)
const forumCtx = this._getForumContext(chatId, threadId, userId);
const attachments = forumCtx.pendingAttachments || [];
forumCtx.pendingAttachments = [];
// Send to Claude
this._api.emit('send_message', {
sessionId: ctx.sessionId,
text,
userId,
chatId,
threadId,
attachments,
callback: async (result) => {
if (result.error) {
await this._api.sendMessage(chatId, `❌ ${this._api.escHtml(result.error)}`);
}
},
});
}
/**
* Create a new session in a forum project topic.
*/
async _forumNewSession(chatId, userId, workdir, silent = false) {
const ctx = this._api.getDirectContext(userId);
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
this._api.stmts.insertSession.run(id, 'Telegram Session', workdir);
ctx.sessionId = id;
ctx.projectWorkdir = workdir;
this._api.saveDeviceContext(userId);
if (!silent) {
const buttons = [
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },
];
await this._api.sendMessage(chatId, this._api.t('forum_session_started'), {
reply_markup: JSON.stringify({ inline_keyboard: [buttons] }),
});
}
}
/**
* Show current session info and project state.
* Public — also called from parent bot for forum topic guard redirect.
*/
async showInfo(chatId, userId, workdir, threadId) {
return this._forumShowInfo(chatId, userId, workdir, threadId);
}
async _forumShowInfo(chatId, userId, workdir, threadId) {
const ctx = this._api.getDirectContext(userId);
const projectName = workdir.split('/').filter(Boolean).pop() || workdir;
const rows = this._api.stmts.getSessionsByWorkdir.all(workdir);
let text = `📁 <b>${this._api.escHtml(projectName)}</b>\n📂 <code>${this._api.escHtml(workdir)}</code>\n`;
if (ctx.sessionId) {
const sess = this._api.db.prepare('SELECT title, updated_at FROM sessions WHERE id = ?').get(ctx.sessionId);
if (sess) {
const title = (sess.title || this._api.t('chat_untitled')).substring(0, 45);
const ago = this._api.timeAgo(sess.updated_at);
const msgCount = this._api.db.prepare('SELECT COUNT(*) as c FROM messages WHERE session_id = ?').get(ctx.sessionId)?.c || 0;
text += `\n💬 <b>${this._api.escHtml(title)}</b>\n📊 ${msgCount} msg · ${ago}`;
}
} else {
text += `\n💬 <i>${this._api.t('error_no_session')}</i>`;
}
text += `\n📜 ${this._api.t('status_sessions', { count: rows.length })}`;
const buttons = [
{ text: this._api.t('fm_btn_last5'), callback_data: 'fm:last' },
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },
{ text: this._api.t('fm_btn_new'), callback_data: 'fm:new' },
];
await this._api.sendMessage(chatId, text, {
reply_markup: JSON.stringify({ inline_keyboard: [buttons] }),
});
}
/**
* Show session history for a project in forum mode.
*/
async _forumShowHistory(chatId, userId, workdir) {
const ctx = this._api.getDirectContext(userId);
const rows = this._api.stmts.getSessionsByWorkdir.all(workdir);
if (rows.length === 0) {
return this._api.sendMessage(chatId, this._api.t('forum_history_empty'));
}
let text = this._api.t('forum_history_title', { count: rows.length });
const keyboard = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const active = r.id === ctx.sessionId ? ' ◀️' : '';
const title = (r.title || this._api.t('chat_untitled')).substring(0, 40);
const ago = this._api.timeAgo(r.updated_at);
text += `\n${i + 1}. ${this._api.escHtml(title)} · ${r.msg_count} msgs · ${ago}${active}`;
// Inline button for quick switching (2 buttons per row)
const btn = { text: `${i + 1}. ${title.substring(0, 25)}${active}`, callback_data: `fs:${i}` };
if (i % 2 === 0) keyboard.push([btn]); else keyboard[keyboard.length - 1].push(btn);
}
await this._api.sendMessage(chatId, text, {
reply_markup: JSON.stringify({ inline_keyboard: keyboard }),
});
}
/**
* Handle forum session switch callback (fs:N).
* threadId passed explicitly (FORUM-03).
*/
async handleSessionCallback(chatId, userId, data, threadId) {
const idx = parseInt(data.slice(3));
if (isNaN(idx) || idx < 0) return;
if (!threadId) return;
const topicInfo = this.getTopicInfo(chatId, threadId);
if (!topicInfo?.workdir) return;
return this._forumSwitchSession(chatId, userId, topicInfo.workdir, idx);
}
/**
* Handle forum action callbacks (fm:history, fm:new, fm:compose, fm:diff, fm:files, fm:stop, fm:retry, fm:last, fm:info).
* threadId passed explicitly (FORUM-03).
*/
async handleActionCallback(chatId, userId, data, threadId) {
const action = data.slice(3);
if (!threadId) return;
const topicInfo = this.getTopicInfo(chatId, threadId);
if (!topicInfo?.workdir) return;
// Always sync user context to the topic we're acting in
const ctx = this._api.getDirectContext(userId);
ctx.projectWorkdir = topicInfo.workdir;
if (ctx.sessionId) {
const sess = this._api.db.prepare('SELECT workdir FROM sessions WHERE id = ?').get(ctx.sessionId);
if (!sess || sess.workdir !== topicInfo.workdir) {
const lastForProject = this._api.stmts.getSessionsByWorkdir.all(topicInfo.workdir);
ctx.sessionId = lastForProject.length ? lastForProject[0].id : null;
}
}
switch (action) {
case 'history':
return this._forumShowHistory(chatId, userId, topicInfo.workdir);
case 'new':
return this._forumNewSession(chatId, userId, topicInfo.workdir);
case 'compose': {
// Prompt user to type their message
await this._api.sendMessage(chatId, this._api.t('compose_prompt'), {
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('btn_cancel'), callback_data: 'fm:info' }],
]}),
});
return;
}
case 'diff':
return this._api.cmdDiff(chatId, userId);
case 'files':
return this._api.cmdFiles(chatId, userId, ['.']);
case 'stop':
return this._api.cmdStop(chatId, userId);
case 'info':
return this._forumShowInfo(chatId, userId, topicInfo.workdir, threadId);
case 'last': {
// Show last 5 messages of current session
if (!ctx.sessionId) return this._api.sendMessage(chatId, this._api.t('forum_history_empty'));
return this._api.cmdLast(chatId, userId, ['5']);
}
case 'retry': {
// Resend the last user message
if (!ctx.sessionId) return;
const lastUserMsg = this._api.db.prepare(
`SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY id DESC LIMIT 1`
).get(ctx.sessionId);
if (!lastUserMsg?.content) return;
this._api.emit('send_message', {
sessionId: ctx.sessionId,
text: lastUserMsg.content,
userId,
chatId,
threadId,
attachments: [],
callback: async (result) => {
if (result.error) await this._api.sendMessage(chatId, `❌ ${this._api.escHtml(result.error)}`);
},
});
return;
}
case 'help': {
// Show context-appropriate help — determine topic type
const helpTopicInfo = this.getTopicInfo(chatId, threadId);
const helpKey = helpTopicInfo?.type === 'tasks' ? 'forum_help_tasks'
: helpTopicInfo?.type === 'project' ? 'forum_help_project'
: 'forum_help_general';
return this._api.sendMessage(chatId, this._api.t(helpKey), {
message_thread_id: threadId,
});
}
}
}
/**
* Handle activity notification callbacks (fa:open:sessionId, fa:project:threadId).
* threadId passed explicitly (FORUM-03).
*/
async handleActivityCallback(chatId, userId, data, threadId) {
const parts = data.slice(3).split(':');
const action = parts[0];
const param = parts.slice(1).join(':');
switch (action) {
case 'open': {
// Open chat — show last messages from this session
const session = this._api.db.prepare('SELECT id, title, workdir FROM sessions WHERE id = ?').get(param);
if (!session) return this._api.sendMessage(chatId, this._api.t('forum_task_not_found'));
// Find project topic for this workdir and switch session there
const topics = this._api.stmts.getForumTopics.all(chatId);
let projectTopic = topics.find(t => t.type === 'project' && t.workdir === session.workdir);
// Auto-create project topic if it doesn't exist yet
if (!projectTopic) {
const newThreadId = await this.createProjectTopic(chatId, session.workdir);
if (!newThreadId) {
return this._api.sendMessage(chatId, '❌ Failed to create project topic.');
}
projectTopic = { thread_id: newThreadId, type: 'project', workdir: session.workdir };
}
// Switch user's active session
const ctx = this._api.getDirectContext(userId);
ctx.projectWorkdir = session.workdir;
ctx.sessionId = session.id;
this._api.saveDeviceContext(userId);
// Show last messages in the project topic
const msgs = this._api.db.prepare(
`SELECT role, type, content FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 5`
).all(session.id).reverse();
let text = `💬 <b>${this._api.escHtml((session.title || this._api.t('chat_untitled')).substring(0, 50))}</b>\n`;
for (const m of msgs) {
const icon = m.role === 'user' ? '👤' : '🤖';
const content = (m.content || '').substring(0, 150).replace(/\n/g, ' ');
text += `\n${icon} ${this._api.escHtml(content)}`;
}
if (msgs.length === 0) text += `\n<i>${this._api.t('chat_no_messages')}</i>`;
// Send message to project topic + provide direct link for navigation
const topicUrl = this._topicLink(chatId, projectTopic.thread_id);
text += `\n\n${this._api.t('fm_session_activated_hint')}`;
const buttons = [
[
{ text: this._api.t('fm_btn_continue'), callback_data: 'fm:compose' },
{ text: this._api.t('fm_btn_full'), callback_data: 'cm:full' },
],
[
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },
{ text: this._api.t('fm_btn_new'), callback_data: 'fm:new' },
],
];
await this._api.sendMessage(chatId, text, {
message_thread_id: projectTopic.thread_id,
parse_mode: 'HTML',
reply_markup: JSON.stringify({ inline_keyboard: buttons }),
});
// Also send a navigation link in the Activity topic so user can jump there
await this._api.sendMessage(chatId, this._api.t('fm_session_activated_short'), {
message_thread_id: threadId,
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('fm_btn_go_project'), url: topicUrl }],
]}),
});
return;
}
case 'continue': {
// Continue session from Activity topic — switch session and prompt in project topic
const contSession = this._api.db.prepare('SELECT id, title, workdir FROM sessions WHERE id = ?').get(param);
if (!contSession) return this._api.sendMessage(chatId, this._api.t('forum_task_not_found'));
const ctx = this._api.getDirectContext(userId);
ctx.projectWorkdir = contSession.workdir;
ctx.sessionId = contSession.id;
this._api.saveDeviceContext(userId);
// Find project topic for navigation
const contTopics = this._api.stmts.getForumTopics.all(chatId);
const contProjectTopic = contTopics.find(t => t.type === 'project' && t.workdir === contSession.workdir);
if (contProjectTopic) {
// Send compose prompt in the project topic
await this._api.sendMessage(chatId, this._api.t('compose_prompt'), {
message_thread_id: contProjectTopic.thread_id,
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('btn_cancel'), callback_data: 'fm:info' }],
]}),
});
// Link back from activity topic
const topicUrl = this._topicLink(chatId, contProjectTopic.thread_id);
await this._api.sendMessage(chatId, this._api.t('fm_session_activated_short'), {
message_thread_id: threadId,
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('fm_btn_go_project'), url: topicUrl }],
]}),
});
}
return;
}
case 'new': {
// Create new session from Activity topic — param is workdir
const newWorkdir = param;
if (!newWorkdir) return;
const newTopics = this._api.stmts.getForumTopics.all(chatId);
let newProjectTopic = newTopics.find(t => t.type === 'project' && t.workdir === newWorkdir);
// Auto-create project topic if it doesn't exist yet
if (!newProjectTopic) {
const newThreadId = await this.createProjectTopic(chatId, newWorkdir);
if (!newThreadId) return this._api.sendMessage(chatId, '❌ Failed to create project topic.');
newProjectTopic = { thread_id: newThreadId, type: 'project', workdir: newWorkdir };
}
await this._forumNewSession(chatId, userId, newWorkdir);
// Link to project topic from activity
const topicUrl = this._topicLink(chatId, newProjectTopic.thread_id);
await this._api.sendMessage(chatId, this._api.t('fm_session_activated_short'), {
message_thread_id: threadId,
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('fm_btn_go_project'), url: topicUrl }],
]}),
});
return;
}
case 'project': {
// Navigate to project topic — send URL link for direct navigation
const targetThreadId = parseInt(param);
if (isNaN(targetThreadId)) return;
const topicUrl = this._topicLink(chatId, targetThreadId);
await this._api.sendMessage(chatId, this._api.t('fm_write_in_topic'), {
message_thread_id: threadId,
parse_mode: 'HTML',
reply_markup: JSON.stringify({ inline_keyboard: [
[{ text: this._api.t('fm_btn_go_project'), url: topicUrl }],
]}),
});
return;
}
}
}
/**
* Switch to a specific session by index (from /history list).
*/
async _forumSwitchSession(chatId, userId, workdir, idx) {
const rows = this._api.stmts.getSessionsByWorkdir.all(workdir);
if (idx >= rows.length) {
return this._api.sendMessage(chatId, this._api.t('forum_task_not_found'));
}
const ctx = this._api.getDirectContext(userId);
ctx.sessionId = rows[idx].id;
this._api.saveDeviceContext(userId);
const title = (rows[idx].title || this._api.t('chat_untitled')).substring(0, 50);
const msgCount = rows[idx].msg_count || 0;
const text = this._api.t('fm_session_switched', {
title: this._api.escHtml(title),
count: msgCount,
});
const buttons = [
[
{ text: this._api.t('fm_btn_last5'), callback_data: 'fm:last' },
{ text: this._api.t('fm_btn_continue'), callback_data: 'fm:compose' },
],
[
{ text: this._api.t('fm_btn_history'), callback_data: 'fm:history' },