@aliwey/bmo 2.0.0

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 (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,2761 @@
1
+ """
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+ Telegram message handlers: auth, OpenCode integration, Document handling.
21
+ Uses persistent ReplyKeyboard instead of slash commands.
22
+ """
23
+
24
+ import asyncio
25
+ import atexit
26
+ import logging
27
+ import httpx
28
+ import os
29
+ import time
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+
33
+ from telegram import Update, Message, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
34
+ from telegram.constants import ParseMode
35
+ from telegram.ext import ContextTypes, CallbackQueryHandler
36
+
37
+ from config.settings import CHUNK_SIZE, ALLOWED_USER_IDS, OWNER_ID, OPENCODE_BASE_URL, DATA_DIR
38
+ from core.bot_client import OpenCodeBotClient
39
+ from models.chat_models import ChatSession
40
+ from storage.storage import get_storage
41
+ from core.shared_state import pending_permissions
42
+ from core.bmo_engine import BMOEngine
43
+
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ engine = BMOEngine()
48
+ opencode_client = engine.client
49
+ storage = engine.storage
50
+ atexit.register(engine.shutdown)
51
+
52
+ # Ensure downloads directory exists
53
+ DOWNLOADS_DIR = DATA_DIR / "downloads"
54
+ DOWNLOADS_DIR.mkdir(exist_ok=True)
55
+
56
+ # ── Persistent Global State ──────────────────
57
+ _chat_model: dict[int, tuple[str, str]] = {}
58
+
59
+ def _get_current_model(chat_id: int, session: ChatSession) -> tuple[str, str]:
60
+ # 1. Try environment variables (Global Sync)
61
+ env_p = os.getenv("OPENCODE_PROVIDER")
62
+ env_m = os.getenv("OPENCODE_MODEL")
63
+ if env_p and env_m:
64
+ return env_p, env_m
65
+
66
+ # 2. Try session metadata
67
+ if session:
68
+ p_id = session.metadata.get("provider_id")
69
+ m_id = session.metadata.get("model_id")
70
+ if p_id and m_id and p_id != "None" and m_id != "None":
71
+ _chat_model[chat_id] = (p_id, m_id) # Update cache
72
+ return p_id, m_id
73
+
74
+ # 3. Default fallback
75
+ return ("opencode", "big-pickle")
76
+
77
+ # ── Persistent Main Menu Keyboard (Clean Redesign) ───────────────────────────
78
+ # Structure: Primary Actions | Session | History | System
79
+
80
+ # Row 1: Primary Actions (Softer, fewer buttons)
81
+ BTN_NEW_SESSION = "🆕 New"
82
+ BTN_SAVE = "💾 Save"
83
+ BTN_SUMMARY = "📝 Summary"
84
+
85
+ # Row 2: Session Management
86
+ BTN_SESSIONS = "📋 All Sessions"
87
+ BTN_SET_TITLE = "🏷️ Set Title"
88
+ BTN_RESET_HISTORY = "🗑️ Reset History"
89
+
90
+ # Row 3: History & Tools
91
+ BTN_HISTORY = "📜 History"
92
+ BTN_LOAD = "📂 Load"
93
+ BTN_CHOOSE_MODEL = "🤖 Model"
94
+
95
+ # Row 4: Tools & System
96
+ BTN_AGENTS = "🎭 Agents"
97
+ BTN_SKILLS = "🛠️ Skills"
98
+ BTN_RELOAD = "🔄 Reload"
99
+
100
+ # Row 5: System (minimal)
101
+ BTN_STATUS = "ℹ️ Status"
102
+ BTN_MODE = "⚡ Mode"
103
+ BTN_MENU = "📋 Menu"
104
+ BTN_SETTINGS = "⚙️ Settings"
105
+
106
+ # Legacy Aliases (to prevent NameErrors)
107
+ BTN_VIEW_HIST = "📜 History"
108
+ BTN_SAVE_SESSION = "💾 Save"
109
+ BTN_LOAD_SESSION = "📂 Load"
110
+ BTN_USE_SKILL = "🛠️ Skills"
111
+ BTN_CLEAR_HIST = "🗑️ Reset History"
112
+
113
+ BTNS_ROW1 = [BTN_NEW_SESSION, BTN_SAVE, BTN_SUMMARY]
114
+ BTNS_ROW2 = [BTN_SESSIONS, BTN_SET_TITLE, BTN_RESET_HISTORY]
115
+ BTNS_ROW3 = [BTN_HISTORY, BTN_LOAD, BTN_CHOOSE_MODEL]
116
+ BTNS_ROW4 = [BTN_AGENTS, BTN_SKILLS, BTN_RELOAD]
117
+ BTNS_ROW5 = [BTN_STATUS, BTN_MODE, BTN_MENU]
118
+ BTNS_ROW6 = [BTN_SETTINGS]
119
+
120
+ MAIN_MENU_KEYBOARD = ReplyKeyboardMarkup(
121
+ [BTNS_ROW1, BTNS_ROW2, BTNS_ROW3, BTNS_ROW4, BTNS_ROW5, BTNS_ROW6],
122
+ resize_keyboard=True,
123
+ is_persistent=True
124
+ )
125
+
126
+ # ── Inline Menu Keyboard (Modern UI with Session Title) ──────────────────────────────────────────
127
+ def build_inline_menu(session: ChatSession = None) -> InlineKeyboardMarkup:
128
+ """Build inline keyboard menu with session-aware buttons."""
129
+ kb = []
130
+
131
+ # Row 1: Session Title Display (prominent)
132
+ session_title = "Untitled Session"
133
+ if session and session.title:
134
+ session_title = session.title[:25] + "..." if len(session.title) > 25 else session.title
135
+ elif session and session.session_id:
136
+ session_title = f"Session #{session.session_id[:6]}"
137
+
138
+ kb.append([
139
+ InlineKeyboardButton(f"📌 {session_title}", callback_data="action:show_session_info")
140
+ ])
141
+
142
+ # Row 2: Session Actions
143
+ kb.append([
144
+ InlineKeyboardButton("🆕 New", callback_data="action:new_session"),
145
+ InlineKeyboardButton("💾 Save", callback_data="action:save_session"),
146
+ InlineKeyboardButton("📂 Load", callback_data="action:list_sessions"),
147
+ ])
148
+
149
+ # Row 3: Title & History
150
+ kb.append([
151
+ InlineKeyboardButton("🏷️ Set Title", callback_data="action:set_title"),
152
+ InlineKeyboardButton("📜 History", callback_data="action:view_history"),
153
+ InlineKeyboardButton("🗑️ Clear", callback_data="action:clear_history"),
154
+ ])
155
+
156
+ # Row 4: Models & Mode
157
+ kb.append([
158
+ InlineKeyboardButton("🤖 Models", callback_data="action:choose_model"),
159
+ InlineKeyboardButton("⚡ Mode", callback_data="action:choose_mode"),
160
+ ])
161
+
162
+ # Row 5: Tools
163
+ kb.append([
164
+ InlineKeyboardButton("🎭 Agents", callback_data="action:agents"),
165
+ InlineKeyboardButton("🛠️ Skills", callback_data="action:use_skill"),
166
+ ])
167
+
168
+ # Row 6: Web chat (dynamic — shows launch or stop based on port status)
169
+ from tools.task_registry import check_port_conflict
170
+ conflict = check_port_conflict(3456)
171
+ webchat_btn = (
172
+ InlineKeyboardButton("⏹ Stop Webchat", callback_data="action:stop_webchat")
173
+ if conflict
174
+ else InlineKeyboardButton("🌐 Chat on Web", callback_data="action:launch_webchat")
175
+ )
176
+ kb.append([webchat_btn])
177
+
178
+ # Row 7: System
179
+ kb.append([
180
+ InlineKeyboardButton("ℹ️ Status", callback_data="action:status"),
181
+ InlineKeyboardButton("⚙️ Settings", callback_data="action:settings"),
182
+ ])
183
+
184
+ return InlineKeyboardMarkup(kb)
185
+
186
+
187
+ def build_recent_sessions_message(sessions: list, max_show: int = 3) -> str:
188
+ """Build a preview of recent sessions for display."""
189
+ if not sessions:
190
+ return ""
191
+
192
+ preview = "📜 <b>Recent Sessions:</b>\n"
193
+ for i, s in enumerate(sessions[:max_show]):
194
+ title = (s.get("title") or f"Session #{s.get('session_id', 'unknown')[:6]}")
195
+ title = title[:35]
196
+ preview += f" {i+1}. {title}\n"
197
+
198
+ if len(sessions) > max_show:
199
+ preview += f" ... +{len(sessions) - max_show} more"
200
+
201
+ return preview
202
+
203
+
204
+ # ── Session History Inline Keyboard ────────────────────────────────────────────
205
+ def build_session_history_kb(sessions: list, page: int = 0, per_page: int = 8) -> InlineKeyboardMarkup:
206
+ """Build inline keyboard for session history navigation."""
207
+ kb = []
208
+
209
+ start = page * per_page
210
+ end = min(start + per_page, len(sessions))
211
+ page_sessions = sessions[start:end]
212
+
213
+ for idx, s in enumerate(page_sessions):
214
+ session_num = start + idx + 1
215
+ # Title (truncated to 30 chars), date, msg count
216
+ title = (s.get("title") or s.get("session_id", "unknown")[:8])[:30]
217
+ created = datetime.fromtimestamp(s.get("created_at", 0)).strftime("%m/%d %H:%M")
218
+ msg_count = s.get("message_count", 0)
219
+
220
+ display = f"#{session_num} {title}"
221
+ kb.append([InlineKeyboardButton(display, callback_data=f"session_load:{s['session_id']}")])
222
+
223
+ # Pagination controls
224
+ nav_row = []
225
+ if page > 0:
226
+ nav_row.append(InlineKeyboardButton("⬅️ Prev", callback_data=f"history_page:{page-1}"))
227
+ if end < len(sessions):
228
+ nav_row.append(InlineKeyboardButton("Next ➡️", callback_data=f"history_page:{page+1}"))
229
+ if nav_row:
230
+ kb.append(nav_row)
231
+
232
+ # Back button
233
+ kb.append([InlineKeyboardButton("🔙 Back to Menu", callback_data="back_to_menu")])
234
+
235
+ return InlineKeyboardMarkup(kb)
236
+
237
+ MODES = {
238
+ "plan": ("📝 Plan Mode", "BMO will only outline steps and design solutions without executing code."),
239
+ "ask": ("❓ Ask Mode", "BMO will ask clarifying questions and gather details before proceeding."),
240
+ "execute": ("🚀 Execute Mode", "BMO will actively write code, run commands, and apply changes."),
241
+ }
242
+
243
+ PERSONAS = {
244
+ "default": ("🦾 BMO Default", "The standard helpful superhero assistant."),
245
+ "document": ("📄 The Document", "BMO becomes the uploaded file content. Ask it questions as if it IS the file."),
246
+ "architect": ("🏗️ Python Architect", "Focus on clean, professional, and efficient coding solutions."),
247
+ "security": ("🛡️ Security Auditor", "Identify vulnerabilities and security risks in code or systems."),
248
+ }
249
+
250
+ # ── Running tasks per chat (for Cancel) ───────────────────────────────────────
251
+ _running_tasks: dict[int, asyncio.Task] = {}
252
+ _file_tasks: dict[int, list[asyncio.Task]] = {} # Track background file analyses
253
+ _awaiting_title: set[int] = set()
254
+ _awaiting_key: dict[int, dict] = {} # chat_id -> {provider_id, provider_name, env_key}
255
+ _onboarding_step: dict[int, int] = {} # chat_id -> step (1: name, 2: prefs)
256
+ _agent_creation: dict[int, dict] = {} # chat_id -> {step, mode, name, prompt}
257
+
258
+ async def handle_settings(update: Update, context: ContextTypes.DEFAULT_TYPE):
259
+ """Shows the settings menu."""
260
+ user = update.effective_user
261
+ kb = [
262
+ [InlineKeyboardButton("🔑 Manage API Keys", callback_data="set_keys")],
263
+ [InlineKeyboardButton("📊 System Statistics", callback_data="admin_stats")]
264
+ ]
265
+
266
+ if user.id != OWNER_ID:
267
+ kb = [kb[0]]
268
+
269
+ await update.message.reply_text(
270
+ "⚙️ <b>BMO Settings</b>\n\nConfigure your API providers and security preferences below:",
271
+ reply_markup=InlineKeyboardMarkup(kb),
272
+ parse_mode=ParseMode.HTML
273
+ )
274
+
275
+ async def agent_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
276
+ """Handles agent selection."""
277
+ query = update.callback_query
278
+ data = query.data
279
+ if data.startswith("agent_set:"):
280
+ agent_name = data.split(":")[1]
281
+ await query.answer(f"Selected: {agent_name}")
282
+ await query.edit_message_text(f"🎭 <b>Agent Selected:</b> {agent_name}\n\nWhat would you like {agent_name} to do?", parse_mode=ParseMode.HTML)
283
+
284
+
285
+ # ── Inline Menu Action Handler ──────────────────────────────────────────────
286
+ async def inline_action_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
287
+ """Handles all inline menu button clicks."""
288
+ query = update.callback_query
289
+ data = query.data
290
+ await query.answer()
291
+
292
+ chat_id = query.message.chat_id
293
+ user = query.from_user
294
+
295
+ if not _is_authorized(user.id):
296
+ return
297
+
298
+ # Get current session for inline menu building
299
+ session = storage.load_session(chat_id)
300
+
301
+ if data == "action:new_session":
302
+ await handle_new_session(update, context)
303
+ elif data == "action:list_sessions":
304
+ await handle_list_sessions_inline(query, session)
305
+ elif data == "action:set_title":
306
+ _awaiting_title.add(chat_id)
307
+ current = ""
308
+ if session and session.title:
309
+ current = f" (current: <b>{session.title}</b>)"
310
+ await query.edit_message_text(
311
+ f"🏷️ <b>Send the session title you want</b>{current}:",
312
+ parse_mode=ParseMode.HTML
313
+ )
314
+ elif data == "action:clear_history":
315
+ await _handle_clear_inline(query, session)
316
+ elif data == "action:choose_model":
317
+ await handle_choose_model(update, context)
318
+ elif data == "action:choose_mode":
319
+ await handle_choose_mode(update, context)
320
+ elif data == "action:agents":
321
+ await handle_agents(update, context)
322
+ elif data == "action:use_skill":
323
+ await handle_use_skill(update, context)
324
+ elif data == "action:status":
325
+ await _handle_status_inline(query, session)
326
+ elif data == "action:settings":
327
+ await handle_settings(update, context)
328
+ elif data == "action:view_history":
329
+ await _handle_view_history_inline(query, session)
330
+ elif data == "action:save_session":
331
+ await _handle_save_session_inline(query, session)
332
+ elif data == "action:launch_webchat":
333
+ await _handle_launch_webchat(update, context)
334
+ return
335
+ elif data == "action:stop_webchat":
336
+ await _handle_stop_webchat(update, context)
337
+ return
338
+ elif data == "back_to_menu":
339
+ await _show_inline_menu(query, session)
340
+ elif data.startswith("history_page:"):
341
+ page = int(data.split(":")[1])
342
+ await handle_list_sessions_inline(query, session, page=page)
343
+ elif data.startswith("session_load:"):
344
+ sid = data.split(":")[1]
345
+ await _handle_load_session(query, sid)
346
+
347
+
348
+ async def handle_list_sessions_inline(query, session, page: int = 0):
349
+ """Show sessions as inline buttons with pagination."""
350
+ chat_id = query.message.chat_id
351
+ all_sessions = storage.list_chat_sessions(chat_id)
352
+
353
+ if not all_sessions:
354
+ await query.edit_message_text(
355
+ "📋 <b>No Sessions Found</b>\n\nStart a conversation to create your first session!",
356
+ parse_mode=ParseMode.HTML,
357
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
358
+ )
359
+ return
360
+
361
+ # Sort by created_at desc
362
+ all_sessions.sort(key=lambda x: x.get("created_at", 0), reverse=True)
363
+
364
+ # Show current session first if exists
365
+ current_sid = session.session_id if session else None
366
+
367
+ kb = []
368
+ start = page * 8
369
+ end = min(start + 8, len(all_sessions))
370
+
371
+ for idx, s in enumerate(all_sessions[start:end]):
372
+ session_num = start + idx + 1
373
+ title = (s.get("title") or s.get("session_id", "unknown")[:8])[:35]
374
+ created = datetime.fromtimestamp(s.get("created_at", 0)).strftime("%m/%d %H:%M")
375
+ msg_count = s.get("message_count", 0)
376
+ is_current = " ✅" if s.get("session_id") == current_sid else ""
377
+ display = f"#{session_num} {title}{is_current}"
378
+ kb.append([InlineKeyboardButton(display, callback_data=f"session_load:{s['session_id']}")])
379
+
380
+ # Pagination
381
+ nav_row = []
382
+ if page > 0:
383
+ nav_row.append(InlineKeyboardButton("⬅️ Prev", callback_data=f"history_page:{page-1}"))
384
+ if end < len(all_sessions):
385
+ nav_row.append(InlineKeyboardButton("Next ➡️", callback_data=f"history_page:{page+1}"))
386
+ if nav_row:
387
+ kb.append(nav_row)
388
+
389
+ kb.append([InlineKeyboardButton("🔙 Back to Menu", callback_data="back_to_menu")])
390
+
391
+ text = f"📋 <b>Session History</b> ({len(all_sessions)} total)\n\nSelect a session to load:"
392
+
393
+ await query.edit_message_text(
394
+ text,
395
+ parse_mode=ParseMode.HTML,
396
+ reply_markup=InlineKeyboardMarkup(kb)
397
+ )
398
+
399
+
400
+ async def _handle_clear_inline(query, session):
401
+ """Handle clear history from inline menu."""
402
+ chat_id = query.message.chat_id
403
+
404
+ old_title = session.title if session else None
405
+ if old_title:
406
+ msg_count = len(session.messages) if session else 0
407
+ await query.edit_message_text(
408
+ f"📋 <b>Session Cleared</b>\n<b>{old_title}</b>\n📊 Messages: {msg_count}",
409
+ parse_mode=ParseMode.HTML
410
+ )
411
+
412
+ storage.delete_session(chat_id)
413
+ _ensure_session(chat_id, query.from_user.id, query.from_user.username)
414
+ await query.message.reply_text("🗑️ History cleared! Ready for a fresh start.", reply_markup=MAIN_MENU_KEYBOARD)
415
+
416
+
417
+ async def _handle_status_inline(query, session):
418
+ """Show status from inline menu."""
419
+ if not session:
420
+ session = _ensure_session(query.message.chat_id, query.from_user.id, query.from_user.username)
421
+
422
+ active_mode = session.metadata.get("active_mode", "execute")
423
+ title = session.title or "Untitled Session"
424
+ msg_count = len(session.messages)
425
+ p_id, m_id = _get_current_model(query.message.chat_id, session)
426
+
427
+ text = (
428
+ f"ℹ️ <b>BMO System Status</b>\n\n"
429
+ f"🛠️ <b>Mode:</b> <code>{active_mode}</code>\n"
430
+ f"💬 <b>Session:</b> {title}\n"
431
+ f"📊 <b>Messages:</b> {msg_count}\n"
432
+ f"🤖 <b>Model:</b> <code>{m_id}</code> ({p_id})"
433
+ )
434
+
435
+ await query.edit_message_text(
436
+ text,
437
+ parse_mode=ParseMode.HTML,
438
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
439
+ )
440
+
441
+
442
+ async def _handle_load_session(query, session_id: str):
443
+ """Load a specific session from history."""
444
+ chat_id = query.message.chat_id
445
+ user = query.from_user
446
+
447
+ # Load the target session
448
+ target_session = storage.get_session_by_id(session_id)
449
+ if not target_session:
450
+ await query.edit_message_text("❌ Session not found.", parse_mode=ParseMode.HTML)
451
+ return
452
+
453
+ # Save current session first
454
+ current = storage.load_session(chat_id)
455
+ if current and current.messages:
456
+ storage.save_session(current)
457
+
458
+ # Create a new session with loaded messages (don't overwrite - clone)
459
+ from uuid import uuid4
460
+ now = datetime.now().timestamp()
461
+ new_session = ChatSession(
462
+ chat_id=chat_id,
463
+ user_id=user.id,
464
+ username=user.username,
465
+ created_at=now,
466
+ updated_at=now,
467
+ messages=target_session.messages.copy(),
468
+ metadata={
469
+ "opencode_session_id": None,
470
+ "provider_id": target_session.metadata.get("provider_id", "opencode"),
471
+ "model_id": target_session.metadata.get("model_id", "big-pickle")
472
+ },
473
+ session_id=str(uuid4())
474
+ )
475
+ new_session.title = f"📜 {target_session.title or 'Loaded Session'}"
476
+ storage.save_session(new_session)
477
+ storage.set_active_session(chat_id, new_session.session_id)
478
+
479
+ await query.edit_message_text(
480
+ f"📜 <b>Session Loaded!</b>\n\n"
481
+ f"🏷️ <b>{new_session.title}</b>\n"
482
+ f"📊 {len(new_session.messages)} messages restored.\n\n"
483
+ f"<i>You can continue where you left off.</i>",
484
+ parse_mode=ParseMode.HTML,
485
+ reply_markup=build_inline_menu(new_session)
486
+ )
487
+
488
+
489
+ async def _show_inline_menu(query, session):
490
+ """Show the inline menu."""
491
+ await query.edit_message_text(
492
+ "⚡ <b>BMO Menu</b>\n\nSelect an action:",
493
+ parse_mode=ParseMode.HTML,
494
+ reply_markup=build_inline_menu(session)
495
+ )
496
+
497
+
498
+ async def _handle_stop_webchat(update, context):
499
+ """Stop the running webchat server and its tunnel."""
500
+ from tools.task_registry import get_active_tasks, remove_task, check_port_conflict
501
+ import psutil
502
+
503
+ query = update.callback_query
504
+ await query.edit_message_text(
505
+ "⏹ <b>Stopping Webchat...</b>",
506
+ parse_mode=ParseMode.HTML,
507
+ )
508
+
509
+ conflict = check_port_conflict(3456)
510
+ if not conflict:
511
+ await query.edit_message_text(
512
+ "⚠️ <b>No webchat running.</b>",
513
+ parse_mode=ParseMode.HTML,
514
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
515
+ )
516
+ return
517
+
518
+ tasks = get_active_tasks()
519
+ stopped = []
520
+ for task in tasks:
521
+ if task.get("port") == 3456 or task.get("type") in ("webchat", "tunnel"):
522
+ try:
523
+ proc = psutil.Process(task["pid"])
524
+ for child in proc.children(recursive=True):
525
+ child.kill()
526
+ proc.kill()
527
+ remove_task(task["pid"])
528
+ stopped.append(task.get("type", "unknown"))
529
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
530
+ remove_task(task["pid"])
531
+ stopped.append(task.get("type", "unknown"))
532
+
533
+ await query.edit_message_text(
534
+ f"⏹ <b>Webchat stopped.</b>\n\nStopped: {', '.join(stopped) if stopped else 'nothing found'}",
535
+ parse_mode=ParseMode.HTML,
536
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
537
+ )
538
+
539
+
540
+ async def _handle_launch_webchat(update, context):
541
+ import re, time, asyncio, subprocess
542
+ from pathlib import Path
543
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup
544
+ from tools.task_registry import register_task, check_port_conflict
545
+ from tools.mcp_server import _start_detached
546
+
547
+ query = update.callback_query
548
+ await query.answer()
549
+ chat_id = query.message.chat_id
550
+ message_id = query.message.message_id
551
+
552
+ conflict = check_port_conflict(3456)
553
+ if conflict:
554
+ url = conflict.get("url", "")
555
+ await query.edit_message_text(
556
+ f"✅ <b>Web Chat Already Running!</b>\n\n"
557
+ f"PID: {conflict['pid']}\n"
558
+ + (f"🔗 <a href='{url}'>Open Web Chat</a>\n\n" if url else "")
559
+ + f"Use the Stop button to shut it down.",
560
+ parse_mode=ParseMode.HTML,
561
+ disable_web_page_preview=True,
562
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
563
+ )
564
+ return
565
+
566
+ base_dir = Path(__file__).resolve().parent.parent
567
+ webchat_dir = base_dir / 'webchat'
568
+ logs_dir = base_dir / "logs"
569
+ logs_dir.mkdir(exist_ok=True)
570
+
571
+ server_port = 3456
572
+ server_log = str(logs_dir / "webchat_server.log")
573
+
574
+ server_cmd = "node server.js"
575
+ server_proc = _start_detached(server_cmd, server_log, cwd=str(webchat_dir))
576
+ register_task(
577
+ pid=server_proc.pid,
578
+ command=server_cmd,
579
+ port=server_port,
580
+ task_type="webchat",
581
+ description="Webchat server",
582
+ log_file=server_log,
583
+ )
584
+
585
+ await query.edit_message_text(
586
+ "🌐 <b>Starting Web Chat...</b>\n\n"
587
+ "Launching server and tunnel in background. "
588
+ "This usually takes 15-30 seconds. Your bot remains responsive!",
589
+ parse_mode=ParseMode.HTML,
590
+ )
591
+
592
+ await asyncio.sleep(2)
593
+
594
+ CREATE_NO_WINDOW = 0x08000000
595
+ tunnel_proc = subprocess.Popen(
596
+ ["cloudflared", "tunnel", "--url", f"http://localhost:{server_port}"],
597
+ stdout=subprocess.PIPE,
598
+ stderr=subprocess.STDOUT,
599
+ creationflags=CREATE_NO_WINDOW,
600
+ )
601
+ register_task(
602
+ pid=tunnel_proc.pid,
603
+ command=f"cloudflared tunnel --url http://localhost:{server_port}",
604
+ port=server_port,
605
+ task_type="tunnel",
606
+ description=f"Tunnel for webchat on port {server_port}",
607
+ )
608
+
609
+ async def _read_tunnel_url(chat_id, message_id, tunnel_proc, session_id):
610
+ loop = asyncio.get_event_loop()
611
+ start = time.time()
612
+ url = None
613
+
614
+ while time.time() - start < 75:
615
+ try:
616
+ line = await asyncio.wait_for(
617
+ loop.run_in_executor(None, tunnel_proc.stdout.readline),
618
+ timeout=3,
619
+ )
620
+ except asyncio.TimeoutError:
621
+ continue
622
+
623
+ if not line:
624
+ break
625
+
626
+ text = line.decode("utf-8", errors="ignore")
627
+ match = re.search(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com", text)
628
+ if match:
629
+ url = match.group(0)
630
+ break
631
+
632
+ if url:
633
+ webchat_url = f"{url}?chat_id={chat_id}&session_id={session_id}"
634
+ from tools.task_registry import update_task
635
+ update_task(tunnel_proc.pid, url=webchat_url)
636
+
637
+ try:
638
+ import httpx
639
+ async with httpx.AsyncClient(timeout=5) as client:
640
+ await client.post(
641
+ f"http://localhost:{server_port}/api/set-session",
642
+ json={"chat_id": chat_id, "session_id": session_id}
643
+ )
644
+ except Exception:
645
+ pass
646
+ await context.bot.edit_message_text(
647
+ chat_id=chat_id,
648
+ message_id=message_id,
649
+ text=(
650
+ f"✅ <b>Web Chat Online!</b>\n\n"
651
+ f"🌍 Public: <a href='{webchat_url}'>Open Web Chat</a>\n"
652
+ f"💻 Local: http://localhost:{server_port}\n\n"
653
+ f"<i>The webchat is using your current session. "
654
+ f"Open the URL to continue the conversation.</i>"
655
+ ),
656
+ parse_mode=ParseMode.HTML,
657
+ disable_web_page_preview=True,
658
+ reply_markup=InlineKeyboardMarkup([
659
+ [InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]
660
+ ])
661
+ )
662
+
663
+ storage.add_message(
664
+ chat_id, "assistant",
665
+ f"The user moved their conversation to the webchat.\n"
666
+ f"🔗 {webchat_url}\n\n"
667
+ "Same OpenCode session. Reply here to continue on Telegram.",
668
+ interface='telegram'
669
+ )
670
+ else:
671
+ await context.bot.edit_message_text(
672
+ chat_id=chat_id,
673
+ message_id=message_id,
674
+ text=(
675
+ f"⚠️ <b>Tunnel Still Connecting...</b>\n\n"
676
+ f"Server PID: {server_proc.pid}\n"
677
+ f"Tunnel PID: {tunnel_proc.pid}\n\n"
678
+ f"The tunnel is taking longer than expected. "
679
+ f"Check status in a moment using the inline menu.",
680
+ ),
681
+ parse_mode=ParseMode.HTML,
682
+ reply_markup=InlineKeyboardMarkup([
683
+ [InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]
684
+ ])
685
+ )
686
+
687
+ session = storage.load_session(chat_id)
688
+ session_id = session.session_id if session else ""
689
+ asyncio.create_task(_read_tunnel_url(chat_id, message_id, tunnel_proc, session_id))
690
+
691
+
692
+ async def _handle_view_history_inline(query, session):
693
+ """Show session history in inline format."""
694
+ chat_id = query.message.chat_id
695
+ all_sessions = storage.list_sessions(chat_id)
696
+
697
+ if not all_sessions:
698
+ await query.edit_message_text(
699
+ "📜 <b>No History Found</b>\n\nStart a conversation to build your session history!",
700
+ parse_mode=ParseMode.HTML,
701
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
702
+ )
703
+ return
704
+
705
+ all_sessions.sort(key=lambda x: x.get("created_at", 0), reverse=True)
706
+
707
+ kb = []
708
+ for s in all_sessions[:12]:
709
+ title = (s.get("title") or s.get("session_id", "unknown")[:8])[:35]
710
+ kb.append([InlineKeyboardButton(f"📂 {title}", callback_data=f"session_load:{s['session_id']}")])
711
+
712
+ kb.append([InlineKeyboardButton("🔙 Back to Menu", callback_data="back_to_menu")])
713
+
714
+ text = f"📜 <b>Session History</b> ({len(all_sessions)} total)\n\nSelect a session to load:"
715
+ await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(kb))
716
+
717
+
718
+ async def _handle_save_session_inline(query, session):
719
+ """Save current session inline."""
720
+ if not session or not session.messages:
721
+ await query.edit_message_text(
722
+ "📭 <b>Nothing to Save</b>\n\nStart a conversation first!",
723
+ parse_mode=ParseMode.HTML,
724
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
725
+ )
726
+ return
727
+
728
+ msg_count = len(session.messages)
729
+ title = session.title or f"Session {session.session_id[:8]}"
730
+
731
+ await query.edit_message_text(
732
+ f"💾 <b>Session Saved!</b>\n\n"
733
+ f"🏷️ <b>{title}</b>\n"
734
+ f"📊 {msg_count} messages\n\n"
735
+ "Your session is safely archived.",
736
+ parse_mode=ParseMode.HTML,
737
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_to_menu")]])
738
+ )
739
+
740
+
741
+ # ── Session Select Callback (existing) ──────────────────────────────────────
742
+ async def session_select_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
743
+ """Handle session selection and loading."""
744
+ query = update.callback_query
745
+ await query.answer()
746
+
747
+ user = query.from_user
748
+ if not _is_authorized(user.id):
749
+ return
750
+
751
+ data = query.data
752
+ chat_id = query.message.chat_id
753
+
754
+ # CASE 1: Direct switch (from /sessions list)
755
+ if data.startswith("session:"):
756
+ sid = data.split(":")[1]
757
+ if storage.switch_session(chat_id, sid):
758
+ session = storage.load_session(chat_id)
759
+ title = session.title if session and session.title else "untitled"
760
+ msg_count = len(session.messages) if session else 0
761
+ await query.edit_message_text(
762
+ f"✅ <b>Switched to session:</b>\n"
763
+ f"🏷️ <b>{title}</b>\n"
764
+ f"📊 <i>{msg_count} messages in history</i>",
765
+ parse_mode=ParseMode.HTML,
766
+ reply_markup=build_inline_menu(session) if session else None
767
+ )
768
+ else:
769
+ await query.edit_message_text("❌ <b>Session not found.</b>", parse_mode=ParseMode.HTML)
770
+ return
771
+
772
+ # CASE 2: Load/Clone (from inline history)
773
+ if data.startswith("session_select:"):
774
+ sid = data.split(":")[1]
775
+
776
+ # Load the target session
777
+ target_session = storage.get_session_by_id(sid)
778
+ if not target_session:
779
+ await query.edit_message_text("❌ Session not found.", parse_mode=ParseMode.HTML)
780
+ return
781
+
782
+ from uuid import uuid4
783
+ now = datetime.now().timestamp()
784
+ new_session = ChatSession(
785
+ chat_id=chat_id,
786
+ user_id=user.id,
787
+ username=user.username,
788
+ created_at=now,
789
+ updated_at=now,
790
+ messages=target_session.messages.copy(),
791
+ metadata={
792
+ "opencode_session_id": None,
793
+ "provider_id": target_session.metadata.get("provider_id", "opencode"),
794
+ "model_id": target_session.metadata.get("model_id", "big-pickle")
795
+ },
796
+ session_id=str(uuid4())
797
+ )
798
+ new_session.title = f"📜 {target_session.title or 'Loaded Session'}"
799
+ storage.save_session(new_session)
800
+ storage.set_active_session(chat_id, new_session.session_id)
801
+
802
+ await query.edit_message_text(
803
+ f"📜 <b>Session Loaded!</b>\n\n"
804
+ f"🏷️ <b>{new_session.title}</b>\n"
805
+ f"📊 {len(new_session.messages)} messages restored.",
806
+ parse_mode=ParseMode.HTML,
807
+ reply_markup=build_inline_menu(new_session)
808
+ )
809
+
810
+ async def settings_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
811
+ """Handles settings navigation and key setup."""
812
+ query = update.callback_query
813
+ data = query.data
814
+ chat_id = query.message.chat_id
815
+ user_id = query.from_user.id
816
+ await query.answer()
817
+
818
+ if data.startswith("perm_"):
819
+ action_parts = data.split("|", 1)
820
+ action = action_parts[0]
821
+ scope = action_parts[1] if len(action_parts) > 1 else "unknown"
822
+ key = (chat_id, scope)
823
+
824
+ if key not in pending_permissions:
825
+ await query.edit_message_text("❌ This request has expired or already been handled.")
826
+ return
827
+
828
+ result = "denied"
829
+ status_text = "❌ Permission Denied."
830
+
831
+ if action == "perm_yes":
832
+ result = "granted"
833
+ status_text = "✅ Permission Granted (One-time)."
834
+ elif action == "perm_no":
835
+ result = "denied"
836
+ status_text = "❌ Permission Denied."
837
+ elif action == "perm_always":
838
+ result = "granted"
839
+ status_text = "🔒 Permission Granted Always (Saved to Vault)."
840
+ storage.save_permission(user_id, scope, 1)
841
+
842
+ # Resolve the pending event in the MCP server
843
+ pending_permissions[key]["result"] = result
844
+ pending_permissions[key]["event"].set()
845
+
846
+ await query.edit_message_text(
847
+ f"🏁 <b>Permission {result.title()}</b>\n"
848
+ f"📁 Scope: <code>{scope}</code>\n\n"
849
+ f"{status_text}",
850
+ parse_mode=ParseMode.HTML
851
+ )
852
+ return
853
+
854
+ if data == "set_keys":
855
+ await handle_key_management(update, context)
856
+ elif data == "admin_stats":
857
+ if user_id != OWNER_ID: return
858
+ stats = storage.get_stats()
859
+ is_oc_up = "✅ Connected" if opencode_client.is_connected else "❌ Disconnected"
860
+ text = (
861
+ "📊 <b>BMO System Statistics</b>\n\n"
862
+ f"👤 <b>Total Users:</b> {stats['users']}\n"
863
+ f"📋 <b>Total Sessions:</b> {stats['sessions']}\n"
864
+ f"💬 <b>Total Messages:</b> {stats['messages']}\n"
865
+ f"🔗 <b>OpenCode Server:</b> {is_oc_up}\n\n"
866
+ "<i>Only you can see this message.</i>"
867
+ )
868
+ await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 Back", callback_data="back_settings")]]), parse_mode=ParseMode.HTML)
869
+ elif data == "back_settings":
870
+ kb = [[InlineKeyboardButton("🔑 Manage API Keys", callback_data="set_keys")], [InlineKeyboardButton("📊 System Statistics", callback_data="admin_stats")]]
871
+ if user_id != OWNER_ID: kb = [kb[0]]
872
+ await query.edit_message_text("⚙️ <b>BMO Settings</b>", reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
873
+ elif data.startswith("setup_p_"):
874
+ provider_id = data.replace("setup_p_", "")
875
+ providers_data = await opencode_client.get_providers()
876
+ all_providers = providers_data.get("all", [])
877
+ provider = next((p for p in all_providers if p["id"] == provider_id), None)
878
+
879
+ if not provider:
880
+ await query.edit_message_text("❌ Provider not found.")
881
+ return
882
+
883
+ env_keys = provider.get("env", [])
884
+ if not env_keys:
885
+ await query.edit_message_text(f"ℹ️ {provider['name']} does not require an API Key.")
886
+ return
887
+
888
+ # For simplicity, we assume one key (e.g. ANTHROPIC_API_KEY)
889
+ key_name = env_keys[0]
890
+ _awaiting_key[chat_id] = {
891
+ "provider_id": provider_id,
892
+ "provider_name": provider["name"],
893
+ "key_name": key_name
894
+ }
895
+
896
+ await query.edit_message_text(
897
+ f"🔑 <b>Setup {provider['name']}</b>\n\n"
898
+ f"Please send your <code>{key_name}</code> now.\n\n"
899
+ "🛡️ <i>Your key will be encrypted and stored in BMO's secure vault.</i>",
900
+ parse_mode=ParseMode.HTML
901
+ )
902
+ elif data.startswith("ttl_"):
903
+ hours = int(data.replace("ttl_", ""))
904
+ pending = context.user_data.get("pending_key")
905
+ if not pending:
906
+ await query.edit_message_text("❌ Session expired. Please try adding the key again.")
907
+ return
908
+
909
+ ttl = hours if hours > 0 else None
910
+
911
+ # ── Global Sync for Forever Keys ──
912
+ sync_status = ""
913
+ if hours == 0:
914
+ try:
915
+ if _sync_to_env(pending["key_name"], pending["value"]):
916
+ sync_status = "\n🌐 <b>System Sync:</b> Key added to global environment."
917
+ except Exception as e:
918
+ logger.error("Env sync failed: %s", e)
919
+ sync_status = f"\n⚠️ <b>Sync Warning:</b> Could not update .env file."
920
+
921
+ storage.save_provider_key(
922
+ pending["provider_id"],
923
+ pending["provider_name"],
924
+ pending["key_name"],
925
+ pending["value"],
926
+ ttl_hours=ttl
927
+ )
928
+ context.user_data.pop("pending_key", None)
929
+
930
+ # Now fetch models for this specific provider to show them
931
+ providers_data = await opencode_client.get_providers()
932
+ all_p = providers_data.get("all", [])
933
+ p_info = next((p for p in all_p if p["id"] == pending["provider_id"]), None)
934
+
935
+ duration = f"{hours} hours" if hours > 0 else "Forever 🔒"
936
+ text = (
937
+ f"✅ <b>{pending['provider_name']} Key Saved!</b>\n"
938
+ f"Vault duration: <b>{duration}</b>"
939
+ f"{sync_status}\n\n"
940
+ "🤖 <b>Available Models for this Provider:</b>"
941
+ )
942
+
943
+ kb = []
944
+ if p_info and p_info.get("models"):
945
+ for m_id, m_data in p_info["models"].items():
946
+ display_name = m_data.get("name", m_id)
947
+ display_name = display_name.replace("-latest", "").replace("-pro", " Pro").replace("-lite", " Lite").replace("-flash", " Flash")
948
+ kb.append([InlineKeyboardButton(f"📡 {display_name}", callback_data=f"m:{pending['provider_id']}:{m_id}")])
949
+
950
+ kb.append([InlineKeyboardButton("🔙 Back to Settings", callback_data="set_keys")])
951
+
952
+ await query.edit_message_text(
953
+ text,
954
+ reply_markup=InlineKeyboardMarkup(kb),
955
+ parse_mode=ParseMode.HTML
956
+ )
957
+
958
+ def _sync_to_env(key_name: str, value: str) -> bool:
959
+ """Writes or updates a key in the project's .env file."""
960
+ env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
961
+ lines = []
962
+ found = False
963
+
964
+ if os.path.exists(env_path):
965
+ with open(env_path, "r", encoding="utf-8") as f:
966
+ lines = f.readlines()
967
+
968
+ new_line = f"{key_name}={value}\n"
969
+
970
+ for i, line in enumerate(lines):
971
+ if line.strip().startswith(f"{key_name}="):
972
+ lines[i] = new_line
973
+ found = True
974
+ break
975
+
976
+ if not found:
977
+ # Add newline if file doesn't end with one
978
+ if lines and not lines[-1].endswith("\n"):
979
+ lines[-1] += "\n"
980
+ lines.append(new_line)
981
+
982
+ with open(env_path, "w", encoding="utf-8") as f:
983
+ f.writelines(lines)
984
+
985
+ # Also update current process environment so Bot picks it up immediately
986
+ os.environ[key_name] = value
987
+ return True
988
+
989
+ async def handle_key_management(update: Update, context: ContextTypes.DEFAULT_TYPE):
990
+ """Shows current keys and allows adding new ones."""
991
+ query = update.callback_query
992
+ providers_data = await opencode_client.get_providers()
993
+ all_providers = providers_data.get("all", [])
994
+ connected_ids = providers_data.get("connected", [])
995
+
996
+ saved_keys = storage.list_provider_keys()
997
+
998
+ text = "🔑 <b>Manage API Providers</b>\n\n"
999
+ if saved_keys:
1000
+ text += "<b>Decrypted & Active in BMO Vault:</b>\n"
1001
+ for k in saved_keys:
1002
+ status = "✅"
1003
+ text += f"- {k['provider_name']} ({k['provider_id']}) {status}\n"
1004
+ text += "\n"
1005
+
1006
+ text += "Select a provider below to add or update its API Key:"
1007
+
1008
+ # Show top providers
1009
+ kb = []
1010
+ popular = ["anthropic", "openai", "google", "deepseek", "openrouter", "groq"]
1011
+
1012
+ row = []
1013
+ for p in all_providers:
1014
+ if p["id"] in popular:
1015
+ btn_text = f"{p['name']}"
1016
+ if p["id"] in connected_ids: btn_text = "🔄 " + btn_text
1017
+ row.append(InlineKeyboardButton(btn_text, callback_data=f"setup_p_{p['id']}"))
1018
+ if len(row) == 2:
1019
+ kb.append(row)
1020
+ row = []
1021
+ if row: kb.append(row)
1022
+
1023
+ kb.append([InlineKeyboardButton("🔙 Back", callback_data="back_settings")])
1024
+
1025
+ await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
1026
+
1027
+ # ── Auto Summary ──────────────────────────────────────────────────────────────
1028
+ INACTIVITY_TIMEOUT = 300 # 5 minutes idle → auto summary
1029
+ _summary_timers: dict[int, asyncio.Task] = {}
1030
+
1031
+ CANCEL_KEYBOARD = InlineKeyboardMarkup([
1032
+ [InlineKeyboardButton("❌ Cancel", callback_data="cancel")]
1033
+ ])
1034
+
1035
+
1036
+ async def _status_poller(
1037
+ bot,
1038
+ chat_id: int,
1039
+ message_id: int,
1040
+ client,
1041
+ session_id: str,
1042
+ stop_event: asyncio.Event,
1043
+ ):
1044
+ """Background task: polls opencode session status and updates Telegram message live."""
1045
+ start_time = time.time()
1046
+ while not stop_event.is_set():
1047
+ elapsed = int(time.time() - start_time)
1048
+
1049
+ # Priority: explicit session_id -> client's last active session
1050
+ sid = session_id or client.last_session_id
1051
+
1052
+ if sid:
1053
+ try:
1054
+ status = await client.get_session_status(sid)
1055
+ # Clean HTML to prevent Telegram parse errors (e.g. from raw snippets with < or &)
1056
+ status = _clean_telegram_html(status)
1057
+ except Exception as e:
1058
+ logger.debug("Error getting status for %s: %s", sid, e)
1059
+ status = "🧠 <i>BMO is thinking...</i>"
1060
+ else:
1061
+ status = "🔗 <i>Connecting...</i>"
1062
+
1063
+ text = f"{status} ⏱️ {elapsed}s"
1064
+ try:
1065
+ await bot.edit_message_text(
1066
+ text,
1067
+ chat_id=chat_id,
1068
+ message_id=message_id,
1069
+ parse_mode=ParseMode.HTML,
1070
+ reply_markup=CANCEL_KEYBOARD,
1071
+ )
1072
+ except Exception as e:
1073
+ # Common error: "Message is not modified" - we can ignore this
1074
+ if "not modified" not in str(e).lower():
1075
+ logger.debug("Status poller edit error: %s", e)
1076
+ try:
1077
+ await asyncio.wait_for(stop_event.wait(), timeout=1.5)
1078
+ except asyncio.TimeoutError:
1079
+ continue
1080
+
1081
+
1082
+ # ─────────────────────────────────────────────────────────────────────────────
1083
+ # Helpers
1084
+ # ─────────────────────────────────────────────────────────────────────────────
1085
+
1086
+ def _is_authorized(user_id: int) -> bool:
1087
+ if not ALLOWED_USER_IDS:
1088
+ return True
1089
+ return user_id in ALLOWED_USER_IDS
1090
+
1091
+
1092
+ def _chunk_text(text: str, chunk_size: int = CHUNK_SIZE) -> list[str]:
1093
+ if len(text) <= chunk_size:
1094
+ return [text]
1095
+ chunks = []
1096
+ while text:
1097
+ if len(text) <= chunk_size:
1098
+ chunks.append(text)
1099
+ break
1100
+ split_at = text.rfind("\n", 0, chunk_size)
1101
+ if split_at == -1:
1102
+ split_at = text.rfind(" ", 0, chunk_size)
1103
+ if split_at == -1:
1104
+ split_at = chunk_size
1105
+ chunks.append(text[:split_at])
1106
+ text = text[split_at:].lstrip("\n ")
1107
+ return chunks
1108
+
1109
+
1110
+ def _generate_title(text: str, max_len: int = 60) -> str:
1111
+ text = text.strip().replace("\n", " ").replace("\r", "")
1112
+ if len(text) > max_len:
1113
+ text = text[:max_len].rstrip() + "…"
1114
+ return text
1115
+
1116
+
1117
+ def _ensure_session(chat_id: int, user_id: int, username: str | None) -> ChatSession:
1118
+ session = storage.load_session(chat_id)
1119
+ if session is None:
1120
+ now = datetime.now().timestamp()
1121
+ session = ChatSession(
1122
+ chat_id=chat_id,
1123
+ user_id=user_id,
1124
+ username=username,
1125
+ created_at=now,
1126
+ updated_at=now,
1127
+ messages=[],
1128
+ metadata={
1129
+ "opencode_session_id": None,
1130
+ "provider_id": "opencode",
1131
+ "model_id": "big-pickle"
1132
+ },
1133
+ )
1134
+ storage.save_session(session)
1135
+ return session
1136
+
1137
+
1138
+ def _convert_md_to_html(text: str) -> str:
1139
+ import re
1140
+
1141
+ # 1. Protect code blocks from inner conversion
1142
+ code_blocks = []
1143
+ def _save_code_block(m):
1144
+ code_blocks.append(m.group(2))
1145
+ return f"\u00abCODEBLOCK{len(code_blocks)-1}\u00bb"
1146
+
1147
+ text = re.sub(r'```(\w*)\n?(.*?)```', _save_code_block, text, flags=re.DOTALL)
1148
+
1149
+ # 2. Protect inline code
1150
+ inline_codes = []
1151
+ def _save_inline_code(m):
1152
+ inline_codes.append(m.group(1))
1153
+ return f"\u00abINLINECODE{len(inline_codes)-1}\u00bb"
1154
+
1155
+ text = re.sub(r'`([^`]+)`', _save_inline_code, text)
1156
+
1157
+ # 3. Convert **bold** (before *italic*)
1158
+ text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
1159
+
1160
+ # 4. Convert *italic* (remaining single asterisks)
1161
+ text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
1162
+
1163
+ # 5. Headings -> bold
1164
+ text = re.sub(r'^#{1,6}\s+(.+?)$', r'<b>\1</b>', text, flags=re.MULTILINE)
1165
+
1166
+ # 6. Horizontal rules
1167
+ text = re.sub(r'^[-*_]{3,}\s*$', '\n---\n', text, flags=re.MULTILINE)
1168
+
1169
+ # 7. List markers
1170
+ text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
1171
+ text = re.sub(r'^\d+\.\s+', '', text, flags=re.MULTILINE)
1172
+
1173
+ # 8. Blockquote markers
1174
+ text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
1175
+
1176
+ # 9. Restore code blocks as <pre>
1177
+ for i, block in enumerate(code_blocks):
1178
+ text = text.replace(f"\u00abCODEBLOCK{i}\u00bb", f"<pre>{block.strip()}</pre>")
1179
+
1180
+ # 10. Restore inline code as <code>
1181
+ for i, code in enumerate(inline_codes):
1182
+ text = text.replace(f"\u00abINLINECODE{i}\u00bb", f"<code>{code}</code>")
1183
+
1184
+ return text
1185
+
1186
+
1187
+ def _clean_telegram_html(text: str) -> str:
1188
+ """Fix common HTML tag hallucinations from models to ensure Telegram accepts them."""
1189
+ import re
1190
+ # Fix invented/broken tags
1191
+ text = text.replace("<//t>", "</b>")
1192
+ text = text.replace("<br/>", "\n").replace("<br>", "\n")
1193
+ text = text.replace("<hr/>", "\n---\n")
1194
+
1195
+ # Telegram DOES NOT support <ul>, <li>, <ol>, <h3>, etc.
1196
+ # Convert them to plain text or supported tags
1197
+ text = re.sub(r'</?ul>', '', text, flags=re.IGNORECASE)
1198
+ text = re.sub(r'</?ol>', '', text, flags=re.IGNORECASE)
1199
+ text = re.sub(r'<li>', '• ', text, flags=re.IGNORECASE)
1200
+ text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
1201
+
1202
+ # Convert Headings to Bold
1203
+ text = re.sub(r'<h[1-6]>', '<b>', text, flags=re.IGNORECASE)
1204
+ text = re.sub(r'</h[1-6]>', '</b>\n', text, flags=re.IGNORECASE)
1205
+
1206
+ # Remove markdown backticks that might be wrapping <pre> blocks
1207
+ text = re.sub(r'```(?:html)?\n?(<pre>.*?</pre>)\n?```', r'\1', text, flags=re.DOTALL)
1208
+
1209
+ # Auto-close unclosed tags (basic logic)
1210
+ for tag in ["b", "i", "code", "pre"]:
1211
+ open_count = len(re.findall(rf"<{tag}>", text))
1212
+ close_count = len(re.findall(rf"</{tag}>", text))
1213
+ if open_count > close_count:
1214
+ text += f"</{tag}>"
1215
+
1216
+ return text
1217
+
1218
+
1219
+ def _sanitize_for_telegram(text: str) -> str:
1220
+ return _clean_telegram_html(_convert_md_to_html(text))
1221
+
1222
+
1223
+ async def _send_safe(send_func, text: str, parse_mode=None):
1224
+ """Try HTML first, then MarkdownV2, then plain text."""
1225
+ if parse_mode:
1226
+ try:
1227
+ await send_func(text, parse_mode=parse_mode)
1228
+ return
1229
+ except Exception:
1230
+ pass
1231
+ try:
1232
+ safe = _sanitize_for_telegram(text)
1233
+ await send_func(safe, parse_mode=ParseMode.HTML)
1234
+ return
1235
+ except Exception:
1236
+ pass
1237
+ if not parse_mode or parse_mode != ParseMode.MARKDOWN_V2:
1238
+ try:
1239
+ await send_func(text, parse_mode=ParseMode.MARKDOWN_V2)
1240
+ return
1241
+ except Exception:
1242
+ pass
1243
+ await send_func(text)
1244
+
1245
+
1246
+ async def _send_chunks(update: Update, context: ContextTypes.DEFAULT_TYPE, waiting_msg, text: str) -> None:
1247
+ chunks = _chunk_text(text)
1248
+ edit = waiting_msg.edit_text
1249
+ reply = update.message.reply_text
1250
+
1251
+ async def _send(first_chunk, send_func, parse_mode):
1252
+ if parse_mode:
1253
+ try:
1254
+ await send_func(first_chunk, parse_mode=parse_mode)
1255
+ return True
1256
+ except Exception:
1257
+ return False
1258
+ else:
1259
+ await send_func(first_chunk)
1260
+ return True
1261
+
1262
+ safe = _sanitize_for_telegram(chunks[0])
1263
+ if not await _send(safe, edit, ParseMode.HTML):
1264
+ if not await _send(chunks[0], edit, ParseMode.MARKDOWN_V2):
1265
+ await _send(chunks[0], edit, None)
1266
+ for chunk in chunks[1:]:
1267
+ safe = _sanitize_for_telegram(chunk)
1268
+ if not await _send(safe, reply, ParseMode.HTML):
1269
+ if not await _send(chunk, reply, ParseMode.MARKDOWN_V2):
1270
+ await _send(chunk, reply, None)
1271
+
1272
+ # Check if the text contains a file path to send
1273
+ await _check_and_send_files(update, context, text)
1274
+
1275
+
1276
+ async def _check_and_send_files(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str) -> None:
1277
+ """Scans text for potential file paths and sends them as documents if they exist."""
1278
+ import re, json, time
1279
+ from datetime import datetime
1280
+ # Look for paths in backticks, <code> tags, quotes, or as absolute paths
1281
+ # Matches patterns like `C:\path\to\file`, <code>C:\path\to\file</code>, /home/user/file.ext, or File saved at: path
1282
+ paths = re.findall(r'(?:[`"\'\s>]|code>)([a-zA-Z]:\\[^`"\'\s\n<>]+|/[^`"\'\s\n<>]+)', text)
1283
+
1284
+ # Also check for explicit tool-like patterns
1285
+ paths += re.findall(r'File saved at:?\s*[`"\'\s]?([^`"\'\s\n]+)', text)
1286
+
1287
+ # Get current session ID
1288
+ chat_id = update.effective_chat.id
1289
+ session = storage.load_session(chat_id)
1290
+ session_uuid = session.session_id if session else "unknown"
1291
+
1292
+ # Files directory — date-based subfolders
1293
+ files_dir = Path(__file__).resolve().parent.parent / "data" / "files"
1294
+ files_dir.mkdir(parents=True, exist_ok=True)
1295
+
1296
+ today = datetime.now().strftime("%Y-%m-%d")
1297
+ day_dir = files_dir / today
1298
+ day_dir.mkdir(parents=True, exist_ok=True)
1299
+
1300
+ # File index
1301
+ index_path = files_dir / "file_index.json"
1302
+ file_index = {}
1303
+ if index_path.exists():
1304
+ try:
1305
+ file_index = json.loads(index_path.read_text(encoding="utf-8"))
1306
+ except Exception:
1307
+ file_index = {}
1308
+
1309
+ sent_paths = set()
1310
+ for path_str in paths:
1311
+ path_str = path_str.strip('`"\' ')
1312
+ try:
1313
+ p = Path(path_str)
1314
+ if p.is_file() and path_str not in sent_paths:
1315
+ import shutil
1316
+ ts = datetime.now().strftime("%H%M%S")
1317
+ archived_name = f"{session_uuid}_{ts}_{p.name}"
1318
+ archived_path = day_dir / archived_name
1319
+
1320
+ shutil.copy2(str(p), str(archived_path))
1321
+
1322
+ # Update index
1323
+ file_index[archived_name] = {
1324
+ "session_id": session_uuid,
1325
+ "date": today,
1326
+ "time": ts,
1327
+ "chat_id": chat_id,
1328
+ "original_path": path_str,
1329
+ "archived_path": str(archived_path),
1330
+ "filename": p.name
1331
+ }
1332
+ index_path.write_text(json.dumps(file_index, ensure_ascii=False, indent=2), encoding="utf-8")
1333
+
1334
+ logger.info("Auto-sending file detected in response: %s → %s", path_str, archived_name)
1335
+ await update.message.reply_document(
1336
+ document=open(archived_path, 'rb'),
1337
+ filename=p.name,
1338
+ caption=f"📄 {p.name}"
1339
+ )
1340
+ sent_paths.add(path_str)
1341
+ except Exception as e:
1342
+ logger.error("Failed to auto-send file %s: %s", path_str, e)
1343
+
1344
+
1345
+ # ─────────────────────────────────────────────────────────────────────────────
1346
+ # Handlers for Command Buttons
1347
+ # ─────────────────────────────────────────────────────────────────────────────
1348
+
1349
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1350
+ chat_id = update.effective_chat.id
1351
+ user = update.effective_user
1352
+
1353
+ if not _is_authorized(user.id):
1354
+ await update.message.reply_text("⛔ Not authorised.")
1355
+ return
1356
+
1357
+ _ensure_session(chat_id, user.id, user.username)
1358
+
1359
+ # Check if user has memory/profile
1360
+ memory = storage.load_memory(chat_id)
1361
+ if not memory:
1362
+ # Start Onboarding
1363
+ _onboarding_step[chat_id] = 1
1364
+ await update.message.reply_text(
1365
+ f"🦾 <b>أهلاً بك في عالم BMO!</b>\n\n"
1366
+ "أنا مساعدك الذكي المتحمس لمساعدتك في رحلتك البرمجية والتقنية.\n"
1367
+ "قبل ما نبدأ، حاب أتعرف عليك أكثر.. <b>شنو تحب أناديك؟</b> (الاسم المفضل)",
1368
+ parse_mode=ParseMode.HTML
1369
+ )
1370
+ return
1371
+
1372
+ if not opencode_client.is_connected:
1373
+ await opencode_client.connect()
1374
+
1375
+ # Normal Welcome for existing users
1376
+ name = memory.preferences.get("name", user.first_name)
1377
+ continuity = ""
1378
+ last_session = storage.load_session(chat_id)
1379
+ if last_session and last_session.get_summary():
1380
+ s = last_session.get_summary()[:200]
1381
+ continuity = f"\n\n📋 <b>موجز آخر جلسة:</b>\n<i>{s}...</i>\n"
1382
+
1383
+ text = (
1384
+ f"⚡ <b>BMO online, {name}!</b>{continuity}\n\n"
1385
+ "أنا جاهز لمساعدتك، اطلب أي شي أو ارسل ملفاتك للتحليل!"
1386
+ )
1387
+
1388
+ from tools.task_registry import check_port_conflict
1389
+ conflict = check_port_conflict(3456)
1390
+ webchat_btn = (
1391
+ InlineKeyboardButton("⏹ Stop Webchat", callback_data="action:stop_webchat")
1392
+ if conflict
1393
+ else InlineKeyboardButton("🌐 Chat on Web", callback_data="action:launch_webchat")
1394
+ )
1395
+ inline_kb = InlineKeyboardMarkup([
1396
+ [webchat_btn],
1397
+ [InlineKeyboardButton("⚡ Open Menu", callback_data="back_to_menu")]
1398
+ ])
1399
+
1400
+ # Send a dummy/activation message for reply keyboard to make sure it's active
1401
+ await update.message.reply_text("🦾 BMO controls activated.", reply_markup=MAIN_MENU_KEYBOARD)
1402
+ await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=inline_kb)
1403
+
1404
+
1405
+ async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1406
+ """Show inline menu via /menu command."""
1407
+ chat_id = update.effective_chat.id
1408
+ user = update.effective_user
1409
+
1410
+ if not _is_authorized(user.id):
1411
+ await update.message.reply_text("⛔ Not authorised.")
1412
+ return
1413
+
1414
+ session = storage.load_session(chat_id)
1415
+ await update.message.reply_text(
1416
+ "⚡ <b>BMO Menu</b>\n\nSelect an action:",
1417
+ parse_mode=ParseMode.HTML,
1418
+ reply_markup=build_inline_menu(session)
1419
+ )
1420
+
1421
+
1422
+ async def handle_new_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1423
+ chat_id = update.effective_chat.id
1424
+ user = update.effective_user
1425
+
1426
+ # Auto-summarize the old session before archiving
1427
+ old_session = storage.load_session(chat_id)
1428
+ if old_session and old_session.messages and not old_session.get_summary():
1429
+ try:
1430
+ # Enhanced Summarization Prompt
1431
+ history = old_session.get_context_text(max_messages=100) # Give full history for summary
1432
+ sp = (
1433
+ "لخص هذه الجلسة البرمجية بشكل احترافي ومفصل باللغة العربية.\n"
1434
+ "يجب أن يتضمن الملخص:\n"
1435
+ "1. الهدف الرئيسي من الجلسة.\n"
1436
+ "2. المشاكل التي تم حلها والكود الذي تم كتابته.\n"
1437
+ "3. القرارات التقنية المهمة.\n"
1438
+ "4. ما الذي يجب إكماله في الجلسة القادمة.\n\n"
1439
+ "السياق:\n" + history
1440
+ )
1441
+ # Summarization with 30s timeout - won't block new session creation
1442
+ summary = await asyncio.wait_for(
1443
+ opencode_client.send_query(sp, active_mode="ask", chat_id=chat_id),
1444
+ timeout=30.0
1445
+ )
1446
+ if summary and not summary.startswith("Error"):
1447
+ old_session.set_summary(summary)
1448
+ storage.save_session(old_session)
1449
+ except asyncio.TimeoutError:
1450
+ logger.warning("Summarization timed out after 30s for chat %s", chat_id)
1451
+ except Exception as e:
1452
+ logger.warning("Summarization failed for chat %s: %s", chat_id, e)
1453
+
1454
+ if old_session and old_session.messages:
1455
+ msg_count = len(old_session.messages)
1456
+ title = old_session.title or old_session.session_id[:8]
1457
+ await update.message.reply_text(
1458
+ f"📋 <b>نسدت المحادثة وانخزنت بالسجل</b>\n"
1459
+ f"<b>{title}</b>\n"
1460
+ f"📊 إجمالي الرسائل: {msg_count}",
1461
+ parse_mode=ParseMode.HTML,
1462
+ )
1463
+
1464
+ # Handle both Message and CallbackQuery
1465
+ chat_id = update.effective_chat.id
1466
+ user = update.effective_user
1467
+
1468
+ # 1. Clean up old session resources if any
1469
+ if chat_id in _running_tasks:
1470
+ task = _running_tasks.get(chat_id)
1471
+ if task and not task.done():
1472
+ task.cancel()
1473
+
1474
+ # 2. Create a BRAND NEW session
1475
+ from uuid import uuid4
1476
+ now = datetime.now().timestamp()
1477
+ new_session = ChatSession(
1478
+ chat_id=chat_id,
1479
+ user_id=user.id,
1480
+ username=user.username,
1481
+ created_at=now,
1482
+ updated_at=now,
1483
+ messages=[],
1484
+ metadata={
1485
+ "opencode_session_id": None,
1486
+ "provider_id": "opencode",
1487
+ "model_id": "big-pickle"
1488
+ },
1489
+ session_id=str(uuid4())
1490
+ )
1491
+ storage.save_session(new_session)
1492
+ storage.set_active_session(chat_id, new_session.session_id)
1493
+ _chat_model[chat_id] = ("opencode", "big-pickle")
1494
+
1495
+ msg_text = (
1496
+ "✨ <b>بدأنا جلسة جديدة!</b>\n"
1497
+ "تم تصفير الذاكرة وجاهز لمهمة جديدة. شنو نسوي اليوم؟"
1498
+ )
1499
+
1500
+ if update.callback_query:
1501
+ await update.callback_query.answer()
1502
+ await update.callback_query.edit_message_text(msg_text, reply_markup=build_inline_menu(new_session), parse_mode=ParseMode.HTML)
1503
+ else:
1504
+ await update.message.reply_text(msg_text, reply_markup=MAIN_MENU_KEYBOARD, parse_mode=ParseMode.HTML)
1505
+
1506
+ async def clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1507
+ chat_id = update.effective_chat.id
1508
+ user = update.effective_user
1509
+
1510
+ old_session = storage.load_session(chat_id)
1511
+ if old_session and old_session.title:
1512
+ msg_count = len(old_session.messages)
1513
+ await update.message.reply_text(
1514
+ f"📋 <b>Session Archived</b>\n"
1515
+ f"<b>{old_session.title}</b>\n"
1516
+ f"📊 Total Messages: {msg_count}",
1517
+ parse_mode=ParseMode.HTML,
1518
+ )
1519
+
1520
+ storage.delete_session(chat_id)
1521
+ _ensure_session(chat_id, user.id, user.username)
1522
+ await update.message.reply_text("🗑️ History cleared! Ready for a fresh start.", reply_markup=MAIN_MENU_KEYBOARD)
1523
+
1524
+
1525
+ async def handle_clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1526
+ """Alias for clear_command for ReplyKeyboard."""
1527
+ await clear_command(update, context)
1528
+
1529
+
1530
+ async def _handle_view_history(update: Update) -> None:
1531
+ """Show full session history with dates."""
1532
+ chat_id = update.effective_chat.id
1533
+ all_sessions = storage.list_sessions(chat_id)
1534
+
1535
+ if not all_sessions:
1536
+ await update.message.reply_text(
1537
+ "📜 <b>No History Found</b>\n\nStart a conversation to build your session history!",
1538
+ parse_mode=ParseMode.HTML
1539
+ )
1540
+ return
1541
+
1542
+ # Sort by created_at desc
1543
+ all_sessions.sort(key=lambda x: x.get("created_at", 0), reverse=True)
1544
+
1545
+ text = f"📜 <b>Session History</b> ({len(all_sessions)} total)\n\n"
1546
+
1547
+ for idx, s in enumerate(all_sessions[:10], 1):
1548
+ title = s.get("title") or "Untitled"
1549
+ created = datetime.fromtimestamp(s.get("created_at", 0))
1550
+ date_str = created.strftime("%Y-%m-%d %H:%M")
1551
+ msg_count = s.get("message_count", 0)
1552
+
1553
+ # Show first 40 chars of title
1554
+ short_title = title[:40] + "..." if len(title) > 40 else title
1555
+
1556
+ text += f"{idx}. <b>{short_title}</b>\n"
1557
+ text += f" 📅 {date_str} | 💬 {msg_count} msgs\n\n"
1558
+
1559
+ if len(all_sessions) > 10:
1560
+ text += f"... and {len(all_sessions) - 10} more sessions."
1561
+
1562
+ # Add inline keyboard for quick load
1563
+ kb = [[InlineKeyboardButton("📂 Load a Session", callback_data="action:list_sessions")]]
1564
+ await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(kb))
1565
+
1566
+
1567
+ async def _handle_save_session(update: Update) -> None:
1568
+ """Manually save and summarize current session."""
1569
+ chat_id = update.effective_chat.id
1570
+ session = storage.load_session(chat_id)
1571
+
1572
+ if not session or not session.messages:
1573
+ await update.message.reply_text("📭 <b>Nothing to Save</b>\n\nStart a conversation first!")
1574
+ return
1575
+
1576
+ msg_count = len(session.messages)
1577
+ title = session.title or f"Session {session.session_id[:8]}"
1578
+
1579
+ # Create backup summary
1580
+ summary_text = session.get_summary()
1581
+
1582
+ await update.message.reply_text(
1583
+ f"💾 <b>Session Saved!</b>\n\n"
1584
+ f"🏷️ <b>{title}</b>\n"
1585
+ f"📊 {msg_count} messages\n"
1586
+ f"📝 Summary: {summary_text[:100]}..." if summary_text else "📝 Summary: Pending...",
1587
+ parse_mode=ParseMode.HTML
1588
+ )
1589
+
1590
+
1591
+ async def _handle_load_sessions(update: Update) -> None:
1592
+ """Show sessions available to load."""
1593
+ chat_id = update.effective_chat.id
1594
+ all_sessions = storage.list_sessions(chat_id)
1595
+
1596
+ if not all_sessions:
1597
+ await update.message.reply_text(
1598
+ "📂 <b>No Sessions to Load</b>\n\nYour sessions will appear here.",
1599
+ parse_mode=ParseMode.HTML
1600
+ )
1601
+ return
1602
+
1603
+ # Sort and show most recent first
1604
+ all_sessions.sort(key=lambda x: x.get("created_at", 0), reverse=True)
1605
+
1606
+ # Build inline list
1607
+ kb = []
1608
+ for s in all_sessions[:15]:
1609
+ title = (s.get("title") or s.get("session_id", "unknown")[:8])[:35]
1610
+ kb.append([InlineKeyboardButton(f"📂 {title}", callback_data=f"session_load:{s['session_id']}")])
1611
+
1612
+ kb.append([InlineKeyboardButton("🔙 Back to Menu", callback_data="back_to_menu")])
1613
+
1614
+ text = f"📂 <b>Load a Session</b> ({len(all_sessions)} total)\n\nSelect a session to load:"
1615
+
1616
+ await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(kb))
1617
+
1618
+
1619
+ async def handle_choose_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1620
+ waiting = await update.message.reply_text("🔍 <i>Scanning BMO's brain for available models...</i>", parse_mode=ParseMode.HTML)
1621
+ await _render_model_page(update, context, p_idx=0, m_page=0, target_msg=waiting)
1622
+
1623
+ async def _render_model_page(update: Update, context: ContextTypes.DEFAULT_TYPE, p_idx: int, m_page: int, target_msg=None) -> None:
1624
+ # 1. Fetch live providers
1625
+ providers_data = await opencode_client.get_providers()
1626
+ all_p = providers_data.get("all", [])
1627
+ connected_ids = providers_data.get("connected", [])
1628
+ saved_ids = [k["provider_id"] for k in storage.list_provider_keys()]
1629
+
1630
+ # 2. Filter and Sort Providers
1631
+ valid_providers = []
1632
+ for p in all_p:
1633
+ p_id = p["id"]
1634
+ is_accessible = (p_id in connected_ids) or (p_id in saved_ids)
1635
+ models = p.get("models", {})
1636
+
1637
+ provider_models = []
1638
+ for mid, m_data in models.items():
1639
+ is_free = (":free" in mid.lower()) or ("free" in m_data.get("name", "").lower()) or (p_id == "opencode")
1640
+ if is_accessible or is_free:
1641
+ label = m_data.get("name", mid)
1642
+ label = label.replace("-latest", "").replace("-pro", " Pro").replace("-lite", " Lite").replace("-flash", " Flash")
1643
+ prefix = "🆓" if is_free else "📡"
1644
+ if p_id in ["google", "openai", "anthropic", "deepseek"]: prefix = "✨"
1645
+
1646
+ provider_models.append({
1647
+ "pid": p_id,
1648
+ "mid": mid,
1649
+ "label": f"{prefix} {label}",
1650
+ "is_free": is_free
1651
+ })
1652
+
1653
+ if provider_models:
1654
+ provider_models.sort(key=lambda x: not x["is_free"]) # Free first
1655
+ valid_providers.append({
1656
+ "id": p_id,
1657
+ "name": p.get("name", p_id),
1658
+ "models": provider_models
1659
+ })
1660
+
1661
+ # 2.5 Ensure 'opencode' is always the first provider (Zen)
1662
+ valid_providers.sort(key=lambda x: x["id"] != "opencode")
1663
+
1664
+ if not valid_providers:
1665
+ text = "❌ No available models found. Try adding a provider key first."
1666
+ if update.callback_query: await update.callback_query.edit_message_text(text)
1667
+ elif target_msg: await target_msg.edit_text(text)
1668
+ return
1669
+
1670
+ # 3. Handle Pagination Logic
1671
+ p_idx = max(0, min(p_idx, len(valid_providers) - 1))
1672
+ current_p = valid_providers[p_idx]
1673
+
1674
+ models_per_page = 20
1675
+ max_m_page = (len(current_p["models"]) - 1) // models_per_page
1676
+ m_page = max(0, min(m_page, max_m_page))
1677
+
1678
+ start = m_page * models_per_page
1679
+ end = start + models_per_page
1680
+ page_models = current_p["models"][start:end]
1681
+
1682
+ # 4. Build Keyboard
1683
+ kb = []
1684
+ row = []
1685
+ for m in page_models:
1686
+ cb_data = f"m:{m['pid']}:{m['mid']}"
1687
+ if len(cb_data.encode('utf-8')) <= 64:
1688
+ row.append(InlineKeyboardButton(m["label"], callback_data=cb_data))
1689
+ if len(row) == 2:
1690
+ kb.append(row)
1691
+ row = []
1692
+ if row: kb.append(row)
1693
+
1694
+ # Model Pagination Row
1695
+ m_nav = []
1696
+ if m_page > 0:
1697
+ m_nav.append(InlineKeyboardButton("⬅️ Prev Models", callback_data=f"mp:{p_idx}:{m_page-1}"))
1698
+ if m_page < max_m_page:
1699
+ m_nav.append(InlineKeyboardButton("Next Models ➡️", callback_data=f"mp:{p_idx}:{m_page+1}"))
1700
+ if m_nav: kb.append(m_nav)
1701
+
1702
+ # Provider Pagination Row
1703
+ p_nav = []
1704
+ if p_idx > 0:
1705
+ p_nav.append(InlineKeyboardButton("⏮ Previous Provider", callback_data=f"mp:{p_idx-1}:0"))
1706
+ if p_idx < len(valid_providers) - 1:
1707
+ p_nav.append(InlineKeyboardButton("Next Provider ⏭", callback_data=f"mp:{p_idx+1}:0"))
1708
+ if p_nav: kb.append(p_nav)
1709
+
1710
+ status_msg = (
1711
+ f"🤖 <b>Select a Model</b>\n"
1712
+ f"🏢 <b>Provider:</b> {current_p['name']} ({p_idx+1}/{len(valid_providers)})\n"
1713
+ f"📄 <b>Page:</b> {m_page+1}/{max_m_page+1}\n\n"
1714
+ "Free models 🆓 are shown first. ✨ are premium. 📡 use your keys."
1715
+ )
1716
+
1717
+ try:
1718
+ if update.callback_query:
1719
+ await update.callback_query.edit_message_text(status_msg, reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
1720
+ elif target_msg:
1721
+ await target_msg.edit_text(status_msg, reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
1722
+ except Exception as e:
1723
+ logger.error("Error rendering model page: %s", e)
1724
+
1725
+
1726
+ async def choose_model_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1727
+ query = update.callback_query
1728
+ await query.answer()
1729
+
1730
+ user = query.from_user
1731
+ if not _is_authorized(user.id):
1732
+ return
1733
+
1734
+ data = query.data
1735
+ chat_id = update.effective_chat.id
1736
+
1737
+ # 1. Handle Pagination
1738
+ if data.startswith("mp:"):
1739
+ try:
1740
+ parts = data.split(":")
1741
+ if len(parts) == 3:
1742
+ p_idx, m_page = int(parts[1]), int(parts[2])
1743
+ await _render_model_page(update, context, p_idx, m_page)
1744
+ except Exception as e:
1745
+ logger.error("Pagination error: %s", e)
1746
+ return
1747
+
1748
+ # 2. Handle Model Selection
1749
+ if data.startswith("m:"):
1750
+ try:
1751
+ _, provider_id, model_id = data.split(":", 2)
1752
+
1753
+ # Ensure a session exists (Recovery)
1754
+ session = _ensure_session(chat_id, user.id, user.username)
1755
+
1756
+ # Update session metadata for persistence
1757
+ if session:
1758
+ session.metadata["provider_id"] = provider_id
1759
+ session.metadata["model_id"] = model_id
1760
+ # HARD RESET: Force the next message to create a fresh backend session
1761
+ session.metadata["opencode_session_id"] = None
1762
+ storage.save_session(session)
1763
+
1764
+ # Clear memory cache to ensure the bot uses the new model immediately
1765
+ _chat_model.pop(chat_id, None)
1766
+
1767
+ # ── Global Environment Sync ──
1768
+ try:
1769
+ _sync_to_env("OPENCODE_PROVIDER", provider_id)
1770
+ _sync_to_env("OPENCODE_MODEL", model_id)
1771
+ except Exception as e:
1772
+ logger.error("Global model sync failed: %s", e)
1773
+
1774
+ # Update memory cache
1775
+ _chat_model[chat_id] = (provider_id, model_id)
1776
+
1777
+ # Clean label for confirmation message
1778
+ display_name = model_id.split("/")[-1] if "/" in model_id else model_id
1779
+ display_name = display_name.replace("-latest", "").replace("-pro", " Pro").replace("-lite", " Lite").replace("-flash", " Flash").title()
1780
+
1781
+ # Sanitize for HTML
1782
+ safe_p_id = provider_id.replace("<", "").replace(">", "")
1783
+ safe_m_id = display_name.replace("<", "").replace(">", "")
1784
+
1785
+ await query.edit_message_text(
1786
+ f"✅ <b>Model Activated!</b>\n\n"
1787
+ f"🏢 Provider: <code>{safe_p_id}</code>\n"
1788
+ f"🤖 Model: <code>{safe_m_id}</code>\n\n"
1789
+ "BMO is ready for your next message.",
1790
+ parse_mode=ParseMode.HTML
1791
+ )
1792
+ except Exception as e:
1793
+ logger.error("Model selection error for chat %s: %s", chat_id, e)
1794
+ await query.edit_message_text(
1795
+ f"❌ <b>Selection Error</b>\n\n<i>{str(e)}</i>",
1796
+ parse_mode=ParseMode.HTML
1797
+ )
1798
+ return
1799
+
1800
+
1801
+ async def handle_use_skill(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1802
+ try:
1803
+ async with httpx.AsyncClient(base_url=OPENCODE_BASE_URL, timeout=10) as client:
1804
+ r = await client.get("/agent")
1805
+ agents = r.json() if r.status_code == 200 else []
1806
+ except Exception:
1807
+ agents = []
1808
+
1809
+ if not agents:
1810
+ await update.message.reply_text("⚠️ Could not fetch agents.")
1811
+ return
1812
+
1813
+ keyboard = [
1814
+ [InlineKeyboardButton(
1815
+ f"🔹 {a['name']}",
1816
+ callback_data=f"skill:{a['name']}"
1817
+ )]
1818
+ for a in agents[:12]
1819
+ ]
1820
+
1821
+ text = "🛠️ <b>Available Skills & Agents:</b>\n"
1822
+ for a in agents[:12]:
1823
+ desc = a.get('description', 'No description')
1824
+ text += f"• <b>{a['name']}</b>: <i>{desc}</i>\n"
1825
+
1826
+ await update.message.reply_text(
1827
+ text,
1828
+ reply_markup=InlineKeyboardMarkup(keyboard),
1829
+ parse_mode=ParseMode.HTML,
1830
+ )
1831
+
1832
+
1833
+ async def use_skill_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1834
+ query = update.callback_query
1835
+ await query.answer()
1836
+ if not _is_authorized(query.from_user.id):
1837
+ return
1838
+ _, skill_name = query.data.split(":", 1)
1839
+ context.chat_data["active_skill"] = skill_name
1840
+ await query.edit_message_text(
1841
+ f"🛠️ Skill <b>{skill_name}</b> activated.\n"
1842
+ f"<i>Your next message will be handled by this agent.</i>",
1843
+ parse_mode=ParseMode.HTML,
1844
+ )
1845
+
1846
+
1847
+ async def handle_choose_mode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1848
+ chat_id = update.effective_chat.id
1849
+ session = _ensure_session(chat_id, update.effective_user.id, update.effective_user.username)
1850
+ current_mode = session.metadata.get("active_mode", "execute")
1851
+
1852
+ keyboard = [
1853
+ [InlineKeyboardButton(f"{'✅ ' if m == current_mode else ''}{label}", callback_data=f"mode_set:{m}")]
1854
+ for m, (label, _) in MODES.items()
1855
+ ]
1856
+
1857
+ text = "⚡ <b>Select Operation Mode:</b>\n\n"
1858
+ for m, (label, desc) in MODES.items():
1859
+ text += f"• <b>{label}</b>: {desc}\n"
1860
+
1861
+ await update.message.reply_text(
1862
+ text,
1863
+ reply_markup=InlineKeyboardMarkup(keyboard),
1864
+ parse_mode=ParseMode.HTML,
1865
+ )
1866
+
1867
+ async def mode_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1868
+ query = update.callback_query
1869
+ await query.answer()
1870
+ if not _is_authorized(query.from_user.id):
1871
+ return
1872
+
1873
+ _, mode_id = query.data.split(":", 1)
1874
+ chat_id = query.message.chat_id
1875
+ session = storage.load_session(chat_id)
1876
+ if session:
1877
+ session.metadata["active_mode"] = mode_id
1878
+ storage.save_session(session)
1879
+
1880
+ label, _ = MODES.get(mode_id, ("Unknown", ""))
1881
+ await query.edit_message_text(
1882
+ f"⚡ Mode updated to <b>{label}</b>\n"
1883
+ f"<i>BMO will now follow these protocols.</i>",
1884
+ parse_mode=ParseMode.HTML,
1885
+ )
1886
+
1887
+
1888
+ async def cancel_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1889
+ query = update.callback_query
1890
+ chat_id = query.message.chat_id
1891
+ await query.answer('Cancelling...')
1892
+ try:
1893
+ await query.edit_message_text('⏹️ <b>Stopped</b>\n<i>Request cancelled — OpenCode stream aborted.</i>', parse_mode=ParseMode.HTML)
1894
+ except: pass
1895
+
1896
+ # Cancel the asyncio task
1897
+ task = _running_tasks.get(chat_id)
1898
+ if task and not task.done():
1899
+ task.cancel()
1900
+ if chat_id in _file_tasks:
1901
+ for t in _file_tasks[chat_id]:
1902
+ if not t.done(): t.cancel()
1903
+ _file_tasks.pop(chat_id, None)
1904
+
1905
+ # ── Abort the OpenCode server-side stream ──
1906
+ # This is the critical step that stops the LLM from continuing to run
1907
+ session = storage.load_session(chat_id)
1908
+ opencode_sid = session.metadata.get("opencode_session_id") if session else None
1909
+ if opencode_sid:
1910
+ try:
1911
+ aborted = await opencode_client.abort_session(opencode_sid)
1912
+ logger.info("Telegram cancel: abort_session(%s) -> %s", opencode_sid, aborted)
1913
+ except Exception as e:
1914
+ logger.warning("Telegram cancel: abort_session failed: %s", e)
1915
+
1916
+
1917
+ # ── Auto Summary ────────────────────────────────────────────────────────────
1918
+
1919
+ async def _auto_summary_task(chat_id: int, session_id: str, bot):
1920
+ """After inactivity, generate & store session summary and title."""
1921
+ try:
1922
+ await asyncio.sleep(INACTIVITY_TIMEOUT)
1923
+ session = storage.load_session(chat_id)
1924
+ if not session or session.session_id != session_id:
1925
+ return
1926
+ if len(session.messages) < 2 or session.get_summary():
1927
+ return
1928
+
1929
+ history = session.get_context_text(max_messages=100)
1930
+ prompt = (
1931
+ "Based on this conversation history:\n"
1932
+ f"{history}\n\n"
1933
+ "1. Provide a concise summary of the work done.\n"
1934
+ "2. Suggest a short 3-4 word title for this session.\n\n"
1935
+ "Format your response as:\nSUMMARY: [summary text]\nTITLE: [suggested title]"
1936
+ )
1937
+ response = await opencode_client.send_query(prompt, active_mode="ask", chat_id=chat_id)
1938
+ if response and not response.startswith("Error"):
1939
+ summary = response
1940
+ title = None
1941
+ if "SUMMARY:" in response and "TITLE:" in response:
1942
+ parts = response.split("TITLE:")
1943
+ summary = parts[0].replace("SUMMARY:", "").strip()
1944
+ title = parts[1].strip()
1945
+ session.set_summary(summary)
1946
+ if title:
1947
+ session.set_title(title)
1948
+ storage.save_session(session)
1949
+ short = summary[:250] + "…" if len(summary) > 250 else summary
1950
+ title_info = f" 🏷️ <b>{title}</b>" if title else ""
1951
+ await _send_safe(
1952
+ lambda text, pm=None: bot.send_message(chat_id=chat_id, text=text, parse_mode=pm),
1953
+ f"📝 <b>Session Summary Saved</b>{title_info}\n\n{short}"
1954
+ )
1955
+ except asyncio.CancelledError:
1956
+ pass
1957
+ except Exception as e:
1958
+ logger.debug("Auto-summary: %s", e)
1959
+
1960
+
1961
+ def _schedule_auto_summary(chat_id: int, session: ChatSession, bot):
1962
+ existing = _summary_timers.get(chat_id)
1963
+ if existing and not existing.done():
1964
+ existing.cancel()
1965
+ task = asyncio.create_task(_auto_summary_task(chat_id, session.session_id, bot))
1966
+ _summary_timers[chat_id] = task
1967
+
1968
+
1969
+ MESSAGE_COUNTER_THRESHOLD = 7
1970
+
1971
+
1972
+ async def _check_message_counter_summary(session: ChatSession, bot, chat_id: int):
1973
+ """If message counter >= threshold, generate summary and reset counter."""
1974
+ count = session.get_message_counter()
1975
+ if count < MESSAGE_COUNTER_THRESHOLD:
1976
+ return
1977
+
1978
+ history = session.get_context_text(max_messages=100)
1979
+ prompt = (
1980
+ "Based on this conversation history:\n"
1981
+ f"{history}\n\n"
1982
+ "1. Provide a concise summary of the work done.\n"
1983
+ "2. Suggest a short 3-4 word title for this session.\n\n"
1984
+ "Format your response exactly as:\n"
1985
+ "SUMMARY: [summary text]\n"
1986
+ "TITLE: [suggested title]"
1987
+ )
1988
+ response = await opencode_client.send_query(prompt, active_mode="ask", chat_id=chat_id)
1989
+ if response and not response.startswith("Error"):
1990
+ summary = response
1991
+ title = None
1992
+
1993
+ if "SUMMARY:" in response:
1994
+ summary = response.split("SUMMARY:")[1]
1995
+ if "TITLE:" in summary:
1996
+ summary, title = summary.split("TITLE:", 1)
1997
+ summary = summary.strip()
1998
+ title = title.strip()
1999
+ else:
2000
+ summary = summary.strip()
2001
+
2002
+ session.set_summary(summary)
2003
+ session.set_description(summary)
2004
+ if title:
2005
+ session.set_title(title)
2006
+ storage.save_session(session)
2007
+
2008
+ short = summary[:250] + "…" if len(summary) > 250 else summary
2009
+ title_info = f" 🏷️ <b>{title}</b>" if title else ""
2010
+ await _send_safe(
2011
+ lambda text, pm=None: bot.send_message(chat_id=chat_id, text=text, parse_mode=pm),
2012
+ f"📝 <b>Auto-Summary</b> (every {MESSAGE_COUNTER_THRESHOLD} messages){title_info}\n\n{short}",
2013
+ parse_mode=ParseMode.HTML,
2014
+ )
2015
+
2016
+ session.reset_message_counter()
2017
+ storage.save_session(session)
2018
+
2019
+
2020
+ # ── CORE HANDLERS ─────────────────────────────────────────────────────────────
2021
+
2022
+ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
2023
+ """Main text message handler."""
2024
+ # Guard: ignore non-message updates (edits, channel posts, etc.)
2025
+ if not update.message or not update.message.text:
2026
+ return
2027
+
2028
+ chat_id = update.effective_chat.id
2029
+ user = update.effective_user
2030
+ text = update.message.text
2031
+
2032
+ if not _is_authorized(user.id):
2033
+ await update.message.reply_text("⛔ Not authorised.")
2034
+ return
2035
+
2036
+ # Handle Menu Buttons
2037
+ if text == BTN_AGENTS:
2038
+ await handle_agents(update, context)
2039
+ return
2040
+
2041
+ if text == BTN_NEW_SESSION:
2042
+ await handle_new_session(update, context)
2043
+ return
2044
+ elif text == BTN_CHOOSE_MODEL:
2045
+ await handle_choose_model(update, context)
2046
+ return
2047
+ elif text == BTN_SKILLS:
2048
+ await handle_use_skill(update, context)
2049
+ return
2050
+ elif text == "⚡ Choose Mode":
2051
+ await handle_choose_mode(update, context)
2052
+ return
2053
+ elif text == BTN_STATUS:
2054
+ session = _ensure_session(chat_id, user.id, user.username)
2055
+ active_mode = session.metadata.get("active_mode", "execute")
2056
+ title = session.title or "Untitled Session"
2057
+ msg_count = len(session.messages)
2058
+ p_id, m_id = _get_current_model(chat_id, session)
2059
+
2060
+ # Verify with Server Truth
2061
+ server_model_id = "Unknown"
2062
+ opencode_sid = session.metadata.get("opencode_session_id")
2063
+ if opencode_sid:
2064
+ info = await opencode_client.get_session_info(opencode_sid)
2065
+ if info and "model" in info:
2066
+ server_model_id = info["model"].get("id", "Unknown")
2067
+
2068
+ sync_status = "✅ Synced" if (server_model_id == "Unknown" or m_id in server_model_id or server_model_id in m_id) else "⚠️ Mismatch"
2069
+
2070
+ status_text = (
2071
+ f"ℹ️ <b>BMO System Status</b>\n\n"
2072
+ f"🛠️ <b>Mode:</b> <code>{active_mode}</code>\n"
2073
+ f"💬 <b>Conversation:</b> {title}\n"
2074
+ f"📊 <b>Messages:</b> {msg_count}\n\n"
2075
+ f"🤖 <b>Bot Selected:</b> <code>{m_id}</code>\n"
2076
+ f"🖥️ <b>Server Active:</b> <code>{server_model_id}</code>\n"
2077
+ f"🔗 <b>Sync Status:</b> {sync_status}"
2078
+ )
2079
+ await update.message.reply_text(status_text, parse_mode=ParseMode.HTML)
2080
+ return
2081
+ elif text == BTN_RELOAD:
2082
+ await handle_system_reload(update, context)
2083
+ return
2084
+ elif text == BTN_SETTINGS:
2085
+ await handle_settings(update, context)
2086
+ return
2087
+ elif text == BTN_MENU:
2088
+ session = storage.load_session(chat_id)
2089
+ await update.message.reply_text(
2090
+ "⚡ <b>BMO Menu</b>\n\nSelect an action:",
2091
+ parse_mode=ParseMode.HTML,
2092
+ reply_markup=build_inline_menu(session)
2093
+ )
2094
+ return
2095
+ elif text == BTN_SESSIONS:
2096
+ await update.message.reply_text(
2097
+ "📋 <b>Loading Session History...</b>",
2098
+ parse_mode=ParseMode.HTML
2099
+ )
2100
+ session = storage.load_session(chat_id)
2101
+ fake_query = type('obj', (object,), {
2102
+ 'message': update.message,
2103
+ 'edit_message_text': update.message.reply_text,
2104
+ 'from_user': user
2105
+ })()
2106
+ await handle_list_sessions_inline(fake_query, session)
2107
+ return
2108
+ elif text == BTN_SET_TITLE:
2109
+ _awaiting_title.add(chat_id)
2110
+ current = ""
2111
+ session = storage.load_session(chat_id)
2112
+ if session and session.title:
2113
+ current = f" (current: <b>{session.title}</b>)"
2114
+ await update.message.reply_text(
2115
+ f"🏷️ <b>Send the session title you want</b>{current}:",
2116
+ parse_mode=ParseMode.HTML,
2117
+ )
2118
+ return
2119
+ elif text == BTN_SUMMARY:
2120
+ await handle_session_summary(update, context)
2121
+ return
2122
+ elif text == BTN_RESET_HISTORY:
2123
+ session = storage.load_session(chat_id)
2124
+ if not session or not session.metadata.get("opencode_session_id"):
2125
+ await update.message.reply_text("⚠️ No active session to reset.")
2126
+ return
2127
+
2128
+ waiting = await update.message.reply_text("🗑️ <i>Resetting session history...</i>", parse_mode=ParseMode.HTML)
2129
+ success = await opencode_client.delete_messages(session.metadata["opencode_session_id"])
2130
+
2131
+ if success:
2132
+ # Also clear local message cache if needed
2133
+ session.messages = []
2134
+ storage.save_session(session)
2135
+ await waiting.edit_text("✅ <b>Session History Reset!</b>\nThe server's memory has been wiped clean for this session.", parse_mode=ParseMode.HTML)
2136
+ else:
2137
+ await waiting.edit_text("❌ Failed to reset history on the server.")
2138
+ return
2139
+ elif text == BTN_HISTORY:
2140
+ await _handle_view_history(update)
2141
+ return
2142
+ elif text == BTN_VIEW_HIST: # Legacy alias
2143
+ await _handle_view_history(update)
2144
+ return
2145
+ elif text == BTN_SAVE:
2146
+ await _handle_save_session(update)
2147
+ return
2148
+ elif text == BTN_SAVE_SESSION: # Legacy alias
2149
+ await _handle_save_session(update)
2150
+ return
2151
+ elif text == BTN_LOAD:
2152
+ await _handle_load_sessions(update)
2153
+ return
2154
+ elif text == BTN_LOAD_SESSION: # Legacy alias
2155
+ await _handle_load_sessions(update)
2156
+ return
2157
+ elif text == BTN_SKILLS:
2158
+ await handle_use_skill(update, context)
2159
+ return
2160
+ elif text == BTN_USE_SKILL: # Legacy alias
2161
+ await handle_use_skill(update, context)
2162
+ return
2163
+ elif text == BTN_MODE:
2164
+ await handle_choose_mode(update, context)
2165
+ return
2166
+ elif text == BTN_CLEAR_HIST:
2167
+ await handle_clear_command(update, context)
2168
+ return
2169
+ elif text == BTN_RELOAD:
2170
+ await handle_system_reload(update, context)
2171
+ return
2172
+
2173
+ # Handle Key Input
2174
+ if chat_id in _awaiting_key:
2175
+ info = _awaiting_key.pop(chat_id)
2176
+ test_msg = await update.message.reply_text(
2177
+ f"⏳ <b>Testing {info['provider_name']} API Key...</b>\n"
2178
+ "Please wait a moment while BMO verifies the connection.",
2179
+ parse_mode=ParseMode.HTML
2180
+ )
2181
+
2182
+ # 1. Fetch available models for this provider
2183
+ providers_data = await opencode_client.get_providers()
2184
+ all_p = providers_data.get("all", [])
2185
+ p_info = next((p for p in all_p if p["id"] == info["provider_id"]), None)
2186
+
2187
+ models_dict = p_info.get("models", {}) if p_info else {}
2188
+ if not models_dict:
2189
+ await test_msg.edit_text(f"❌ Error: Could not find any models to test for {info['provider_name']}.")
2190
+ return
2191
+
2192
+ # Pick first active, text-capable model (avoid deprecated/speech-only)
2193
+ test_model = None
2194
+ for m_id, m_data in models_dict.items():
2195
+ status = m_data.get("status", "")
2196
+ can_text = m_data.get("capabilities", {}).get("input", {}).get("text", False)
2197
+ if status == "active" and can_text:
2198
+ test_model = m_id
2199
+ break
2200
+ if not test_model:
2201
+ test_model = list(models_dict.keys())[0] # fallback to first
2202
+
2203
+ # 2. Try a real test request using the new dedicated method
2204
+ try:
2205
+ success, message = await opencode_client.test_provider_key(
2206
+ provider_id=info["provider_id"],
2207
+ model_id=test_model,
2208
+ env={info["key_name"]: text}
2209
+ )
2210
+
2211
+ if success:
2212
+ # Store key in context temporarily until TTL is chosen
2213
+ context.user_data["pending_key"] = {
2214
+ "provider_id": info["provider_id"],
2215
+ "provider_name": info["provider_name"],
2216
+ "key_name": info["key_name"],
2217
+ "value": text
2218
+ }
2219
+
2220
+ kb = [
2221
+ [InlineKeyboardButton("1 Hour", callback_data="ttl_1"), InlineKeyboardButton("24 Hours", callback_data="ttl_24")],
2222
+ [InlineKeyboardButton("7 Days", callback_data="ttl_168"), InlineKeyboardButton("Forever 🔒", callback_data="ttl_0")]
2223
+ ]
2224
+
2225
+ await test_msg.edit_text(
2226
+ f"✅ <b>{info['provider_name']} Verified!</b>\n\n"
2227
+ "Connection confirmed. How long should BMO keep this key in the secure vault?\n"
2228
+ "<i>It will be automatically deleted after this period.</i>",
2229
+ reply_markup=InlineKeyboardMarkup(kb),
2230
+ parse_mode=ParseMode.HTML
2231
+ )
2232
+ else:
2233
+ await test_msg.edit_text(
2234
+ f"❌ <b>Verification Failed</b>\n\n"
2235
+ f"Reason: <i>{message}</i>\n\n"
2236
+ "The key was <b>not</b> saved. Please check your key and try again.",
2237
+ parse_mode=ParseMode.HTML
2238
+ )
2239
+ except Exception as e:
2240
+ logger.error("API Key Test Error: %s", e)
2241
+ await test_msg.edit_text(f"❌ <b>Test Error:</b> System encountered an issue while testing the key.")
2242
+ return
2243
+
2244
+ # ── Thinking Phase: Immediate Feedback ──
2245
+ waiting_msg = await update.message.reply_text(
2246
+ "🧠 <i>BMO is thinking...</i>",
2247
+ parse_mode=ParseMode.HTML,
2248
+ reply_markup=CANCEL_KEYBOARD
2249
+ )
2250
+
2251
+ # ── CRITICAL: Ensure Session and OpenCode ID are defined ──
2252
+ session = _ensure_session(chat_id, user.id, user.username)
2253
+ provider_id, model_id = _get_current_model(chat_id, session)
2254
+
2255
+ opencode_sid = session.metadata.get("opencode_session_id")
2256
+ if not opencode_sid:
2257
+ logger.info("Initializing new OpenCode session for chat %s", chat_id)
2258
+ provider_env = None
2259
+ p_keys = storage.get_provider_keys(provider_id)
2260
+ if p_keys:
2261
+ provider_env = p_keys
2262
+
2263
+ opencode_sid = await opencode_client.create_session(provider_id, model_id, env=provider_env)
2264
+ if opencode_sid:
2265
+ session.metadata["opencode_session_id"] = opencode_sid
2266
+ storage.save_session(session)
2267
+ else:
2268
+ await waiting_msg.edit_text("❌ Failed to initialize OpenCode session. Please try again.")
2269
+ return
2270
+
2271
+ # Start Status Poller (now that we have opencode_sid)
2272
+ stop_event = asyncio.Event()
2273
+ poller_task = asyncio.create_task(
2274
+ _status_poller(
2275
+ context.bot,
2276
+ chat_id,
2277
+ waiting_msg.message_id,
2278
+ opencode_client,
2279
+ opencode_sid,
2280
+ stop_event,
2281
+ )
2282
+ )
2283
+
2284
+ # ── Agent Creation Logic ──
2285
+ if chat_id in _agent_creation:
2286
+ creation = _agent_creation[chat_id]
2287
+ if creation["mode"] == "manual":
2288
+ if creation["step"] == "name":
2289
+ creation["name"] = text
2290
+ creation["step"] = "prompt"
2291
+ await update.message.reply_text(f"Great! Name: <b>{text}</b>\n\nNow, enter the **System Prompt** (the rules BMO must follow):", parse_mode=ParseMode.HTML)
2292
+ elif creation["step"] == "prompt":
2293
+ storage.save_custom_agent(chat_id, user.id, creation["name"], "Manual Agent", text)
2294
+ _agent_creation.pop(chat_id)
2295
+ await update.message.reply_text(f"✅ Agent <b>{creation['name']}</b> created and saved!", parse_mode=ParseMode.HTML, reply_markup=MAIN_MENU_KEYBOARD)
2296
+ else: # AI Mode
2297
+ if creation["step"] == "desc":
2298
+ wait_msg = await update.message.reply_text("🤖 Generating your custom agent persona... please wait.")
2299
+ gen_prompt = (
2300
+ "Create a detailed AI system prompt based on this user description:\n"
2301
+ f"Description: {text}\n\n"
2302
+ "Output in JSON format only:\n"
2303
+ "{\"name\": \"Short Name\", \"prompt\": \"Detailed system instructions\"}"
2304
+ )
2305
+ try:
2306
+ # REUSE current session for speed and stability
2307
+ res = await opencode_client.send_query(
2308
+ query=gen_prompt,
2309
+ session_id=opencode_sid,
2310
+ active_mode="ask",
2311
+ chat_id=chat_id
2312
+ )
2313
+ import json, re
2314
+ match = re.search(r'\{.*\}', res, re.DOTALL)
2315
+ if match:
2316
+ data = json.loads(match.group())
2317
+ storage.save_custom_agent(chat_id, user.id, data["name"], text, data["prompt"])
2318
+ _agent_creation.pop(chat_id)
2319
+ await _send_safe(lambda t, pm=None: wait_msg.edit_text(t, parse_mode=pm), f"✅ AI Agent <b>{data['name']}</b> generated and saved!\n\nRules: {data['prompt'][:100]}...")
2320
+ else:
2321
+ await wait_msg.edit_text("❌ Failed to parse AI response. Please try describing it differently.")
2322
+ except Exception as e:
2323
+ await wait_msg.edit_text(f"❌ Error generating agent: {e}")
2324
+ return
2325
+
2326
+ # ── Onboarding Logic ──
2327
+ if chat_id in _onboarding_step:
2328
+ step = _onboarding_step[chat_id]
2329
+
2330
+ # Smart Check: Is this a question or a correction?
2331
+ lower_text = text.lower()
2332
+ is_question = any(q in lower_text for q in ["شنو", "ماذا", "كيف", "تساعدني", "help", "who", "what", "?"])
2333
+ is_correction = any(c in lower_text for c in ["لا", "اسمي", "no", "name is"])
2334
+
2335
+ if step == 1:
2336
+ if is_question and not is_correction:
2337
+ await update.message.reply_text(
2338
+ "أنا BMO، مساعدك البرمجي الذكي! 🤖 أقدر أساعدك بكتابة الكود، شرح المفاهيم المعقدة، وحل المشاكل التقنية.\n\n"
2339
+ "بس قبل ما نبدأ، حاب أعرف اسمك حتى أناديك بي؟ 😊",
2340
+ parse_mode=ParseMode.HTML
2341
+ )
2342
+ return
2343
+
2344
+ # If it's a correction or a normal name entry
2345
+ name = text
2346
+ if "اسمي" in text:
2347
+ name = text.split("اسمي")[-1].strip().replace("هو", "").strip()
2348
+
2349
+ context.user_data["onboarding_name"] = name
2350
+ _onboarding_step[chat_id] = 2
2351
+ await update.message.reply_text(
2352
+ f"عاشت الأسامي يا <b>{name}</b>! ✨\n\n"
2353
+ "حتى أقدر أساعدك بشكل أفضل، شنو هي لغات البرمجة أو المواضيع التقنية اللي تهمك؟ "
2354
+ "(مثلاً: Python, React, AI, Cybersecurity...)",
2355
+ parse_mode=ParseMode.HTML
2356
+ )
2357
+ elif step == 2:
2358
+ if is_correction:
2359
+ # User is correcting their name from step 1
2360
+ new_name = text.split("اسمي")[-1].strip().replace("هو", "").strip()
2361
+ context.user_data["onboarding_name"] = new_name
2362
+ await update.message.reply_text(f"تمام، اعتذر! تم تعديل الاسم إلى <b>{new_name}</b>. 😊\n\nهسه كلي شنو اهتماماتك التقنية؟", parse_mode=ParseMode.HTML)
2363
+ return
2364
+
2365
+ name = context.user_data.get("onboarding_name", user.first_name)
2366
+ prefs = {"name": name, "interests": text}
2367
+ from models.chat_models import UserMemory
2368
+ new_memory = UserMemory(
2369
+ user_id=user.id,
2370
+ chat_id=chat_id,
2371
+ preferences=prefs,
2372
+ knowledge={},
2373
+ created_at=time.time(),
2374
+ updated_at=time.time()
2375
+ )
2376
+ storage.save_memory(new_memory)
2377
+ _onboarding_step.pop(chat_id, None)
2378
+
2379
+ await update.message.reply_text(
2380
+ f"تم الحفظ! ✅ شكراً الك يا <b>{name}</b>.\n\n"
2381
+ "هسه صار عندي فكرة عن تفضيلاتك وراح أحاول أركز عليها بإجاباتي.\n"
2382
+ "<b>BMO جاهز للانطلاق! 🚀</b>",
2383
+ parse_mode=ParseMode.HTML,
2384
+ reply_markup=MAIN_MENU_KEYBOARD
2385
+ )
2386
+ return
2387
+
2388
+ storage.add_message(chat_id, "user", text, interface='telegram')
2389
+ session.increment_message_counter()
2390
+
2391
+ # ── Handle title-waiting mode ──
2392
+ if chat_id in _awaiting_title:
2393
+ _awaiting_title.discard(chat_id)
2394
+ session.set_title(_generate_title(text))
2395
+ storage.save_session(session)
2396
+ await update.message.reply_text(
2397
+ f"✅ <b>Title set:</b> {session.title}",
2398
+ parse_mode=ParseMode.HTML,
2399
+ )
2400
+ return
2401
+
2402
+ # ── Fast-path: casual greetings (skip LLM, respond instantly) ──
2403
+ lower_text = text.lower().strip()
2404
+ greeting_patterns = [
2405
+ "hi", "hey", "hello", "hola", "salut", "مرحبا", "هلو", "هاي",
2406
+ "how are you", "how r u", "how do you do", "how's it going",
2407
+ "how is it going", "whats up", "what's up", "sup", "yo",
2408
+ "good morning", "good evening", "good night", "good afternoon",
2409
+ "صباح الخير", "مساء الخير", "شلونك", "شلون", "شخبار", "اخبارك",
2410
+ ]
2411
+ is_greeting = any(g in lower_text for g in greeting_patterns)
2412
+ is_very_short = len(text.strip()) <= 15 and is_greeting
2413
+
2414
+ if is_very_short:
2415
+ import random
2416
+ name = context.user_data.get("onboarding_name", user.first_name or "bro")
2417
+ responses = [
2418
+ f"Hey {name}! 👋 All good here, ready to code! What are we building today?",
2419
+ f"Hi {name}! 😊 Doing great, thanks for asking! Need help with anything?",
2420
+ f"Hey! 👋 I'm good and ready to roll. What's on your mind?",
2421
+ f"Hello {name}! ✨ All systems go — what can I help you with?",
2422
+ f"Hi there! 🤖 Running smooth. What's the mission today?",
2423
+ ]
2424
+ response = random.choice(responses)
2425
+ stop_event.set()
2426
+ try:
2427
+ await poller_task
2428
+ except Exception:
2429
+ pass
2430
+ await _send_chunks(update, context, waiting_msg, response)
2431
+ storage.add_message(chat_id, "assistant", response, interface='telegram')
2432
+ asyncio.create_task(_check_message_counter_summary(session, context.bot, chat_id))
2433
+ return
2434
+
2435
+ # ── Thinking Phase ──
2436
+ # Load Context for Continuity (Last 5 messages only — more causes loop confusion)
2437
+ history_messages = session.get_context_text(max_messages=5)
2438
+
2439
+ # Custom Persona injection
2440
+ active_agent = session.metadata.get("active_agent", "default")
2441
+ agent_info = None
2442
+ if active_agent.startswith("custom_"):
2443
+ try:
2444
+ agent_id = int(active_agent.split("_")[1])
2445
+ agent_info = storage.get_custom_agent_by_id(agent_id)
2446
+ except: pass
2447
+
2448
+ query_with_context = f"[Message Source: TELEGRAM]\nPrevious context for continuity:\n{history_messages}\n\nLatest User Message: {text}"
2449
+ if agent_info:
2450
+ query_with_context = f"[CUSTOM AGENT SYSTEM PROMPT: {agent_info['system_prompt']}]\n\n{query_with_context}"
2451
+
2452
+ # Prepare request
2453
+ active_mode = session.metadata.get("active_mode", "execute")
2454
+
2455
+ async def _process():
2456
+ try:
2457
+ response = await opencode_client.send_query(
2458
+ query=query_with_context,
2459
+ session_id=opencode_sid,
2460
+ provider_id=provider_id,
2461
+ model_id=model_id,
2462
+ active_mode=active_mode,
2463
+ active_agent=active_agent,
2464
+ chat_id=chat_id,
2465
+ session_uuid=session.session_id
2466
+ )
2467
+ # Update session ID if it was new
2468
+ if opencode_client.last_session_id:
2469
+ session.metadata["opencode_session_id"] = opencode_client.last_session_id
2470
+ storage.save_session(session)
2471
+
2472
+ storage.add_message(chat_id, "assistant", response, interface='telegram')
2473
+
2474
+ stop_event.set()
2475
+ await poller_task
2476
+ await _send_chunks(update, context, waiting_msg, response)
2477
+ asyncio.create_task(_check_message_counter_summary(session, context.bot, chat_id))
2478
+ except asyncio.CancelledError:
2479
+ stop_event.set()
2480
+ await poller_task
2481
+ logger.info("Task cancelled for chat %s", chat_id)
2482
+ except Exception as e:
2483
+ stop_event.set()
2484
+ await poller_task
2485
+ logger.error("Processing error: %s", e)
2486
+ await waiting_msg.edit_text(f"❌ Error: {str(e)}")
2487
+
2488
+ task = asyncio.create_task(_process())
2489
+ _running_tasks[chat_id] = task
2490
+
2491
+
2492
+ async def handle_agents(update: Update, context: ContextTypes.DEFAULT_TYPE):
2493
+ """Shows the Agent/Persona selection menu."""
2494
+ chat_id = update.effective_chat.id
2495
+ session = storage.load_session(chat_id)
2496
+ active_agent = session.metadata.get("active_agent", "default")
2497
+
2498
+ text = (
2499
+ "🎭 <b>BMO Agents Factory</b>\n\n"
2500
+ "Welcome to the Agent system. You can choose a pre-defined persona, "
2501
+ "load one of your custom-made agents, or create a brand new one.\n\n"
2502
+ f"📍 <b>Active Agent:</b> <code>{active_agent}</code>"
2503
+ )
2504
+
2505
+ kb = [
2506
+ [InlineKeyboardButton("🆕 Create Agent (Manual)", callback_data="agent_start_manual")],
2507
+ [InlineKeyboardButton("🤖 Create Agent (AI-Generated)", callback_data="agent_start_ai")],
2508
+ [InlineKeyboardButton("👤 My Custom Agents", callback_data="agent_list_custom")],
2509
+ [InlineKeyboardButton("📄 The Document Persona", callback_data="agent_set:document")],
2510
+ [InlineKeyboardButton("🦾 Reset to BMO Default", callback_data="agent_set:default")],
2511
+ ]
2512
+
2513
+ await update.message.reply_text(
2514
+ text,
2515
+ reply_markup=InlineKeyboardMarkup(kb),
2516
+ parse_mode=ParseMode.HTML
2517
+ )
2518
+
2519
+ async def agent_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
2520
+ """Handles agent selection and creation starts."""
2521
+ query = update.callback_query
2522
+ await query.answer()
2523
+ chat_id = query.message.chat_id
2524
+ data = query.data
2525
+
2526
+ if data.startswith("agent_set:"):
2527
+ persona_id = data.split(":", 1)[1]
2528
+ session = storage.load_session(chat_id)
2529
+ session.metadata["active_agent"] = persona_id
2530
+ storage.save_session(session)
2531
+ await query.edit_message_text(f"✅ <b>Agent Switched:</b> {persona_id}", parse_mode=ParseMode.HTML)
2532
+
2533
+ elif data == "agent_start_manual":
2534
+ _agent_creation[chat_id] = {"step": "name", "mode": "manual"}
2535
+ await query.edit_message_text("📝 <b>Manual Creation</b>\n\nPlease enter a **Name** for your new agent:", parse_mode=ParseMode.HTML)
2536
+
2537
+ elif data == "agent_start_ai":
2538
+ _agent_creation[chat_id] = {"step": "desc", "mode": "ai"}
2539
+ await query.edit_message_text("🤖 <b>AI Creation</b>\n\nDescribe the personality or role you want BMO to take (e.g. 'A pirate coder' or 'A strict lawyer'):", parse_mode=ParseMode.HTML)
2540
+
2541
+ elif data == "agent_list_custom":
2542
+ agents = storage.get_custom_agents(chat_id)
2543
+ if not agents:
2544
+ await query.edit_message_text("📭 You haven't created any custom agents yet.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Back", callback_data="agent_back")]]))
2545
+ return
2546
+ kb = []
2547
+ for a in agents:
2548
+ kb.append([InlineKeyboardButton(f"👤 {a['name']}", callback_data=f"agent_set:custom_{a['agent_id']}")])
2549
+ kb.append([InlineKeyboardButton("⬅️ Back", callback_data="agent_back")])
2550
+ await query.edit_message_text("👤 <b>Your Custom Agents</b>\n\nSelect an agent to activate it:", reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
2551
+
2552
+ elif data == "agent_back":
2553
+ # Simulate handle_agents update
2554
+ session = storage.load_session(chat_id)
2555
+ active_agent = session.metadata.get("active_agent", "default")
2556
+ kb = [
2557
+ [InlineKeyboardButton("🆕 Create Agent (Manual)", callback_data="agent_start_manual")],
2558
+ [InlineKeyboardButton("🤖 Create Agent (AI-Generated)", callback_data="agent_start_ai")],
2559
+ [InlineKeyboardButton("👤 My Custom Agents", callback_data="agent_list_custom")],
2560
+ [InlineKeyboardButton("📄 The Document Persona", callback_data="agent_set:document")],
2561
+ [InlineKeyboardButton("🦾 Reset to BMO Default", callback_data="agent_set:default")],
2562
+ ]
2563
+ await query.edit_message_text(f"🎭 <b>BMO Agents Factory</b>\n\nActive: {active_agent}", reply_markup=InlineKeyboardMarkup(kb), parse_mode=ParseMode.HTML)
2564
+
2565
+
2566
+ async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
2567
+ """Handles documents and photos."""
2568
+ chat_id = update.effective_chat.id
2569
+ user = update.effective_user
2570
+
2571
+ if not _is_authorized(user.id):
2572
+ return
2573
+
2574
+ # Download file
2575
+ msg = update.message
2576
+ file_obj = None
2577
+ file_name = "image.jpg"
2578
+ mime_type = "image/jpeg"
2579
+
2580
+ if msg.document:
2581
+ file_obj = await msg.document.get_file()
2582
+ file_name = msg.document.file_name
2583
+ mime_type = msg.document.mime_type
2584
+ elif msg.photo:
2585
+ file_obj = await msg.photo[-1].get_file()
2586
+
2587
+ if not file_obj:
2588
+ return
2589
+
2590
+ local_path = DOWNLOADS_DIR / file_name
2591
+ await file_obj.download_to_drive(local_path)
2592
+
2593
+ waiting_msg = await update.message.reply_text(
2594
+ f"⏳ <i>Analysing {file_name}...</i>",
2595
+ parse_mode=ParseMode.HTML,
2596
+ reply_markup=CANCEL_KEYBOARD
2597
+ )
2598
+
2599
+ session = _ensure_session(chat_id, user.id, user.username)
2600
+ caption = msg.caption or f"Please analyse this file: {file_name}"
2601
+
2602
+ # Load Context for Continuity (Last 5 messages only)
2603
+ history_messages = session.get_context_text(max_messages=8)
2604
+ query_with_context = f"[Message Source: TELEGRAM]\nPrevious context for continuity:\n{history_messages}\n\nLatest User Message: {caption}"
2605
+
2606
+ storage.add_message(chat_id, "user", f"[File: {file_name}] {caption}", interface='telegram')
2607
+
2608
+ stop_event = asyncio.Event()
2609
+ poller_task = asyncio.create_task(
2610
+ _status_poller(
2611
+ context.bot,
2612
+ chat_id,
2613
+ waiting_msg.message_id,
2614
+ opencode_client,
2615
+ session.metadata.get("opencode_session_id"),
2616
+ stop_event,
2617
+ )
2618
+ )
2619
+
2620
+ provider_id, model_id = _get_current_model(chat_id, session)
2621
+ active_mode = session.metadata.get("active_mode", "execute")
2622
+ provider_env = storage.get_provider_keys(provider_id) if provider_id else None
2623
+
2624
+ async def _process_file():
2625
+ try:
2626
+ response = await opencode_client.send_query(
2627
+ query_with_context,
2628
+ session_id=session.metadata.get("opencode_session_id"),
2629
+ files=[{"path": str(local_path), "mime": mime_type}],
2630
+ provider_id=provider_id,
2631
+ model_id=model_id,
2632
+ active_mode=active_mode,
2633
+ provider_env=provider_env,
2634
+ chat_id=chat_id
2635
+ )
2636
+ if opencode_client.last_session_id:
2637
+ session.metadata["opencode_session_id"] = opencode_client.last_session_id
2638
+ storage.save_session(session)
2639
+
2640
+ storage.add_message(chat_id, "assistant", response, interface='telegram')
2641
+
2642
+ stop_event.set()
2643
+ await poller_task
2644
+ await _send_chunks(update, context, waiting_msg, response)
2645
+ # _schedule_auto_summary(chat_id, session, context.bot)
2646
+ except asyncio.CancelledError:
2647
+ stop_event.set()
2648
+ await poller_task
2649
+ except Exception as e:
2650
+ stop_event.set()
2651
+ await poller_task
2652
+ await waiting_msg.edit_text(f"❌ Error: {str(e)}")
2653
+
2654
+ task = asyncio.create_task(_process_file())
2655
+ _running_tasks[chat_id] = task
2656
+
2657
+
2658
+ async def handle_system_reload(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
2659
+ """Gracefully exits the process. start_BMO.bat will restart it."""
2660
+ # Send confirmation message
2661
+ await update.message.reply_text("☢️ <b>System Reload Initiated...</b>\nBot will be back online in 5 seconds.", parse_mode=ParseMode.HTML)
2662
+ logger.info("System reload requested by user %s", update.effective_user.id)
2663
+
2664
+ # Send notification to user
2665
+ try:
2666
+ from telegram import Bot
2667
+ bot = Bot(token=os.getenv("TELEGRAM_TOKEN", ""))
2668
+ await bot.send_message(
2669
+ chat_id=OWNER_ID,
2670
+ text="🔄 <b>BMO is restarting with new buttons!</b>\n\nCheck your keyboard - new cleaner layout is ready!",
2671
+ parse_mode=ParseMode.HTML
2672
+ )
2673
+ except Exception as e:
2674
+ logger.error(f"Could not send notification: {e}")
2675
+
2676
+ await asyncio.sleep(2)
2677
+ os._exit(0)
2678
+
2679
+
2680
+ async def handle_session_summary(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
2681
+ """Generates a summary and automatically updates the session title."""
2682
+ chat_id = update.effective_chat.id
2683
+ session = storage.load_session(chat_id)
2684
+ if not session or not session.messages:
2685
+ await update.message.reply_text("⚠️ No messages in this session to summarise.")
2686
+ return
2687
+
2688
+ waiting_msg = await update.message.reply_text("📝 <i>Analysing history and re-titling session...</i>", parse_mode=ParseMode.HTML)
2689
+
2690
+ try:
2691
+ provider_id, model_id = _get_current_model(chat_id, session)
2692
+ history_text = session.get_context_text(max_messages=50)
2693
+
2694
+ # Smart Prompt for Summary + Title
2695
+ prompt = (
2696
+ f"Based on this history:\n{history_text}\n\n"
2697
+ "1. Provide a concise summary of the work done in bold Telegram HTML.\n"
2698
+ "2. Suggest a short 3-4 word title for this session.\n\n"
2699
+ "Format your response as:\nSUMMARY: [summary text]\nTITLE: [suggested title]"
2700
+ )
2701
+
2702
+ response = await opencode_client.send_query(
2703
+ prompt,
2704
+ session_id=session.metadata.get("opencode_session_id"),
2705
+ provider_id=provider_id,
2706
+ model_id=model_id,
2707
+ active_mode="ask",
2708
+ chat_id=chat_id
2709
+ )
2710
+
2711
+ # Parse response
2712
+ summary_text = "No summary available."
2713
+ new_title = "Untitled Session"
2714
+
2715
+ if "SUMMARY:" in response and "TITLE:" in response:
2716
+ parts = response.split("TITLE:")
2717
+ summary_text = parts[0].replace("SUMMARY:", "").strip()
2718
+ new_title = parts[1].strip()
2719
+ else:
2720
+ summary_text = response
2721
+
2722
+ # Update DB
2723
+ session.set_summary(summary_text)
2724
+ session.set_title(new_title)
2725
+ storage.save_session(session)
2726
+
2727
+ await _send_safe(
2728
+ lambda t, pm=None: waiting_msg.edit_text(t, parse_mode=pm),
2729
+ f"📝 <b>Session Summary:</b>\n\n{summary_text}\n\n"
2730
+ f"🏷️ <b>New Title:</b> {new_title}"
2731
+ )
2732
+ except Exception as e:
2733
+ logger.error("Summary/Title error: %s", e)
2734
+ await waiting_msg.edit_text(f"❌ Failed to process summary: {str(e)}")
2735
+
2736
+
2737
+ async def handle_list_sessions(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
2738
+ """Show all saved sessions and let user pick one."""
2739
+ chat_id = update.effective_chat.id
2740
+ sessions = storage.list_chat_sessions(chat_id)
2741
+
2742
+ if not sessions:
2743
+ await update.message.reply_text("📋 <b>No saved sessions.</b>", parse_mode=ParseMode.HTML)
2744
+ return
2745
+
2746
+ text = "📋 <b>Your Sessions</b> — tap to switch:\n"
2747
+ keyboard = []
2748
+ for s in sessions:
2749
+ title = s["title"] or s["session_id"][:8]
2750
+ label = f"{'✅ ' if s['is_active'] else ''}{title} ({s['msg_count']} msgs)"
2751
+ keyboard.append([InlineKeyboardButton(label, callback_data=f"session:{s['session_id']}")])
2752
+
2753
+ await update.message.reply_text(
2754
+ text,
2755
+ reply_markup=InlineKeyboardMarkup(keyboard),
2756
+ parse_mode=ParseMode.HTML,
2757
+ )
2758
+
2759
+
2760
+
2761
+