@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.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- 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
|
+
|