telegram-opencode-bridge-bot 0.1.0__py3-none-any.whl

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.
handlers/commands.py ADDED
@@ -0,0 +1,943 @@
1
+ """
2
+ Telegram command handlers for the OpenCode bot.
3
+
4
+ Handles all slash commands: /start, /help, /new, /sessions, /switch,
5
+ /model, /share, /status, /mode.
6
+ """
7
+
8
+ import html
9
+ import logging
10
+
11
+ from telegram import Update, BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
12
+ from telegram.ext import ContextTypes
13
+ from telegram.constants import ChatAction
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ # ──────────────────────────────────────────────
19
+ # Command: /start
20
+ # ──────────────────────────────────────────────
21
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
22
+ """Welcome message and initial session creation."""
23
+ user = update.effective_user
24
+ bot_data = context.bot_data
25
+
26
+ session_mgr = bot_data["session_manager"]
27
+ oc_client = bot_data["opencode_client"]
28
+
29
+ # Check if OpenCode is reachable
30
+ oc_available = await oc_client.is_available()
31
+ status_icon = "🟢" if oc_available else "🔴"
32
+
33
+ welcome = (
34
+ f"👋 <b>Welcome, {html.escape(user.first_name)}!</b>\n\n"
35
+ f"I'm your bridge to <b>OpenCode</b> — an AI coding agent "
36
+ f"running on your machine.\n\n"
37
+ f"OpenCode Server: {status_icon} {'Connected' if oc_available else 'Disconnected (make sure `opencode serve` is running)'}\n\n"
38
+ f"<b>How to use:</b>\n"
39
+ f"Just send me any coding question or instruction, and I'll "
40
+ f"route it to OpenCode. Your conversation is persistent — "
41
+ f"follow-up messages keep context.\n\n"
42
+ f"Type /help to see all commands."
43
+ )
44
+ await update.message.reply_text(welcome, parse_mode="HTML")
45
+
46
+
47
+ # ──────────────────────────────────────────────
48
+ # Command: /help
49
+ # ──────────────────────────────────────────────
50
+ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
51
+ """Show all available commands."""
52
+ help_text = (
53
+ "/start — Welcome message & connection check\n"
54
+ "/help — Show this help message\n"
55
+ "/new — Start a fresh conversation (clears current session)\n"
56
+ "/stop — Stop/abort the current active task\n"
57
+ "/project — View & switch active project directories\n"
58
+ "/project depth &lt;1-5&gt; — Configure recursive subfolder scan depth\n"
59
+ "/enable — Enable live tool call & progress streaming\n"
60
+ "/disable — Disable live progress streaming\n"
61
+ "/sessions — List your recent sessions\n"
62
+ "/switch <code>&lt;id&gt;</code> — Switch to a different session\n"
63
+ "/model <code>&lt;name&gt;</code> — Change AI model\n"
64
+ "/models — List all available models\n"
65
+ "/mode <code>&lt;plan|build&gt;</code> — Toggle plan/build mode\n"
66
+ "/share — Share current session (get public URL)\n"
67
+ "/status — Show bot & connection status\n"
68
+ "/id — Show your Telegram user ID\n\n"
69
+ "<b>💡 Tips:</b>\n"
70
+ "• Just type normally to chat with OpenCode\n"
71
+ "• Use <code>@filename</code> in prompts to reference files\n"
72
+ "• Sessions persist — follow-up messages keep context\n"
73
+ "• Use /new to start fresh when switching topics"
74
+ )
75
+ await update.message.reply_text(help_text, parse_mode="HTML")
76
+
77
+
78
+ # ──────────────────────────────────────────────
79
+ # Command: /new
80
+ # ──────────────────────────────────────────────
81
+ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
82
+ """Clear current session and start fresh."""
83
+ user_id = update.effective_user.id
84
+ session_mgr = context.bot_data["session_manager"]
85
+
86
+ await session_mgr.clear_session(user_id)
87
+
88
+ await update.message.reply_text(
89
+ "🔄 <b>Session cleared!</b>\n\n"
90
+ "Send your next message to start a fresh conversation.",
91
+ parse_mode="HTML",
92
+ )
93
+
94
+
95
+ # ──────────────────────────────────────────────
96
+ # Command: /sessions
97
+ # ──────────────────────────────────────────────
98
+ async def sessions_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
99
+ """List recent sessions for this user in their active workspace.
100
+
101
+ Uses the OpenCode server as the source of truth. Sessions are filtered
102
+ by matching the server's `directory` field to the user's currently
103
+ active workspace, exactly like the OpenCode GUI does.
104
+ """
105
+ import os
106
+ from utils.formatting import format_session_info
107
+
108
+ user_id = update.effective_user.id
109
+ session_mgr = context.bot_data["session_manager"]
110
+ oc_client = context.bot_data["opencode_client"]
111
+ config = context.bot_data["config"]
112
+
113
+ # Ensure OpenCode server is running dynamically
114
+ from handlers.messages import ensure_server_running
115
+ if not await ensure_server_running(update, context, user_id):
116
+ return
117
+
118
+ # Resolve the user's current workspace directory
119
+ base_dir = os.path.abspath(config.opencode_work_dir)
120
+ current_dir = await session_mgr.get_user_work_dir(user_id, base_dir)
121
+ current_dir = os.path.abspath(current_dir)
122
+
123
+ def norm(p):
124
+ if not p:
125
+ return ""
126
+ return os.path.normcase(os.path.normpath(os.path.abspath(p)))
127
+
128
+ current_dir_norm = norm(current_dir)
129
+
130
+ # -- 1. Fetch ALL sessions from the server ---------
131
+ server_sessions = []
132
+ server_session_ids = set()
133
+ try:
134
+ server_sessions = await oc_client.list_sessions()
135
+ server_session_ids = {s.get("id") for s in server_sessions if s.get("id")}
136
+ except Exception as e:
137
+ logger.warning(f"Could not fetch sessions from server: {e}")
138
+ await update.message.reply_text(
139
+ "⚠️ Could not reach the OpenCode server to list sessions.\n"
140
+ "Make sure <code>opencode serve</code> is running.",
141
+ parse_mode="HTML",
142
+ )
143
+ return
144
+
145
+ # -- 2. Prune local DB: delete any sessions not on the server --
146
+ local_sessions = await session_mgr.list_user_sessions(user_id)
147
+ local_ids = [s.get("session_id") for s in local_sessions]
148
+ stale_ids = [sid for sid in local_ids if sid not in server_session_ids]
149
+ if stale_ids:
150
+ for sid in stale_ids:
151
+ try:
152
+ await session_mgr._db.execute(
153
+ "DELETE FROM sessions WHERE user_id = ? AND opencode_session_id = ?",
154
+ (user_id, sid)
155
+ )
156
+ except Exception as e:
157
+ logger.error(f"Error pruning session {sid}: {e}")
158
+ await session_mgr._db.commit()
159
+
160
+ # Clear active session cache if it was pruned
161
+ active_sid = await session_mgr.get_active_session(user_id)
162
+ if active_sid in stale_ids:
163
+ if user_id in session_mgr._active_sessions:
164
+ del session_mgr._active_sessions[user_id]
165
+
166
+ # -- 3. Filter server sessions to the current workspace --
167
+ workspace_sessions = []
168
+ for s in server_sessions:
169
+ s_dir = s.get("directory", "")
170
+ if norm(s_dir) == current_dir_norm:
171
+ workspace_sessions.append(s)
172
+
173
+ if not workspace_sessions:
174
+ folder_name = os.path.basename(current_dir) or "Root"
175
+ await update.message.reply_text(
176
+ f"📭 No sessions found in project <b>{html.escape(folder_name)}</b>.\n"
177
+ f"Send a message to start one!",
178
+ parse_mode="HTML",
179
+ )
180
+ return
181
+
182
+ # -- 4. Build the display list ----------------------
183
+ workspace_sessions.sort(
184
+ key=lambda s: s.get("time", {}).get("updated", 0),
185
+ reverse=True,
186
+ )
187
+
188
+ # Lookup locally-tracked data (message counts, model, etc.)
189
+ refreshed_local = await session_mgr.list_user_sessions(user_id)
190
+ local_map = {ls.get("session_id"): ls for ls in refreshed_local}
191
+ active_sid = await session_mgr.get_active_session(user_id)
192
+
193
+ folder_name = os.path.basename(current_dir) or "Root"
194
+ lines = [f"<b>📋 Sessions in {html.escape(folder_name)}</b>\n"]
195
+
196
+ for s in workspace_sessions:
197
+ s_id = s.get("id", "")
198
+ s_title = s.get("title", "")
199
+ local_info = local_map.get(s_id, {})
200
+
201
+ display = {
202
+ "session_id": s_id,
203
+ "name": s_title,
204
+ "is_active": (s_id == active_sid),
205
+ "message_count": local_info.get("message_count", 0),
206
+ "created_at": "",
207
+ "last_active": "",
208
+ "model": local_info.get("model", ""),
209
+ "mode": local_info.get("mode", "build"),
210
+ }
211
+
212
+ time_obj = s.get("time", {})
213
+ if time_obj.get("created"):
214
+ from datetime import datetime, timezone
215
+ try:
216
+ display["created_at"] = datetime.fromtimestamp(
217
+ time_obj["created"] / 1000, tz=timezone.utc
218
+ ).isoformat()
219
+ except Exception:
220
+ pass
221
+ if time_obj.get("updated"):
222
+ from datetime import datetime, timezone
223
+ try:
224
+ display["last_active"] = datetime.fromtimestamp(
225
+ time_obj["updated"] / 1000, tz=timezone.utc
226
+ ).isoformat()
227
+ except Exception:
228
+ pass
229
+
230
+ lines.append(format_session_info(display))
231
+ lines.append("")
232
+
233
+ # Update local DB title in background
234
+ if s_title and s_id in local_map:
235
+ try:
236
+ await session_mgr._db.execute(
237
+ "UPDATE sessions SET name = ? WHERE opencode_session_id = ?",
238
+ (s_title, s_id)
239
+ )
240
+ await session_mgr._db.commit()
241
+ except Exception:
242
+ pass
243
+
244
+ lines.append("\n<i>Use /switch &lt;id&gt; to switch sessions</i>")
245
+
246
+ await update.message.reply_text("\n".join(lines), parse_mode="HTML")
247
+
248
+
249
+
250
+ # ──────────────────────────────────────────────
251
+ # Command: /switch <session_id>
252
+ # ──────────────────────────────────────────────
253
+ async def switch_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
254
+ """Switch to a different session."""
255
+ user_id = update.effective_user.id
256
+ session_mgr = context.bot_data["session_manager"]
257
+ oc_client = context.bot_data["opencode_client"]
258
+
259
+ # Ensure OpenCode server is running dynamically
260
+ from handlers.messages import ensure_server_running
261
+ if not await ensure_server_running(update, context, user_id):
262
+ return
263
+
264
+ if not context.args:
265
+ await update.message.reply_text(
266
+ "⚠️ Usage: /switch <code>&lt;session_id&gt;</code>\n\n"
267
+ "Use /sessions to see available session IDs.",
268
+ parse_mode="HTML",
269
+ )
270
+ return
271
+
272
+ target_id = context.args[0].strip()
273
+
274
+ # 1. Resolve short ID prefix to full ID if necessary
275
+ resolved_id = None
276
+ server_sessions = []
277
+
278
+ try:
279
+ server_sessions = await oc_client.list_sessions()
280
+ for s in server_sessions:
281
+ s_id = s.get("id", "")
282
+ if s_id.lower().startswith(target_id.lower()):
283
+ resolved_id = s_id
284
+ break
285
+ except Exception as e:
286
+ logger.warning(f"Could not verify session list on server during switch resolution: {e}")
287
+
288
+ # Fallback to local DB if server query failed or returned no matches
289
+ if not resolved_id:
290
+ try:
291
+ local_sessions = await session_mgr.list_user_sessions(user_id)
292
+ for s in local_sessions:
293
+ s_id = s.get("session_id", "")
294
+ if s_id.lower().startswith(target_id.lower()):
295
+ resolved_id = s_id
296
+ break
297
+ except Exception as e:
298
+ logger.warning(f"Could not fetch local sessions during switch resolution: {e}")
299
+
300
+ if not resolved_id:
301
+ resolved_id = target_id
302
+
303
+ # 2. Check if the resolved session exists on the server (if server is online)
304
+ if server_sessions:
305
+ server_session_ids = {s.get("id") for s in server_sessions if s.get("id")}
306
+ if resolved_id not in server_session_ids:
307
+ # Delete it from local DB as it was deleted on the server!
308
+ try:
309
+ await session_mgr._db.execute(
310
+ "DELETE FROM sessions WHERE user_id = ? AND opencode_session_id = ?",
311
+ (user_id, resolved_id)
312
+ )
313
+ await session_mgr._db.commit()
314
+ except Exception:
315
+ pass
316
+
317
+ # Clean active sessions cache if needed
318
+ if user_id in session_mgr._active_sessions and session_mgr._active_sessions[user_id]["session_id"] == resolved_id:
319
+ del session_mgr._active_sessions[user_id]
320
+
321
+ await update.message.reply_text(
322
+ f"❌ Session <code>{html.escape(resolved_id[:8])}</code> has been deleted on the server.\n"
323
+ f"Use /sessions to see your current sessions.",
324
+ parse_mode="HTML",
325
+ )
326
+ return
327
+
328
+ # 3. Switch to the session in local database
329
+ success = await session_mgr.switch_session(user_id, resolved_id)
330
+
331
+ # 4. If not found locally but exists on the server, dynamically register it in local DB and switch!
332
+ if not success and server_sessions:
333
+ server_session_ids = {s.get("id") for s in server_sessions if s.get("id")}
334
+ if resolved_id in server_session_ids:
335
+ # Find the session object
336
+ s_obj = None
337
+ for s in server_sessions:
338
+ if s.get("id") == resolved_id:
339
+ s_obj = s
340
+ break
341
+
342
+ if s_obj:
343
+ s_dir = s_obj.get("directory", "")
344
+ s_title = s_obj.get("title", "")
345
+
346
+ from datetime import datetime
347
+ now_iso = datetime.utcnow().isoformat()
348
+
349
+ # Insert into local DB
350
+ try:
351
+ await session_mgr._db.execute(
352
+ "INSERT INTO sessions (user_id, opencode_session_id, work_dir, name, created_at, last_active, is_active, message_count) "
353
+ "VALUES (?, ?, ?, ?, ?, ?, 0, 0)",
354
+ (user_id, resolved_id, s_dir, s_title, now_iso, now_iso)
355
+ )
356
+ await session_mgr._db.commit()
357
+ # Switch again
358
+ success = await session_mgr.switch_session(user_id, resolved_id)
359
+ except Exception:
360
+ pass
361
+
362
+ if success:
363
+ await update.message.reply_text(
364
+ f"✅ Switched to session <code>{html.escape(resolved_id[:8])}</code>",
365
+ parse_mode="HTML",
366
+ )
367
+ else:
368
+ await update.message.reply_text(
369
+ f"❌ Session <code>{html.escape(resolved_id[:8])}</code> not found.\n"
370
+ f"Use /sessions to see your sessions.",
371
+ parse_mode="HTML",
372
+ )
373
+
374
+
375
+ # ──────────────────────────────────────────────
376
+ # Command: /model <model_name>
377
+ # ──────────────────────────────────────────────
378
+ async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
379
+ """Change the AI model for the current session."""
380
+ user_id = update.effective_user.id
381
+ session_mgr = context.bot_data["session_manager"]
382
+ config = context.bot_data["config"]
383
+
384
+ if not context.args:
385
+ current_info = await session_mgr.get_session_info(user_id)
386
+ current_model = (current_info or {}).get("model", config.opencode_model) or config.opencode_model
387
+
388
+ await update.message.reply_text(
389
+ f"🤖 <b>Current model:</b> <code>{html.escape(current_model)}</code>\n\n"
390
+ f"<b>Usage:</b> /model <code>&lt;provider/model&gt;</code>\n\n"
391
+ f"<b>Examples:</b>\n"
392
+ f" /model anthropic/claude-sonnet-4\n"
393
+ f" /model google/gemini-2.5-pro\n"
394
+ f" /model openai/gpt-4o\n"
395
+ f" /model ollama/llama3",
396
+ parse_mode="HTML",
397
+ )
398
+ return
399
+
400
+ new_model = " ".join(context.args)
401
+ await session_mgr.set_model(user_id, new_model)
402
+
403
+ await update.message.reply_text(
404
+ f"✅ Model changed to <code>{html.escape(new_model)}</code>\n\n"
405
+ f"<i>This applies to your current session.</i>",
406
+ parse_mode="HTML",
407
+ )
408
+
409
+
410
+ # ──────────────────────────────────────────────
411
+ # Command: /mode <plan|build>
412
+ # ──────────────────────────────────────────────
413
+ async def mode_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
414
+ """Switch between plan and build mode."""
415
+ user_id = update.effective_user.id
416
+ session_mgr = context.bot_data["session_manager"]
417
+
418
+ if not context.args:
419
+ current_info = await session_mgr.get_session_info(user_id)
420
+ current_mode = (current_info or {}).get("mode", "build")
421
+
422
+ await update.message.reply_text(
423
+ f"⚙️ <b>Current mode:</b> {current_mode}\n\n"
424
+ f"<b>Usage:</b> /mode <code>&lt;plan|build&gt;</code>\n\n"
425
+ f"• <b>build</b> — OpenCode can read, write, and execute\n"
426
+ f"• <b>plan</b> — Read-only analysis, no file modifications",
427
+ parse_mode="HTML",
428
+ )
429
+ return
430
+
431
+ mode = context.args[0].lower()
432
+ if mode not in ("plan", "build"):
433
+ await update.message.reply_text(
434
+ "❌ Invalid mode. Use <code>plan</code> or <code>build</code>.",
435
+ parse_mode="HTML",
436
+ )
437
+ return
438
+
439
+ await session_mgr.set_mode(user_id, mode)
440
+ emoji = "📋" if mode == "plan" else "🔨"
441
+
442
+ await update.message.reply_text(
443
+ f"{emoji} Mode changed to <b>{mode}</b>",
444
+ parse_mode="HTML",
445
+ )
446
+
447
+
448
+ # ──────────────────────────────────────────────
449
+ # Command: /share
450
+ # ──────────────────────────────────────────────
451
+ async def share_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
452
+ """Share the current session and get a public URL."""
453
+ user_id = update.effective_user.id
454
+ session_mgr = context.bot_data["session_manager"]
455
+ oc_client = context.bot_data["opencode_client"]
456
+
457
+ # Ensure OpenCode server is running dynamically
458
+ from handlers.messages import ensure_server_running
459
+ if not await ensure_server_running(update, context, user_id):
460
+ return
461
+
462
+ session_id = await session_mgr.get_active_session(user_id)
463
+ if not session_id:
464
+ await update.message.reply_text(
465
+ "📭 No active session to share. Send a message first!",
466
+ parse_mode="HTML",
467
+ )
468
+ return
469
+
470
+ await update.message.chat.send_action(ChatAction.TYPING)
471
+
472
+ try:
473
+ share_url = await oc_client.share_session(session_id)
474
+ await update.message.reply_text(
475
+ f"🔗 <b>Session shared!</b>\n\n{html.escape(str(share_url))}",
476
+ parse_mode="HTML",
477
+ )
478
+ except Exception as e:
479
+ logger.error(f"Failed to share session: {e}", exc_info=True)
480
+ await update.message.reply_text(
481
+ f"❌ Failed to share session: {html.escape(str(e))}",
482
+ parse_mode="HTML",
483
+ )
484
+
485
+
486
+ # ──────────────────────────────────────────────
487
+ # Command: /status
488
+ # ──────────────────────────────────────────────
489
+ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
490
+ """Show bot status, connection info, and current session."""
491
+ from utils.formatting import format_status
492
+
493
+ user_id = update.effective_user.id
494
+ session_mgr = context.bot_data["session_manager"]
495
+ oc_client = context.bot_data["opencode_client"]
496
+ config = context.bot_data["config"]
497
+
498
+ oc_available = await oc_client.is_available()
499
+ session_info = await session_mgr.get_session_info(user_id)
500
+
501
+ status_text = format_status(oc_available, session_info, config.opencode_model)
502
+ await update.message.reply_text(status_text, parse_mode="HTML")
503
+
504
+
505
+ # ──────────────────────────────────────────────
506
+ # Command: /id
507
+ # ──────────────────────────────────────────────
508
+ async def id_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
509
+ """Show the user's Telegram ID (useful for adding to AUTHORIZED_USERS)."""
510
+ user = update.effective_user
511
+ await update.message.reply_text(
512
+ f"👤 <b>Your Telegram User ID:</b>\n<code>{user.id}</code>\n\n"
513
+ f"<i>Add this to AUTHORIZED_USERS in your .env file to authorize yourself.</i>",
514
+ parse_mode="HTML",
515
+ )
516
+
517
+
518
+ # ──────────────────────────────────────────────
519
+ # Command: /models
520
+ # ──────────────────────────────────────────────
521
+ async def models_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
522
+ """List all available (connected/active) models on the OpenCode server."""
523
+ user_id = update.effective_user.id
524
+ bot_data = context.bot_data
525
+ oc_client = bot_data["opencode_client"]
526
+
527
+ # Ensure OpenCode server is running dynamically
528
+ from handlers.messages import ensure_server_running
529
+ if not await ensure_server_running(update, context, user_id):
530
+ return
531
+
532
+ await update.message.chat.send_action(ChatAction.TYPING)
533
+
534
+ try:
535
+ data = await oc_client.get_available_models()
536
+ all_providers = data.get("all", [])
537
+ connected = data.get("connected", [])
538
+
539
+ if not all_providers:
540
+ await update.message.reply_text("📭 No models found on the server.", parse_mode="HTML")
541
+ return
542
+
543
+ lines = ["<b>🤖 Available Models on OpenCode</b>\n"]
544
+ connected_count = 0
545
+
546
+ for p in all_providers:
547
+ p_id = p.get("id")
548
+ # Only display models for connected/active providers (e.g. opencode free models, or ones with active API keys like chutes)
549
+ if p_id not in connected:
550
+ continue
551
+
552
+ p_name = p.get("name", p_id)
553
+ models = p.get("models", {})
554
+
555
+ if not models:
556
+ continue
557
+
558
+ connected_count += 1
559
+ lines.append(f"🔌 <b>{html.escape(p_name)}</b> (<code>{html.escape(p_id)}</code>):")
560
+ for m_id, m in models.items():
561
+ m_name = m.get("name", m_id)
562
+ # Provider programmatic path is providerID/modelID
563
+ path = f"{p_id}/{m_id}"
564
+ lines.append(f" • {html.escape(m_name)}\n → <code>/model {html.escape(path)}</code>")
565
+ lines.append("")
566
+
567
+ if connected_count == 0:
568
+ lines.append("<i>No active/connected providers found.</i>\n")
569
+
570
+ lines.append("💡 <b>Tip:</b> To enable additional providers (like <code>chutes</code>, <code>openai</code>, <code>anthropic</code>, etc.), configure their respective API keys in your environment or on the OpenCode server.")
571
+
572
+ response_text = "\n".join(lines)
573
+ from utils.formatting import split_message
574
+ chunks = split_message(response_text, 4000)
575
+ for chunk in chunks:
576
+ await update.message.reply_text(chunk, parse_mode="HTML")
577
+
578
+ except Exception as e:
579
+ logger.error(f"Failed to fetch models: {e}", exc_info=True)
580
+ await update.message.reply_text(
581
+ f"❌ Failed to retrieve models from OpenCode server: {html.escape(str(e))}",
582
+ parse_mode="HTML",
583
+ )
584
+
585
+
586
+ # ──────────────────────────────────────────────
587
+ # Command: /stop
588
+ # ──────────────────────────────────────────────
589
+ async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
590
+ """Stop/abort the active session's running process."""
591
+ user_id = update.effective_user.id
592
+ session_mgr = context.bot_data["session_manager"]
593
+ oc_client = context.bot_data["opencode_client"]
594
+
595
+ # Ensure OpenCode server is running dynamically
596
+ from handlers.messages import ensure_server_running
597
+ if not await ensure_server_running(update, context, user_id):
598
+ return
599
+
600
+ session_id = await session_mgr.get_active_session(user_id)
601
+ if not session_id:
602
+ await update.message.reply_text("📭 No active session is running to stop.", parse_mode="HTML")
603
+ return
604
+
605
+ await update.message.chat.send_action(ChatAction.TYPING)
606
+ try:
607
+ success = await oc_client.abort_session(session_id)
608
+ if success:
609
+ await update.message.reply_text("🛑 <b>Execution aborted successfully!</b>", parse_mode="HTML")
610
+ else:
611
+ await update.message.reply_text("⚠️ No active task was running, or session is already idle.", parse_mode="HTML")
612
+ except Exception as e:
613
+ logger.error(f"Failed to stop session: {e}", exc_info=True)
614
+ await update.message.reply_text(
615
+ f"❌ Failed to stop session: {html.escape(str(e))}",
616
+ parse_mode="HTML",
617
+ )
618
+
619
+
620
+ # ──────────────────────────────────────────────
621
+ # Command: /project
622
+ # ──────────────────────────────────────────────
623
+ async def project_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
624
+ """List project directories or switch to one by number or name."""
625
+ import os
626
+ user_id = update.effective_user.id
627
+ session_mgr = context.bot_data["session_manager"]
628
+ config = context.bot_data["config"]
629
+ oc_client = context.bot_data["opencode_client"]
630
+
631
+ def norm(p):
632
+ if not p:
633
+ return ""
634
+ return os.path.normcase(os.path.normpath(os.path.abspath(p)))
635
+
636
+ # 1. Base directory is the workspace parent folder configured in .env
637
+ base_dir = os.path.abspath(config.opencode_work_dir)
638
+
639
+ # 2. Get user's current working directory and scan settings
640
+ current_dir = await session_mgr.get_user_work_dir(user_id, base_dir)
641
+ current_dir = os.path.abspath(current_dir)
642
+
643
+ user_depth = await session_mgr.get_user_scan_depth(user_id, config.project_scan_depth)
644
+
645
+ # 3. Check if the user is changing the scan depth setting
646
+ if context.args and context.args[0].lower() in ("depth", "level", "levels"):
647
+ if len(context.args) > 1 and context.args[1].isdigit():
648
+ new_depth = int(context.args[1])
649
+ if 1 <= new_depth <= 5: # Limit depth between 1 and 5 for safety and speed
650
+ await session_mgr.set_user_scan_depth(user_id, new_depth)
651
+ await update.message.reply_text(
652
+ f"⚙️ <b>Scan depth updated successfully!</b>\n"
653
+ f"Projects will now be scanned up to <b>{new_depth}</b> level(s) deep inside your parent workspace.\n\n"
654
+ f"Type /project to see the updated list!",
655
+ parse_mode="HTML"
656
+ )
657
+ return
658
+ else:
659
+ await update.message.reply_text(
660
+ "⚠️ Invalid depth level. Please choose a depth level between 1 and 5.",
661
+ parse_mode="HTML"
662
+ )
663
+ return
664
+ else:
665
+ await update.message.reply_text(
666
+ "⚠️ Usage: <code>/project depth &lt;number&gt;</code>\n"
667
+ "Example: <code>/project depth 2</code> (scans up to 2 levels deep)",
668
+ parse_mode="HTML"
669
+ )
670
+ return
671
+
672
+ # 4. Pruned recursive scanning function to list project subfolders
673
+ def scan_projects_recursive(current_dir, parent_dir, max_depth=3, current_depth=1):
674
+ if current_depth > max_depth:
675
+ return []
676
+
677
+ ignore_dirs = {".git", ".venv", "venv", "__pycache__", "node_modules", ".gemini", ".idea", ".vscode", "build", "dist", ".next"}
678
+ found_projects = []
679
+ try:
680
+ for name in sorted(os.listdir(current_dir)):
681
+ if name.startswith(".") or name in ignore_dirs:
682
+ continue
683
+ full_path = os.path.join(current_dir, name)
684
+ if os.path.isdir(full_path):
685
+ # Relpath from parent_dir
686
+ rel_path = os.path.relpath(full_path, parent_dir)
687
+ found_projects.append(rel_path)
688
+ # Recurse down
689
+ found_projects.extend(scan_projects_recursive(full_path, parent_dir, max_depth, current_depth + 1))
690
+ except Exception:
691
+ pass
692
+ return found_projects
693
+
694
+ try:
695
+ if not os.path.exists(base_dir) or not os.path.isdir(base_dir):
696
+ await update.message.reply_text(
697
+ f"❌ <b>Workspace directory does not exist:</b>\n<code>{html.escape(base_dir)}</code>",
698
+ parse_mode="HTML"
699
+ )
700
+ return
701
+
702
+ # Scan subfolders recursively (up to user-configured depth)
703
+ projects = scan_projects_recursive(base_dir, base_dir, max_depth=user_depth)
704
+ except Exception as e:
705
+ logger.error(f"Failed to scan workspace directory {base_dir}: {e}", exc_info=True)
706
+ await update.message.reply_text(
707
+ f"❌ <b>Error scanning projects folder:</b>\n<code>{html.escape(str(e))}</code>",
708
+ parse_mode="HTML"
709
+ )
710
+ return
711
+
712
+ # 5. Check if we received an argument to switch
713
+ if context.args:
714
+ arg = " ".join(context.args).strip()
715
+ target_folder = None
716
+ target_path = None
717
+
718
+ # Check if the user specified the root workspace (0, 'root', or the base dir name)
719
+ base_name = os.path.basename(base_dir)
720
+ if arg == "0" or arg.lower() in ("root", "root workspace", "root workspace root", base_name.lower()):
721
+ target_folder = f"[Root] {base_name}"
722
+ target_path = base_dir
723
+ # Check if the argument is a valid absolute path on the system
724
+ elif os.path.isabs(arg) and os.path.isdir(arg):
725
+ target_path = os.path.abspath(arg)
726
+ target_folder = os.path.basename(target_path) or "Root"
727
+ # Check if argument is a number from the projects list
728
+ elif arg.isdigit():
729
+ idx = int(arg) - 1
730
+ if 0 <= idx < len(projects):
731
+ target_folder = projects[idx].replace('\\', '/')
732
+ target_path = os.path.abspath(os.path.join(base_dir, projects[idx]))
733
+ else:
734
+ await update.message.reply_text(
735
+ f"⚠️ Invalid project number. Choose a number between 1 and {len(projects)}, or 0 for Root Workspace.",
736
+ parse_mode="HTML"
737
+ )
738
+ return
739
+ else:
740
+ # Case-insensitive name match inside subfolders (checks full relative path or leaf folder name)
741
+ for p in projects:
742
+ folder_name = os.path.basename(p)
743
+ p_display = p.replace('\\', '/')
744
+ if p.lower() == arg.lower() or p_display.lower() == arg.lower() or folder_name.lower() == arg.lower():
745
+ target_folder = p_display
746
+ target_path = os.path.abspath(os.path.join(base_dir, p))
747
+ break
748
+
749
+ # If no exact name match, try a partial match inside subfolders
750
+ if not target_path:
751
+ for p in projects:
752
+ folder_name = os.path.basename(p)
753
+ p_display = p.replace('\\', '/')
754
+ if arg.lower() in p.lower() or arg.lower() in folder_name.lower():
755
+ target_folder = p_display
756
+ target_path = os.path.abspath(os.path.join(base_dir, p))
757
+ break
758
+
759
+ if not target_path:
760
+ await update.message.reply_text(
761
+ f"⚠️ Project folder <code>{html.escape(arg)}</code> not found inside your workspace directory.\n\n"
762
+ f"Type /project to see the list of available projects.",
763
+ parse_mode="HTML"
764
+ )
765
+ return
766
+
767
+ # Save to database
768
+ await session_mgr.set_user_work_dir(user_id, target_path)
769
+
770
+ # 6. Dynamic Restart & Isolation Logic:
771
+ # Inform the user that we are switching the project and restarting the OpenCode server to mount the directory.
772
+ status_msg = await update.message.reply_text(
773
+ f"⏳ <b>Switching project to:</b> <code>{html.escape(target_folder)}</code>...\n"
774
+ f"Restarting OpenCode serve backend to physically isolate execution environment...",
775
+ parse_mode="HTML"
776
+ )
777
+
778
+ async def update_status(text):
779
+ try:
780
+ await status_msg.edit_text(text, parse_mode="HTML")
781
+ except Exception:
782
+ await update.message.reply_text(text, parse_mode="HTML")
783
+
784
+ from urllib.parse import urlparse
785
+ try:
786
+ url_parsed = urlparse(config.opencode_server_url)
787
+ hostname = url_parsed.hostname or "127.0.0.1"
788
+ port = url_parsed.port or 8080
789
+ except Exception:
790
+ hostname = "127.0.0.1"
791
+ port = 8080
792
+
793
+ from opencode.server import restart_server
794
+ restart_success = await restart_server(target_path, port=port, hostname=hostname)
795
+
796
+ if not restart_success:
797
+ await update_status(
798
+ f"❌ <b>Failed to restart OpenCode server inside the new project directory.</b>\n\n"
799
+ f"📍 <i>Target Path: {html.escape(target_path)}</i>\n\n"
800
+ f"Please ensure <code>opencode serve</code> can run on port {port} or check bot logs.",
801
+ )
802
+ return
803
+
804
+ context.bot_data["server_started"] = True
805
+
806
+ # Clear the old session from SQLite and active cache
807
+ await session_mgr.clear_session(user_id)
808
+
809
+ # Create a fresh session inside the restarted backend scoped to the target directory
810
+ try:
811
+ result = await oc_client.create_session(directory=target_path)
812
+ if not isinstance(result, dict):
813
+ raise ValueError("Invalid session response from OpenCode server.")
814
+
815
+ session_id = (
816
+ result.get("id")
817
+ or result.get("session_id")
818
+ or result.get("sessionId")
819
+ )
820
+ if not session_id:
821
+ raise ValueError("Response did not contain a session ID.")
822
+
823
+ await session_mgr.set_active_session(user_id, session_id, config.opencode_model, work_dir=target_path)
824
+
825
+ await update_status(
826
+ f"🔄 <b>Switched project to:</b> <code>{html.escape(target_folder)}</code>\n"
827
+ f"🚀 <b>OpenCode server restarted & fresh session prepared!</b>\n\n"
828
+ f"📍 <i>Path: {html.escape(target_path)}</i>\n\n"
829
+ f"Deterministic isolation active. Send your next message to start coding!",
830
+ )
831
+ except Exception as e:
832
+ logger.error(f"Failed to create fresh session after server restart: {e}", exc_info=True)
833
+ await update_status(
834
+ f"🔄 <b>Switched project to:</b> <code>{html.escape(target_folder)}</code>\n"
835
+ f"⚠️ Server restarted successfully, but failed to create a new session: <code>{html.escape(str(e))}</code>\n\n"
836
+ f"📍 <i>Path: {html.escape(target_path)}</i>\n\n"
837
+ f"Try sending a message; the bot will attempt to auto-heal and create a new session.",
838
+ )
839
+ return
840
+
841
+ # 6. Show the list of available projects (no arguments provided)
842
+ lines = [
843
+ "<b>📁 Project Workspace Root:</b>",
844
+ f"<code>{html.escape(base_dir)}</code>\n",
845
+ f"📍 <b>Currently Active Folder:</b>",
846
+ f"<code>{html.escape(current_dir)}</code>\n",
847
+ "<b>📂 Available Projects:</b>"
848
+ ]
849
+
850
+ # Always render Option 0 (the root parent workspace itself!)
851
+ is_root_active = (current_dir == base_dir)
852
+ root_marker = "🔹" if is_root_active else "🏠"
853
+ lines.append(f" 0. {root_marker} <code>[Root Workspace Root]</code>")
854
+
855
+ if projects:
856
+ for i, p in enumerate(projects):
857
+ p_display = p.replace('\\', '/')
858
+ is_active_marker = "🔹" if os.path.abspath(os.path.join(base_dir, p)) == current_dir else "📁"
859
+ lines.append(f" {i+1}. {is_active_marker} <code>{html.escape(p_display)}</code>")
860
+
861
+ lines.append("")
862
+ lines.append(f"⚙️ <i>Recursive Depth:</i> <code>{user_depth} level(s)</code> (Type <code>/project depth &lt;1-5&gt;</code> to change!)")
863
+ lines.append("👉 <i>To switch, type:</i> <code>/project &lt;number&gt;</code> or <code>/project &lt;name&gt;</code>")
864
+ else:
865
+ lines.append(" <i>(No subdirectories found in the workspace root)</i>")
866
+ lines.append(f"\n⚙️ <i>Recursive Depth:</i> <code>{user_depth} level(s)</code> (Type <code>/project depth &lt;1-5&gt;</code> to change!)")
867
+ lines.append("\n💡 <i>Create directories inside your workspace root folder to manage multiple projects!</i>")
868
+
869
+ await update.message.reply_text("\n".join(lines), parse_mode="HTML")
870
+
871
+
872
+ # ──────────────────────────────────────────────
873
+ # Command: /enable
874
+ # ──────────────────────────────────────────────
875
+ async def enable_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
876
+ """Enable real-time tool execution streaming on Telegram."""
877
+ user_id = update.effective_user.id
878
+ session_mgr = context.bot_data["session_manager"]
879
+
880
+ await session_mgr.set_user_streaming(user_id, 1)
881
+
882
+ await update.message.reply_text(
883
+ "🚀 <b>Real-time Streaming Enabled!</b>\n\n"
884
+ "I will now show you what OpenCode is doing (like tool calls, shell commands, and file edits) live on Telegram as it executes!\n\n"
885
+ "Type /disable at any time to turn it off.",
886
+ parse_mode="HTML"
887
+ )
888
+
889
+
890
+ # ──────────────────────────────────────────────
891
+ # Command: /disable
892
+ # ──────────────────────────────────────────────
893
+ async def disable_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
894
+ """Disable real-time tool execution streaming on Telegram."""
895
+ user_id = update.effective_user.id
896
+ session_mgr = context.bot_data["session_manager"]
897
+
898
+ await session_mgr.set_user_streaming(user_id, 0)
899
+
900
+ await update.message.reply_text(
901
+ "⏸️ <b>Real-time Streaming Disabled.</b>\n\n"
902
+ "I will only show you the final outputs from OpenCode.\n\n"
903
+ "Type /enable to turn it back on.",
904
+ parse_mode="HTML"
905
+ )
906
+
907
+
908
+ # ──────────────────────────────────────────────
909
+ # Register bot commands for Telegram menu
910
+ # ──────────────────────────────────────────────
911
+ async def set_bot_commands(app) -> None:
912
+ """Register commands with Telegram so they appear in the bot menu."""
913
+ commands = [
914
+ BotCommand("start", "Welcome & connection check"),
915
+ BotCommand("help", "Show all commands"),
916
+ BotCommand("new", "Start a fresh conversation"),
917
+ BotCommand("stop", "Stop/abort the current active task"),
918
+ BotCommand("project", "View & switch project folders"),
919
+ BotCommand("enable", "Enable live progress streaming"),
920
+ BotCommand("disable", "Disable live progress streaming"),
921
+ BotCommand("sessions", "List your sessions"),
922
+ BotCommand("switch", "Switch to another session"),
923
+ BotCommand("model", "Change AI model"),
924
+ BotCommand("models", "List all available models"),
925
+ BotCommand("mode", "Toggle plan/build mode"),
926
+ BotCommand("share", "Share current session"),
927
+ BotCommand("status", "Bot & connection status"),
928
+ BotCommand("id", "Show your Telegram user ID"),
929
+ ]
930
+ # 1. Set default command list globally
931
+ await app.bot.set_my_commands(commands)
932
+
933
+ # 2. Set explicitly for all private chats (ensures visibility in DMs)
934
+ try:
935
+ await app.bot.set_my_commands(commands, scope=BotCommandScopeAllPrivateChats())
936
+ except Exception as e:
937
+ logger.warning(f"Could not set commands for private chats scope: {e}")
938
+
939
+ # 3. Set explicitly for all group chats (ensures visibility in group discussions)
940
+ try:
941
+ await app.bot.set_my_commands(commands, scope=BotCommandScopeAllGroupChats())
942
+ except Exception as e:
943
+ logger.warning(f"Could not set commands for group chats scope: {e}")