telegram-opencode-bridge-bot 0.1.8__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {telegram_opencode_bridge_bot-0.1.8/telegram_opencode_bridge_bot.egg-info → telegram_opencode_bridge_bot-0.1.10}/PKG-INFO +3 -3
  2. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/README.md +2 -2
  3. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/bot.py +7 -0
  4. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/handlers/commands.py +124 -1
  5. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/handlers/messages.py +249 -235
  6. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/opencode/server.py +55 -35
  7. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/pyproject.toml +1 -1
  8. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10/telegram_opencode_bridge_bot.egg-info}/PKG-INFO +3 -3
  9. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/.env.example +0 -0
  10. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/MANIFEST.in +0 -0
  11. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/config.py +0 -0
  12. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/handlers/__init__.py +0 -0
  13. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/list_session_models.py +0 -0
  14. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/opencode/__init__.py +0 -0
  15. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/opencode/client.py +0 -0
  16. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/requirements.txt +0 -0
  17. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/sessions/__init__.py +0 -0
  18. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/sessions/manager.py +0 -0
  19. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/setup.cfg +0 -0
  20. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/telegram_opencode_bridge_bot.egg-info/SOURCES.txt +0 -0
  21. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/telegram_opencode_bridge_bot.egg-info/dependency_links.txt +0 -0
  22. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/telegram_opencode_bridge_bot.egg-info/entry_points.txt +0 -0
  23. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/telegram_opencode_bridge_bot.egg-info/requires.txt +0 -0
  24. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/telegram_opencode_bridge_bot.egg-info/top_level.txt +0 -0
  25. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/utils/__init__.py +0 -0
  26. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/utils/formatting.py +0 -0
  27. {telegram_opencode_bridge_bot-0.1.8 → telegram_opencode_bridge_bot-0.1.10}/utils/security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telegram-opencode-bridge-bot
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A Telegram bot that bridges messages directly to OpenCode — an AI coding agent running on your machine
5
5
  Author-email: MaheshNagabhairava <maheshnagabhirava12345@gmail.com>
6
6
  License-Expression: MIT
@@ -58,7 +58,7 @@ A premium, feature-rich Python bot that bridges your Telegram client directly to
58
58
  ### Option A: Standard PyPI Installation
59
59
  Install and run the bot wrapper directly from the terminal:
60
60
  ```bash
61
- pip install telegram-opencode-bridge-bot==0.1.8
61
+ pip install telegram-opencode-bridge-bot==0.1.10
62
62
  telegram-opencode-bot
63
63
  ```
64
64
  > **💡 Tip:** Use the `--env` flag anytime to reconfigure your variables:
@@ -106,7 +106,7 @@ telegram-opencode-bot
106
106
  | **`/enable`** | — | Enables real-time streaming of tool calls, shell executions, and file edits. |
107
107
  | **`/disable`** | — | Disables streaming; the bot will only report the final LLM response. |
108
108
  | **`/share`** | — | Fetches a public, shareable web preview URL of the active conversation. |
109
-
109
+ | **`/update`** | — | Update to a new available version. |
110
110
  ---
111
111
 
112
112
  ## ⚙️ Configuration Variables
@@ -41,7 +41,7 @@ A premium, feature-rich Python bot that bridges your Telegram client directly to
41
41
  ### Option A: Standard PyPI Installation
42
42
  Install and run the bot wrapper directly from the terminal:
43
43
  ```bash
44
- pip install telegram-opencode-bridge-bot==0.1.8
44
+ pip install telegram-opencode-bridge-bot==0.1.10
45
45
  telegram-opencode-bot
46
46
  ```
47
47
  > **💡 Tip:** Use the `--env` flag anytime to reconfigure your variables:
@@ -89,7 +89,7 @@ telegram-opencode-bot
89
89
  | **`/enable`** | — | Enables real-time streaming of tool calls, shell executions, and file edits. |
90
90
  | **`/disable`** | — | Disables streaming; the bot will only report the final LLM response. |
91
91
  | **`/share`** | — | Fetches a public, shareable web preview URL of the active conversation. |
92
-
92
+ | **`/update`** | — | Update to a new available version. |
93
93
  ---
94
94
 
95
95
  ## ⚙️ Configuration Variables
@@ -60,6 +60,7 @@ from handlers.commands import (
60
60
  enable_command,
61
61
  disable_command,
62
62
  history_command,
63
+ update_command,
63
64
  set_bot_commands,
64
65
  callback_handler,
65
66
  )
@@ -211,6 +212,10 @@ def build_authorized_handlers(authorizer: UserAuthorizer, rate_limiter: RateLimi
211
212
  async def _history(update, context):
212
213
  await history_command(update, context)
213
214
 
215
+ @authorized(authorizer, rate_limiter)
216
+ async def _update(update, context):
217
+ await update_command(update, context)
218
+
214
219
  @authorized(authorizer)
215
220
  async def _callback(update, context):
216
221
  await callback_handler(update, context)
@@ -237,6 +242,7 @@ def build_authorized_handlers(authorizer: UserAuthorizer, rate_limiter: RateLimi
237
242
  "enable": _enable,
238
243
  "disable": _disable,
239
244
  "history": _history,
245
+ "update": _update,
240
246
  "plan": _plan,
241
247
  "build": _build,
242
248
  "mode": _mode,
@@ -550,6 +556,7 @@ def main():
550
556
  application.add_handler(CommandHandler("enable", handlers["enable"], block=False))
551
557
  application.add_handler(CommandHandler("disable", handlers["disable"], block=False))
552
558
  application.add_handler(CommandHandler("history", handlers["history"], block=False))
559
+ application.add_handler(CommandHandler("update", handlers["update"], block=False))
553
560
  application.add_handler(CommandHandler("mode", handlers["mode"], block=False))
554
561
  application.add_handler(CommandHandler("plan", handlers["plan"], block=False))
555
562
  application.add_handler(CommandHandler("build", handlers["build"], block=False))
@@ -69,7 +69,8 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
69
69
  "/build — Switch to build mode (read, write, execute)\n"
70
70
  "/share — Share current session (get public URL)\n"
71
71
  "/status — Show bot & connection status\n"
72
- "/id — Show your Telegram user ID\n\n"
72
+ "/id — Show your Telegram user ID\n"
73
+ "/update — Check and install updates\n\n"
73
74
  "<b>💡 Tips:</b>\n"
74
75
  "• Just type normally to chat with OpenCode\n"
75
76
  "• Use <code>@filename</code> in prompts to reference files\n"
@@ -1158,6 +1159,127 @@ async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
1158
1159
  await asyncio.sleep(0.5)
1159
1160
 
1160
1161
 
1162
+ # ──────────────────────────────────────────────
1163
+ # Command: /update
1164
+ # ──────────────────────────────────────────────
1165
+ async def update_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1166
+ """Check for and apply bot updates, then restart the process in-place."""
1167
+ import asyncio
1168
+ import os
1169
+ import sys
1170
+ import html
1171
+ from utils.formatting import format_error
1172
+
1173
+ user_id = update.effective_user.id
1174
+
1175
+ # 1. Send initial status message
1176
+ status_msg = await update.message.reply_text(
1177
+ "⏳ <b>Checking for bot updates...</b>",
1178
+ parse_mode="HTML"
1179
+ )
1180
+
1181
+ async def update_status(text: str):
1182
+ try:
1183
+ await status_msg.edit_text(text, parse_mode="HTML")
1184
+ except Exception:
1185
+ await update.message.reply_text(text, parse_mode="HTML")
1186
+
1187
+ is_git = os.path.exists(".git")
1188
+
1189
+ def run_cmd_sync(command: list[str]) -> tuple[int, str, str]:
1190
+ """Helper to run a command synchronously."""
1191
+ import subprocess
1192
+ proc = subprocess.run(
1193
+ command,
1194
+ shell=False,
1195
+ stdout=subprocess.PIPE,
1196
+ stderr=subprocess.PIPE,
1197
+ text=True
1198
+ )
1199
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
1200
+
1201
+ async def run_cmd(command: list[str]) -> tuple[int, str, str]:
1202
+ """Helper to run a command in a background thread."""
1203
+ return await asyncio.to_thread(run_cmd_sync, command)
1204
+
1205
+ if is_git:
1206
+ await update_status("🔍 <b>Detected Git installation. Fetching remote changes...</b>")
1207
+
1208
+ # 1. Git fetch
1209
+ code, stdout, stderr = await run_cmd(["git", "fetch", "origin"])
1210
+ if code != 0:
1211
+ await update_status(
1212
+ f"❌ <b>Git fetch failed:</b>\n"
1213
+ f"<pre>{html.escape(stderr or stdout)}</pre>"
1214
+ )
1215
+ return
1216
+
1217
+ # 2. Check diff
1218
+ code_local, local_hash, _ = await run_cmd(["git", "rev-parse", "HEAD"])
1219
+ code_remote, remote_hash, _ = await run_cmd(["git", "rev-parse", "@{u}"])
1220
+
1221
+ if code_local != 0 or code_remote != 0:
1222
+ await update_status("❌ <b>Failed to resolve Git commit hashes.</b>")
1223
+ return
1224
+
1225
+ if local_hash == remote_hash:
1226
+ await update_status(
1227
+ f"✨ <b>Your bot is already up to date!</b>\n"
1228
+ f"Commit: <code>{local_hash[:8]}</code>"
1229
+ )
1230
+ return
1231
+
1232
+ await update_status("🔄 <b>New updates found. Pulling latest code changes...</b>")
1233
+
1234
+ # 3. Git pull
1235
+ code_pull, pull_stdout, pull_stderr = await run_cmd(["git", "pull"])
1236
+ if code_pull != 0:
1237
+ await update_status(
1238
+ f"❌ <b>Git pull failed:</b>\n"
1239
+ f"<pre>{html.escape(pull_stderr or pull_stdout)}</pre>"
1240
+ )
1241
+ return
1242
+
1243
+ # 4. Pip install requirements if updated
1244
+ if os.path.exists("requirements.txt"):
1245
+ await update_status("📦 <b>Updating dependencies from requirements.txt...</b>")
1246
+ await run_cmd([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
1247
+
1248
+ else:
1249
+ # Pip package installation
1250
+ await update_status("🔍 <b>Detected Pip installation. Checking PyPI for updates...</b>")
1251
+
1252
+ # Run pip install --upgrade
1253
+ code, stdout, stderr = await run_cmd([sys.executable, "-m", "pip", "install", "--upgrade", "telegram-opencode-bridge-bot"])
1254
+ if code != 0:
1255
+ await update_status(
1256
+ f"❌ <b>Pip upgrade failed:</b>\n"
1257
+ f"<pre>{html.escape(stderr or stdout)}</pre>"
1258
+ )
1259
+ return
1260
+
1261
+ # Check if it was already up to date
1262
+ if "Requirement already satisfied" in stdout and "Successfully installed" not in stdout:
1263
+ await update_status("✨ <b>Your bot is already up to date!</b>")
1264
+ return
1265
+
1266
+ # Trigger restart
1267
+ await update_status("✅ <b>Update complete! Restarting bot in-place...</b>")
1268
+
1269
+ # Wait 1 second to let Telegram send the message completely
1270
+ await asyncio.sleep(1)
1271
+
1272
+ # Close any running connections or servers nicely
1273
+ try:
1274
+ from opencode.server import stop_server
1275
+ await stop_server()
1276
+ except Exception:
1277
+ pass
1278
+
1279
+ # Exec process replacement
1280
+ os.execv(sys.executable, [sys.executable] + sys.argv)
1281
+
1282
+
1161
1283
  # ──────────────────────────────────────────────
1162
1284
  # Register bot commands for Telegram menu
1163
1285
  # ──────────────────────────────────────────────
@@ -1183,6 +1305,7 @@ async def set_bot_commands(app) -> None:
1183
1305
  BotCommand("share", "Share current session"),
1184
1306
  BotCommand("status", "Bot & connection status"),
1185
1307
  BotCommand("id", "Show your Telegram user ID"),
1308
+ BotCommand("update", "Check and install updates"),
1186
1309
  ]
1187
1310
  await app.bot.set_my_commands(commands)
1188
1311
 
@@ -450,7 +450,9 @@ async def _listen_and_stream_events(
450
450
  is_streaming: bool,
451
451
  status_msg_holder = None
452
452
  ):
453
- """Listens to global OpenCode events via SSE and handles tool progress/permission requests."""
453
+ """Listens to global OpenCode events via SSE and handles tool progress/permission requests.
454
+ Includes an automatic reconnect loop with exponential back-off to prevent getting stuck.
455
+ """
454
456
  import aiohttp
455
457
  import json
456
458
  import html
@@ -483,261 +485,273 @@ async def _listen_and_stream_events(
483
485
  except Exception as e:
484
486
  logger.debug(f"Failed to update status message: {e}")
485
487
 
486
- async with aiohttp.ClientSession(read_bufsize=100 * 1024 * 1024) as sse_session:
488
+ retry_delay = 1.0
489
+ while True:
487
490
  try:
488
- async with sse_session.get(url, headers={"Accept": "text/event-stream"}) as resp:
489
- async for line in resp.content:
490
- line_str = line.decode('utf-8').strip()
491
- if not line_str or not line_str.startswith("data:"):
492
- continue
491
+ async with aiohttp.ClientSession(read_bufsize=100 * 1024 * 1024) as sse_session:
492
+ async with sse_session.get(url, headers={"Accept": "text/event-stream"}) as resp:
493
+ # Connection successful, reset retry delay
494
+ retry_delay = 1.0
493
495
 
494
- data_content = line_str[5:].strip()
495
- try:
496
- event_obj = json.loads(data_content)
497
- payload = event_obj.get("payload", {})
498
- if not isinstance(payload, dict):
499
- continue
500
-
501
- properties = payload.get("properties", {})
502
- if not isinstance(properties, dict):
496
+ async for line in resp.content:
497
+ line_str = line.decode('utf-8').strip()
498
+ if not line_str or not line_str.startswith("data:"):
503
499
  continue
504
500
 
505
- event_session_id = (
506
- properties.get("sessionID")
507
- or properties.get("sessionId")
508
- or properties.get("session_id")
509
- or payload.get("sessionID")
510
- or payload.get("sessionId")
511
- or payload.get("session_id")
512
- or ""
513
- )
514
- if event_session_id != session_id:
515
- continue
516
-
517
- event_type = payload.get("type", "")
518
-
519
- # A. Handle Intermediate Assistant Message Completion (Real-time Streaming)
520
- if event_type == "message.updated":
521
- info = properties.get("info", {})
522
- msg_id = info.get("id")
523
- role = info.get("role")
524
- completed = info.get("time", {}).get("completed")
501
+ data_content = line_str[5:].strip()
502
+ try:
503
+ event_obj = json.loads(data_content)
504
+ payload = event_obj.get("payload", {})
505
+ if not isinstance(payload, dict):
506
+ continue
525
507
 
526
- if role == "assistant" and completed:
527
- sent_message_ids = context.user_data.setdefault("sent_message_ids", set())
528
- if msg_id not in sent_message_ids:
529
- sent_message_ids.add(msg_id)
530
- try:
531
- oc_client = context.bot_data["opencode_client"]
532
- messages = await oc_client.list_messages(session_id)
533
- target_msg = next((m for m in messages if m.get("info", {}).get("id") == msg_id), None)
534
- if target_msg:
535
- parts = target_msg.get("parts", [])
536
- content_text = ""
537
- if isinstance(parts, list):
538
- text_parts = [
539
- p.get("text", "")
540
- for p in parts
541
- if isinstance(p, dict) and p.get("type") == "text"
542
- ]
543
- content_text = "".join(text_parts)
544
-
545
- if content_text.strip():
546
- # Delete the old status message at the top
547
- if status_msg_holder and status_msg_holder[0]:
548
- try:
549
- await status_msg_holder[0].delete()
550
- except Exception:
551
- pass
552
- status_msg_holder[0] = None
553
-
554
- from utils.formatting import format_opencode_response, split_message
555
- formatted = format_opencode_response(content_text)
556
- chunks = split_message(formatted, context.bot_data["config"].max_message_length)
557
- for i, chunk in enumerate(chunks):
558
- await update.message.reply_text(
559
- chunk,
560
- parse_mode="HTML",
561
- disable_web_page_preview=True,
562
- )
563
- if i < len(chunks) - 1:
564
- await asyncio.sleep(0.5)
565
-
566
- # Recreate the status indicator at the very bottom
567
- if status_msg_holder:
568
- try:
569
- status_msg_holder[0] = await update.message.reply_text(
570
- last_status_text[0],
571
- parse_mode="HTML"
572
- )
573
- last_update_time[0] = time.time()
574
- except Exception as e:
575
- logger.warning(f"Failed to recreate status message at bottom: {e}")
576
- except Exception as e:
577
- logger.warning(f"Failed to stream intermediate message {msg_id}: {e}")
578
-
579
- # B. Handle Permission Requested Popup (Always Enabled)
580
- elif event_type == "permission.asked":
581
- perm_id = properties.get("id") or properties.get("permissionID") or payload.get("id")
582
- perm_type = properties.get("permission") or properties.get("type") or "execute"
583
- patterns = properties.get("patterns", [])
584
-
585
- if not perm_id:
586
- logger.warning("Received permission.asked event but no permission ID was found.")
508
+ properties = payload.get("properties", {})
509
+ if not isinstance(properties, dict):
587
510
  continue
588
-
589
- # Register pending permission in-memory lookup to avoid Telegram 64-char callback limit
590
- if "pending_permissions" not in context.bot_data:
591
- context.bot_data["pending_permissions"] = {}
592
-
593
- short_key = uuid.uuid4().hex[:8]
594
- context.bot_data["pending_permissions"][short_key] = {
595
- "session_id": session_id,
596
- "permission_id": perm_id
597
- }
598
-
599
- patterns_text = ""
600
- if patterns:
601
- pat_list = "\n".join([f"• <code>{html.escape(str(p))}</code>" for p in patterns])
602
- patterns_text = f"\n<b>Target Resource(s):</b>\n{pat_list}"
603
-
604
- tool_name = ""
605
- tool_info = properties.get("tool", {})
606
- if isinstance(tool_info, dict):
607
- tool_name = tool_info.get("name", "")
608
- if not tool_name:
609
- tool_name = perm_type
610
-
611
- msg = (
612
- f"🛡️ <b>OpenCode Permission Requested</b>\n\n"
613
- f"The agent is asking for confirmation to use the tool <code>{html.escape(tool_name)}</code>.\n"
614
- f"{patterns_text}\n\n"
615
- f"Do you want to allow this operation?"
511
+
512
+ event_session_id = (
513
+ properties.get("sessionID")
514
+ or properties.get("sessionId")
515
+ or properties.get("session_id")
516
+ or payload.get("sessionID")
517
+ or payload.get("sessionId")
518
+ or payload.get("session_id")
519
+ or ""
616
520
  )
521
+ if event_session_id != session_id:
522
+ continue
523
+
524
+ event_type = payload.get("type", "")
525
+
526
+ # A. Handle Intermediate Assistant Message Completion (Real-time Streaming)
527
+ if event_type == "message.updated":
528
+ info = properties.get("info", {})
529
+ msg_id = info.get("id")
530
+ role = info.get("role")
531
+ completed = info.get("time", {}).get("completed")
532
+
533
+ if role == "assistant" and completed:
534
+ sent_message_ids = context.user_data.setdefault("sent_message_ids", set())
535
+ if msg_id not in sent_message_ids:
536
+ sent_message_ids.add(msg_id)
537
+ try:
538
+ oc_client = context.bot_data["opencode_client"]
539
+ messages = await oc_client.list_messages(session_id)
540
+ target_msg = next((m for m in messages if m.get("info", {}).get("id") == msg_id), None)
541
+ if target_msg:
542
+ parts = target_msg.get("parts", [])
543
+ content_text = ""
544
+ if isinstance(parts, list):
545
+ text_parts = [
546
+ p.get("text", "")
547
+ for p in parts
548
+ if isinstance(p, dict) and p.get("type") == "text"
549
+ ]
550
+ content_text = "".join(text_parts)
551
+
552
+ if content_text.strip():
553
+ # Delete the old status message at the top
554
+ if status_msg_holder and status_msg_holder[0]:
555
+ try:
556
+ await status_msg_holder[0].delete()
557
+ except Exception:
558
+ pass
559
+ status_msg_holder[0] = None
560
+
561
+ from utils.formatting import format_opencode_response, split_message
562
+ formatted = format_opencode_response(content_text)
563
+ chunks = split_message(formatted, context.bot_data["config"].max_message_length)
564
+ for i, chunk in enumerate(chunks):
565
+ await update.message.reply_text(
566
+ chunk,
567
+ parse_mode="HTML",
568
+ disable_web_page_preview=True,
569
+ )
570
+ if i < len(chunks) - 1:
571
+ await asyncio.sleep(0.5)
572
+
573
+ # Recreate the status indicator at the very bottom
574
+ if status_msg_holder:
575
+ try:
576
+ status_msg_holder[0] = await update.message.reply_text(
577
+ last_status_text[0],
578
+ parse_mode="HTML"
579
+ )
580
+ last_update_time[0] = time.time()
581
+ except Exception as e:
582
+ logger.warning(f"Failed to recreate status message at bottom: {e}")
583
+ except Exception as e:
584
+ logger.warning(f"Failed to stream intermediate message {msg_id}: {e}")
585
+
586
+ # B. Handle Permission Requested Popup (Always Enabled)
587
+ elif event_type == "permission.asked":
588
+ perm_id = properties.get("id") or properties.get("permissionID") or payload.get("id")
589
+ perm_type = properties.get("permission") or properties.get("type") or "execute"
590
+ patterns = properties.get("patterns", [])
591
+
592
+ if not perm_id:
593
+ logger.warning("Received permission.asked event but no permission ID was found.")
594
+ continue
617
595
 
618
- keyboard = [
619
- [
620
- InlineKeyboardButton("✅ Yes, Allow", callback_data=f"perm:allow:{short_key}"),
621
- InlineKeyboardButton("❌ No, Deny", callback_data=f"perm:deny:{short_key}")
596
+ # Register pending permission in-memory lookup to avoid Telegram 64-char callback limit
597
+ if "pending_permissions" not in context.bot_data:
598
+ context.bot_data["pending_permissions"] = {}
599
+
600
+ short_key = uuid.uuid4().hex[:8]
601
+ context.bot_data["pending_permissions"][short_key] = {
602
+ "session_id": session_id,
603
+ "permission_id": perm_id
604
+ }
605
+
606
+ patterns_text = ""
607
+ if patterns:
608
+ pat_list = "\n".join([f"• <code>{html.escape(str(p))}</code>" for p in patterns])
609
+ patterns_text = f"\n<b>Target Resource(s):</b>\n{pat_list}"
610
+
611
+ tool_name = ""
612
+ tool_info = properties.get("tool", {})
613
+ if isinstance(tool_info, dict):
614
+ tool_name = tool_info.get("name", "")
615
+ if not tool_name:
616
+ tool_name = perm_type
617
+
618
+ msg = (
619
+ f"🛡️ <b>OpenCode Permission Requested</b>\n\n"
620
+ f"The agent is asking for confirmation to use the tool <code>{html.escape(tool_name)}</code>.\n"
621
+ f"{patterns_text}\n\n"
622
+ f"Do you want to allow this operation?"
623
+ )
624
+
625
+ keyboard = [
626
+ [
627
+ InlineKeyboardButton("✅ Yes, Allow", callback_data=f"perm:allow:{short_key}"),
628
+ InlineKeyboardButton("❌ No, Deny", callback_data=f"perm:deny:{short_key}")
629
+ ]
622
630
  ]
623
- ]
624
631
 
625
- await update.message.reply_text(
626
- msg,
627
- parse_mode="HTML",
628
- reply_markup=InlineKeyboardMarkup(keyboard)
629
- )
632
+ await update.message.reply_text(
633
+ msg,
634
+ parse_mode="HTML",
635
+ reply_markup=InlineKeyboardMarkup(keyboard)
636
+ )
630
637
 
631
- # B. Handle Tool Execution Progress
632
- elif event_type == "message.part.updated":
633
- part = properties.get("part", {})
634
- if not isinstance(part, dict):
635
- continue
636
-
637
- part_type = part.get("type", "")
638
- if part_type == "tool":
639
- tool_name = part.get("tool", "unknown")
640
- call_id = part.get("callID", "unknown")
641
- state = part.get("state", {})
642
- if not isinstance(state, dict):
638
+ # B. Handle Tool Execution Progress
639
+ elif event_type == "message.part.updated":
640
+ part = properties.get("part", {})
641
+ if not isinstance(part, dict):
643
642
  continue
644
643
 
645
- status = state.get("status", "")
646
- input_data = state.get("input", {})
647
- output_data = state.get("output", "")
648
- metadata = state.get("metadata", {})
649
- if not isinstance(metadata, dict):
650
- metadata = {}
651
-
652
- # ── 1. Update In-Place Status Message (Always Active) ──
653
- if status in ("pending", "running") and status_msg_holder and status_msg_holder[0]:
654
- status_text = ""
655
- if tool_name == "bash":
656
- cmd = input_data.get("command") or input_data.get("content") or ""
657
- cmd_truncated = truncate(cmd, 60)
658
- status_text = f"💻 <b>Running shell command...</b>\n<code>{html.escape(cmd_truncated)}</code>"
659
- elif tool_name in ("edit", "write", "save"):
660
- path = input_data.get("path") or input_data.get("target") or input_data.get("filepath") or ""
661
- path_truncated = truncate(os.path.basename(path) if path else "", 60)
662
- status_text = f"📝 <b>Modifying file...</b>\n<code>{html.escape(path_truncated)}</code>"
663
- elif tool_name in ("read", "view", "show"):
664
- path = input_data.get("path") or input_data.get("target") or input_data.get("filepath") or ""
665
- path_truncated = truncate(os.path.basename(path) if path else "", 60)
666
- status_text = f"🔍 <b>Reading file...</b>\n<code>{html.escape(path_truncated)}</code>"
667
- elif tool_name in ("webfetch", "websearch", "search"):
668
- query = input_data.get("query") or input_data.get("url") or ""
669
- query_truncated = truncate(query, 60)
670
- status_text = f"🌐 <b>Searching web...</b>\n<code>{html.escape(query_truncated)}</code>"
671
- else:
672
- status_text = f"⚙️ <b>Executing tool <code>{html.escape(tool_name)}</code>...</b>"
644
+ part_type = part.get("type", "")
645
+ if part_type == "tool":
646
+ tool_name = part.get("tool", "unknown")
647
+ call_id = part.get("callID", "unknown")
648
+ state = part.get("state", {})
649
+ if not isinstance(state, dict):
650
+ continue
673
651
 
674
- await update_status(status_text)
675
-
676
- # ── 2. Stream Full Tool Logs (Only if is_streaming is True) ──
677
- if is_streaming:
678
- # 1. Tool Call Started / Running
679
- if status in ("pending", "running") and call_id not in notified_calls:
680
- notified_calls.add(call_id)
681
-
682
- desc = input_data.get("description", "") if isinstance(input_data, dict) else ""
683
- desc_text = f" — <i>\"{html.escape(desc)}\"</i>" if desc else ""
684
-
685
- # Format arguments
686
- arg_lines = []
687
- if isinstance(input_data, dict):
688
- for k, v in input_data.items():
689
- if k not in ("description", "content"):
690
- arg_lines.append(f"<b>{html.escape(str(k))}:</b> {html.escape(truncate(str(v)))}")
691
- args_text = "\n".join(arg_lines)
652
+ status = state.get("status", "")
653
+ input_data = state.get("input", {})
654
+ output_data = state.get("output", "")
655
+ metadata = state.get("metadata", {})
656
+ if not isinstance(metadata, dict):
657
+ metadata = {}
658
+
659
+ # ── 1. Update In-Place Status Message (Always Active) ──
660
+ if status in ("pending", "running") and status_msg_holder and status_msg_holder[0]:
661
+ status_text = ""
662
+ if tool_name == "bash":
663
+ cmd = input_data.get("command") or input_data.get("content") or ""
664
+ cmd_truncated = truncate(cmd, 60)
665
+ status_text = f"💻 <b>Running shell command...</b>\n<code>{html.escape(cmd_truncated)}</code>"
666
+ elif tool_name in ("edit", "write", "save"):
667
+ path = input_data.get("path") or input_data.get("target") or input_data.get("filepath") or ""
668
+ path_truncated = truncate(os.path.basename(path) if path else "", 60)
669
+ status_text = f"📝 <b>Modifying file...</b>\n<code>{html.escape(path_truncated)}</code>"
670
+ elif tool_name in ("read", "view", "show"):
671
+ path = input_data.get("path") or input_data.get("target") or input_data.get("filepath") or ""
672
+ path_truncated = truncate(os.path.basename(path) if path else "", 60)
673
+ status_text = f"🔍 <b>Reading file...</b>\n<code>{html.escape(path_truncated)}</code>"
674
+ elif tool_name in ("webfetch", "websearch", "search"):
675
+ query = input_data.get("query") or input_data.get("url") or ""
676
+ query_truncated = truncate(query, 60)
677
+ status_text = f"🌐 <b>Searching web...</b>\n<code>{html.escape(query_truncated)}</code>"
678
+ else:
679
+ status_text = f"⚙️ <b>Executing tool <code>{html.escape(tool_name)}</code>...</b>"
692
680
 
693
- msg = (
694
- f"🛠️ <b>Calling Tool <code>{html.escape(tool_name)}</code></b>{desc_text}\n"
695
- )
696
- if args_text:
697
- msg += f"{args_text}\n"
698
-
699
- await update.message.reply_text(msg, parse_mode="HTML")
681
+ await update_status(status_text)
700
682
 
701
- # 2. Tool Completed
702
- elif status == "completed" and call_id not in completed_calls:
703
- completed_calls.add(call_id)
704
-
705
- exit_code = metadata.get("exit", 0)
706
- output_cleaned = truncate(str(output_data))
707
-
708
- msg = (
709
- f"✅ <b>Tool <code>{html.escape(tool_name)}</code> Completed</b> (Exit <code>{exit_code}</code>)\n"
710
- )
711
- if output_cleaned.strip():
712
- msg += f"<pre>{html.escape(output_cleaned)}</pre>"
713
- else:
714
- msg += f"<i>(No output returned)</i>"
683
+ # ── 2. Stream Full Tool Logs (Only if is_streaming is True) ──
684
+ if is_streaming:
685
+ # 1. Tool Call Started / Running
686
+ if status in ("pending", "running") and call_id not in notified_calls:
687
+ notified_calls.add(call_id)
715
688
 
716
- await update.message.reply_text(msg, parse_mode="HTML")
717
-
718
- # 3. Tool Failed
719
- elif status in ("failed", "error") and call_id not in completed_calls:
720
- completed_calls.add(call_id)
721
-
722
- output_cleaned = truncate(str(output_data))
723
-
724
- msg = (
725
- f"❌ <b>Tool <code>{html.escape(tool_name)}</code> Failed</b>\n"
726
- )
727
- if output_cleaned.strip():
728
- msg += f"<pre>{html.escape(output_cleaned)}</pre>"
729
- else:
730
- msg += f"<i>(No error description returned)</i>"
689
+ desc = input_data.get("description", "") if isinstance(input_data, dict) else ""
690
+ desc_text = f" — <i>\"{html.escape(desc)}\"</i>" if desc else ""
731
691
 
732
- await update.message.reply_text(msg, parse_mode="HTML")
733
-
734
- except Exception as e:
735
- logger.debug(f"Error parsing SSE event in listener: {e}")
692
+ # Format arguments
693
+ arg_lines = []
694
+ if isinstance(input_data, dict):
695
+ for k, v in input_data.items():
696
+ if k not in ("description", "content"):
697
+ arg_lines.append(f"<b>{html.escape(str(k))}:</b> {html.escape(truncate(str(v)))}")
698
+ args_text = "\n".join(arg_lines)
699
+
700
+ msg = (
701
+ f"🛠️ <b>Calling Tool <code>{html.escape(tool_name)}</code></b>{desc_text}\n"
702
+ )
703
+ if args_text:
704
+ msg += f"{args_text}\n"
705
+
706
+ await update.message.reply_text(msg, parse_mode="HTML")
707
+
708
+ # 2. Tool Completed
709
+ elif status == "completed" and call_id not in completed_calls:
710
+ completed_calls.add(call_id)
711
+
712
+ exit_code = metadata.get("exit", 0)
713
+ output_cleaned = truncate(str(output_data))
714
+
715
+ msg = (
716
+ f"✅ <b>Tool <code>{html.escape(tool_name)}</code> Completed</b> (Exit <code>{exit_code}</code>)\n"
717
+ )
718
+ if output_cleaned.strip():
719
+ msg += f"<pre>{html.escape(output_cleaned)}</pre>"
720
+ else:
721
+ msg += f"<i>(No output returned)</i>"
722
+
723
+ await update.message.reply_text(msg, parse_mode="HTML")
724
+
725
+ # 3. Tool Failed
726
+ elif status in ("failed", "error") and call_id not in completed_calls:
727
+ completed_calls.add(call_id)
728
+
729
+ output_cleaned = truncate(str(output_data))
730
+
731
+ msg = (
732
+ f"❌ <b>Tool <code>{html.escape(tool_name)}</code> Failed</b>\n"
733
+ )
734
+ if output_cleaned.strip():
735
+ msg += f"<pre>{html.escape(output_cleaned)}</pre>"
736
+ else:
737
+ msg += f"<i>(No error description returned)</i>"
738
+
739
+ await update.message.reply_text(msg, parse_mode="HTML")
740
+
741
+ except Exception as e:
742
+ logger.debug(f"Error parsing SSE event in listener: {e}")
736
743
 
737
744
  except asyncio.CancelledError:
738
- pass
745
+ logger.debug("SSE streaming task listener cancelled by parent task.")
746
+ break
739
747
  except Exception as e:
740
- logger.warning(f"Error in SSE streaming task listener: {e}")
748
+ err_name = e or type(e).__name__
749
+ logger.warning(f"Error in SSE streaming task listener: {err_name}. Reconnecting in {retry_delay}s...")
750
+ try:
751
+ await asyncio.sleep(retry_delay)
752
+ except asyncio.CancelledError:
753
+ break
754
+ retry_delay = min(retry_delay * 2, 10.0)
741
755
 
742
756
 
743
757
  async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -41,45 +41,65 @@ async def restart_server(directory: str, port: int = 8080, hostname: str = "127.
41
41
  await stop_server()
42
42
  await asyncio.sleep(1)
43
43
 
44
- # 2. Start a new one from the target directory
45
44
  binary = get_opencode_binary()
46
45
  cmd = [binary, "serve", "--port", str(port), "--hostname", hostname]
47
46
 
48
- logger.info(f"Starting opencode serve from {directory}: {' '.join(cmd)}")
49
-
50
- try:
51
- creationflags = 0
52
- if platform.system() == "Windows":
53
- # CREATE_NEW_PROCESS_GROUP = 0x00000200
54
- creationflags = 0x00000200
55
-
56
- _server_process = subprocess.Popen(
57
- cmd,
58
- cwd=directory,
59
- stdout=subprocess.DEVNULL,
60
- stderr=subprocess.DEVNULL,
61
- stdin=subprocess.DEVNULL,
62
- creationflags=creationflags,
63
- start_new_session=(platform.system() != "Windows"),
64
- )
65
- except Exception as e:
66
- logger.error(f"Failed to start opencode serve: {e}")
67
- return False
68
-
69
- # 3. Wait until the server is reachable (max 30s)
70
- import aiohttp
71
- for attempt in range(30):
72
- await asyncio.sleep(1)
47
+ creationflags = 0
48
+ if platform.system() == "Windows":
49
+ creationflags = 0x00000200 # CREATE_NEW_PROCESS_GROUP
50
+
51
+ for run_attempt in range(1, 3):
52
+ logger.info(f"Starting opencode serve (attempt {run_attempt}/2) from {directory}: {' '.join(cmd)}")
73
53
  try:
74
- async with aiohttp.ClientSession() as session:
75
- async with session.get(f"http://{hostname}:{port}/session", timeout=aiohttp.ClientTimeout(total=2)) as resp:
76
- if resp.status < 500:
77
- logger.info(f"opencode serve is up after {attempt + 1}s (pid={_server_process.pid})")
78
- return True
79
- except Exception:
80
- pass
81
-
82
- logger.error("opencode serve did not become reachable within 30s")
54
+ _server_process = subprocess.Popen(
55
+ cmd,
56
+ cwd=directory,
57
+ stdout=subprocess.DEVNULL,
58
+ stderr=subprocess.DEVNULL,
59
+ stdin=subprocess.DEVNULL,
60
+ creationflags=creationflags,
61
+ start_new_session=(platform.system() != "Windows"),
62
+ )
63
+ except Exception as e:
64
+ logger.error(f"Failed to start opencode serve on attempt {run_attempt}: {e}")
65
+ if run_attempt == 2:
66
+ return False
67
+ await asyncio.sleep(2)
68
+ continue
69
+
70
+ # 3. Wait until the server is reachable (max 15 attempts, 1s sleep + 1s timeout)
71
+ import aiohttp
72
+ for attempt in range(15):
73
+ # Check if the process exited prematurely
74
+ poll_code = _server_process.poll()
75
+ if poll_code is not None:
76
+ logger.warning(f"opencode serve process exited prematurely with code {poll_code} on attempt {run_attempt}")
77
+ break
78
+
79
+ await asyncio.sleep(1)
80
+ try:
81
+ async with aiohttp.ClientSession() as session:
82
+ async with session.get(f"http://{hostname}:{port}/session", timeout=aiohttp.ClientTimeout(total=1)) as resp:
83
+ if resp.status < 500:
84
+ logger.info(f"opencode serve is up after {attempt + 1}s (pid={_server_process.pid})")
85
+ return True
86
+ except Exception:
87
+ pass
88
+
89
+ # Cleanup failed process
90
+ if _server_process:
91
+ try:
92
+ _server_process.terminate()
93
+ _server_process.wait(timeout=2)
94
+ except Exception:
95
+ pass
96
+ _server_process = None
97
+
98
+ if run_attempt == 1:
99
+ logger.warning("First startup attempt failed or port was busy. Retrying in 2 seconds...")
100
+ await asyncio.sleep(2)
101
+
102
+ logger.error("opencode serve did not become reachable after 2 attempts")
83
103
  return False
84
104
 
85
105
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "telegram-opencode-bridge-bot"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  description = "A Telegram bot that bridges messages directly to OpenCode — an AI coding agent running on your machine"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telegram-opencode-bridge-bot
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A Telegram bot that bridges messages directly to OpenCode — an AI coding agent running on your machine
5
5
  Author-email: MaheshNagabhairava <maheshnagabhirava12345@gmail.com>
6
6
  License-Expression: MIT
@@ -58,7 +58,7 @@ A premium, feature-rich Python bot that bridges your Telegram client directly to
58
58
  ### Option A: Standard PyPI Installation
59
59
  Install and run the bot wrapper directly from the terminal:
60
60
  ```bash
61
- pip install telegram-opencode-bridge-bot==0.1.8
61
+ pip install telegram-opencode-bridge-bot==0.1.10
62
62
  telegram-opencode-bot
63
63
  ```
64
64
  > **💡 Tip:** Use the `--env` flag anytime to reconfigure your variables:
@@ -106,7 +106,7 @@ telegram-opencode-bot
106
106
  | **`/enable`** | — | Enables real-time streaming of tool calls, shell executions, and file edits. |
107
107
  | **`/disable`** | — | Disables streaming; the bot will only report the final LLM response. |
108
108
  | **`/share`** | — | Fetches a public, shareable web preview URL of the active conversation. |
109
-
109
+ | **`/update`** | — | Update to a new available version. |
110
110
  ---
111
111
 
112
112
  ## ⚙️ Configuration Variables