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/__init__.py +1 -0
- handlers/commands.py +943 -0
- handlers/messages.py +482 -0
- opencode/__init__.py +8 -0
- opencode/client.py +443 -0
- opencode/server.py +144 -0
- sessions/__init__.py +1 -0
- sessions/manager.py +342 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/METADATA +156 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/RECORD +16 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/WHEEL +5 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/entry_points.txt +2 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +1 -0
- utils/formatting.py +218 -0
- utils/security.py +115 -0
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 <1-5> — 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><id></code> — Switch to a different session\n"
|
|
63
|
+
"/model <code><name></code> — Change AI model\n"
|
|
64
|
+
"/models — List all available models\n"
|
|
65
|
+
"/mode <code><plan|build></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 <id> 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><session_id></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><provider/model></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><plan|build></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 <number></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 <1-5></code> to change!)")
|
|
863
|
+
lines.append("👉 <i>To switch, type:</i> <code>/project <number></code> or <code>/project <name></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 <1-5></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}")
|