telegram-opencode-bridge-bot 0.1.6__tar.gz → 0.1.7__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.6/telegram_opencode_bridge_bot.egg-info → telegram_opencode_bridge_bot-0.1.7}/PKG-INFO +2 -2
  2. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/README.md +1 -1
  3. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/bot.py +65 -5
  4. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/handlers/commands.py +3 -1
  5. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/handlers/messages.py +221 -41
  6. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/opencode/client.py +14 -0
  7. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/opencode/server.py +19 -16
  8. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/pyproject.toml +1 -1
  9. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/sessions/manager.py +31 -1
  10. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7/telegram_opencode_bridge_bot.egg-info}/PKG-INFO +2 -2
  11. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/utils/formatting.py +151 -1
  12. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/.env.example +0 -0
  13. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/MANIFEST.in +0 -0
  14. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/config.py +0 -0
  15. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/handlers/__init__.py +0 -0
  16. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/list_session_models.py +0 -0
  17. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/opencode/__init__.py +0 -0
  18. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/requirements.txt +0 -0
  19. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/sessions/__init__.py +0 -0
  20. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/setup.cfg +0 -0
  21. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/telegram_opencode_bridge_bot.egg-info/SOURCES.txt +0 -0
  22. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/telegram_opencode_bridge_bot.egg-info/dependency_links.txt +0 -0
  23. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/telegram_opencode_bridge_bot.egg-info/entry_points.txt +0 -0
  24. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/telegram_opencode_bridge_bot.egg-info/requires.txt +0 -0
  25. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/telegram_opencode_bridge_bot.egg-info/top_level.txt +0 -0
  26. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/utils/__init__.py +0 -0
  27. {telegram_opencode_bridge_bot-0.1.6 → telegram_opencode_bridge_bot-0.1.7}/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.6
3
+ Version: 0.1.7
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
@@ -47,7 +47,7 @@ A lightweight Python bot that bridges your Telegram messages directly to [OpenCo
47
47
 
48
48
  ### Quick Installation:
49
49
  ```bash
50
- pip install telegram-opencode-bridge-bot==0.1.6
50
+ pip install telegram-opencode-bridge-bot==0.1.7
51
51
  telegram-opencode-bot
52
52
  telegram-opencode-bot --env (use --env flag if u want to re-configure later anytime)
53
53
  ```
@@ -30,7 +30,7 @@ A lightweight Python bot that bridges your Telegram messages directly to [OpenCo
30
30
 
31
31
  ### Quick Installation:
32
32
  ```bash
33
- pip install telegram-opencode-bridge-bot==0.1.6
33
+ pip install telegram-opencode-bridge-bot==0.1.7
34
34
  telegram-opencode-bot
35
35
  telegram-opencode-bot --env (use --env flag if u want to re-configure later anytime)
36
36
  ```
@@ -19,6 +19,10 @@ import logging
19
19
  import sys
20
20
  import os
21
21
 
22
+ # Switch to Selector Event Loop on Windows for robust signal handling and clean shutdowns
23
+ if sys.platform == 'win32':
24
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
25
+
22
26
  from telegram import Update
23
27
  from telegram.ext import (
24
28
  ApplicationBuilder,
@@ -67,6 +71,43 @@ logging.basicConfig(
67
71
  )
68
72
  logger = logging.getLogger("opencode-telegram-bot")
69
73
 
74
+ _lock_file = None
75
+
76
+ def acquire_bot_lock():
77
+ """Acquire an exclusive lock file to prevent multiple instances from running concurrently."""
78
+ global _lock_file
79
+ lock_path = os.path.join(os.path.abspath("."), "bot.lock")
80
+ try:
81
+ _lock_file = open(lock_path, "w")
82
+ if os.name == 'nt':
83
+ import msvcrt
84
+ try:
85
+ _lock_file.seek(0)
86
+ msvcrt.locking(_lock_file.fileno(), msvcrt.LK_NBLCK, 1)
87
+ _lock_file.write(str(os.getpid()))
88
+ _lock_file.flush()
89
+ except (OSError, IOError):
90
+ print("\n" + "="*65)
91
+ print("❌ ERROR: Another instance of the Telegram bot is already running!")
92
+ print("Please close the other terminal or kill the stray Python process.")
93
+ print("="*65 + "\n")
94
+ sys.exit(1)
95
+ else:
96
+ import fcntl
97
+ try:
98
+ fcntl.flock(_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
99
+ _lock_file.write(str(os.getpid()))
100
+ _lock_file.flush()
101
+ except (OSError, IOError):
102
+ print("\n" + "="*65)
103
+ print("❌ ERROR: Another instance of the Telegram bot is already running!")
104
+ print("Please close the other terminal or kill the stray Python process.")
105
+ print("="*65 + "\n")
106
+ sys.exit(1)
107
+ except Exception as e:
108
+ logger.warning(f"Could not acquire bot lock: {e}")
109
+
110
+
70
111
 
71
112
  class RetryingHTTPXRequest(HTTPXRequest):
72
113
  """Custom HTTPXRequest that automatically retries failed requests on connection timeouts/errors."""
@@ -198,8 +239,17 @@ def build_authorized_handlers(authorizer: UserAuthorizer, rate_limiter: RateLimi
198
239
 
199
240
  async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
200
241
  """Log the error and send a Telegram message to notify the user."""
201
- # Log the error with traceback
202
- logger.error("Exception while handling an update:", exc_info=context.error)
242
+ # Suppress full traceback for common transient network / timeout errors to keep logs clean
243
+ from telegram.error import NetworkError, TimedOut
244
+
245
+ err = context.error
246
+ err_str = str(err).lower()
247
+ if isinstance(err, (NetworkError, TimedOut)) or "httpx" in err_str or "httpcore" in err_str or "read error" in err_str:
248
+ logger.warning(f"📡 Transient Telegram network/timeout error: {err}")
249
+ return
250
+
251
+ # Log unexpected errors with traceback
252
+ logger.error("Exception while handling an update:", exc_info=err)
203
253
 
204
254
  # Notify the user if the update is a Telegram Update with a message
205
255
  if isinstance(update, Update) and update.effective_message:
@@ -233,19 +283,25 @@ async def post_shutdown(application) -> None:
233
283
  # Stop the background opencode server process if running
234
284
  try:
235
285
  from opencode.server import stop_server
236
- await stop_server()
286
+ await asyncio.wait_for(stop_server(), timeout=8.0)
237
287
  except Exception as e:
238
288
  logger.warning(f"Failed to stop background OpenCode server: {e}")
239
289
 
240
290
  # Close session manager DB
241
291
  session_mgr = application.bot_data.get("session_manager")
242
292
  if session_mgr:
243
- await session_mgr.close()
293
+ try:
294
+ await asyncio.wait_for(session_mgr.close(), timeout=3.0)
295
+ except Exception as e:
296
+ logger.warning(f"Failed to close session manager: {e}")
244
297
 
245
298
  # Close HTTP client
246
299
  oc_client = application.bot_data.get("opencode_client")
247
300
  if oc_client:
248
- await oc_client.close()
301
+ try:
302
+ await asyncio.wait_for(oc_client.close(), timeout=3.0)
303
+ except Exception as e:
304
+ logger.warning(f"Failed to close HTTP client: {e}")
249
305
 
250
306
  logger.info("Goodbye!")
251
307
 
@@ -412,6 +468,9 @@ def main():
412
468
  logger.error("Copy .env.example → .env and fill in your values.")
413
469
  sys.exit(1)
414
470
 
471
+ # ── Acquire exclusive bot lock to prevent concurrent instances ───────
472
+ acquire_bot_lock()
473
+
415
474
  logger.info("=" * 50)
416
475
  logger.info(" OpenCode Telegram Bot")
417
476
  logger.info("=" * 50)
@@ -447,6 +506,7 @@ def main():
447
506
  ApplicationBuilder()
448
507
  .token(config.telegram_bot_token)
449
508
  .request(request)
509
+ .get_updates_request(request)
450
510
  .post_init(post_init)
451
511
  .post_shutdown(post_shutdown)
452
512
  .build()
@@ -828,7 +828,9 @@ async def execute_project_switch(update_or_query, context: ContextTypes.DEFAULT_
828
828
  if not session_id:
829
829
  raise ValueError("Response did not contain a session ID.")
830
830
 
831
- await session_mgr.set_active_session(user_id, session_id, config.opencode_model, work_dir=target_path)
831
+ # Fetch user preferred model, falling back to config model
832
+ preferred_model = await session_mgr.get_user_preferred_model(user_id, config.opencode_model)
833
+ await session_mgr.set_active_session(user_id, session_id, preferred_model, work_dir=target_path)
832
834
 
833
835
  await update_status(
834
836
  f"🔄 <b>Switched project to:</b> <code>{html.escape(target_folder)}</code>\n"
@@ -146,6 +146,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
146
146
  parse_mode="HTML"
147
147
  )
148
148
 
149
+ status_msg_holder = [status_msg]
150
+
149
151
  # Always spawn the SSE event stream listener so we can handle interactive permission prompts
150
152
  # (e.g. for sensitive files like .env) even if the user has disabled regular tool-call progress.
151
153
  sse_task = asyncio.create_task(
@@ -155,14 +157,24 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
155
157
  session_id=session_id,
156
158
  server_url=config.opencode_server_url,
157
159
  is_streaming=bool(is_streaming == 1),
158
- status_msg=status_msg
160
+ status_msg_holder=status_msg_holder
159
161
  )
160
162
  )
161
163
 
162
164
  typing_task = asyncio.create_task(
163
165
  _keep_typing(update, config.response_timeout)
164
166
  )
167
+ before_ids = set()
168
+ sent_message_ids = context.user_data.setdefault("sent_message_ids", set())
169
+ sent_message_ids.clear()
165
170
  try:
171
+ # Fetch message IDs before sending the prompt
172
+ try:
173
+ before_messages = await oc_client.list_messages(session_id)
174
+ before_ids = {m.get("info", {}).get("id") for m in before_messages if m.get("info", {}).get("id")}
175
+ except Exception as e:
176
+ logger.warning(f"Failed to fetch messages before prompt: {e}")
177
+
166
178
  session_info = await session_mgr.get_session_info(user_id)
167
179
  session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
168
180
 
@@ -276,9 +288,9 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
276
288
  if sse_task:
277
289
  sse_task.cancel()
278
290
  # Clean up by deleting the temporary live phase status message
279
- if status_msg:
291
+ if status_msg_holder and status_msg_holder[0]:
280
292
  try:
281
- await status_msg.delete()
293
+ await status_msg_holder[0].delete()
282
294
  except Exception:
283
295
  pass
284
296
 
@@ -286,40 +298,78 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
286
298
  await session_mgr.increment_message_count(user_id, prompt=message_text)
287
299
 
288
300
  # ── 5. Format and send response ───────────────────────
289
- if not response_text or response_text == "ABORTED":
290
- # Silent return for aborted, cancelled, or empty responses
301
+ # Fetch messages after prompt completes to get all multi-step assistant messages
302
+ response_texts = []
303
+ try:
304
+ after_messages = await oc_client.list_messages(session_id)
305
+ new_messages = [
306
+ m for m in after_messages
307
+ if m.get("info", {}).get("id") not in before_ids
308
+ and m.get("info", {}).get("id") not in sent_message_ids
309
+ and m.get("info", {}).get("role") == "assistant"
310
+ ]
311
+ for m in new_messages:
312
+ parts = m.get("parts", [])
313
+ content_text = ""
314
+ if isinstance(parts, list):
315
+ text_parts = [
316
+ p.get("text", "")
317
+ for p in parts
318
+ if isinstance(p, dict) and p.get("type") == "text"
319
+ ]
320
+ content_text = "".join(text_parts)
321
+ if content_text.strip():
322
+ response_texts.append(content_text)
323
+ except Exception as e:
324
+ logger.warning(f"Failed to fetch messages after prompt: {e}")
325
+
326
+ # Fallback to standard response if no intermediate texts were retrieved
327
+ all_responses = response_texts if response_texts else ([response_text] if response_text else [])
328
+
329
+ if not all_responses:
330
+ if response_text == "ABORTED":
331
+ return
332
+ # Send a user-friendly status message to prevent getting stuck silently
333
+ await update.message.reply_text(
334
+ "ℹ️ <b>OpenCode finished execution.</b>\n<i>(No conversational text response was returned)</i>",
335
+ parse_mode="HTML"
336
+ )
291
337
  return
292
338
 
293
- # Format OpenCode output for Telegram
294
- formatted = format_opencode_response(response_text)
339
+ for resp in all_responses:
340
+ if not resp or resp == "ABORTED":
341
+ continue
295
342
 
296
- # Split into chunks if too long
297
- chunks = split_message(formatted, config.max_message_length)
343
+ # Format OpenCode output for Telegram
344
+ formatted = format_opencode_response(resp)
298
345
 
299
- for i, chunk in enumerate(chunks):
300
- try:
301
- await update.message.reply_text(
302
- chunk,
303
- parse_mode="HTML",
304
- disable_web_page_preview=True,
305
- )
306
- except Exception as e:
307
- # If HTML parsing fails, try sending as plain text
308
- logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}")
346
+ # Split into chunks if too long
347
+ chunks = split_message(formatted, config.max_message_length)
348
+
349
+ for i, chunk in enumerate(chunks):
309
350
  try:
310
- # Strip HTML tags for plain text fallback
311
- import re
312
- plain = re.sub(r'<[^>]+>', '', chunk)
313
351
  await update.message.reply_text(
314
- plain,
352
+ chunk,
353
+ parse_mode="HTML",
315
354
  disable_web_page_preview=True,
316
355
  )
317
- except Exception as e2:
318
- logger.error(f"Failed to send chunk {i+1} even as plain text: {e2}")
356
+ except Exception as e:
357
+ # If HTML parsing fails, try sending as plain text
358
+ logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}")
359
+ try:
360
+ # Strip HTML tags for plain text fallback
361
+ import re
362
+ plain = re.sub(r'<[^>]+>', '', chunk)
363
+ await update.message.reply_text(
364
+ plain,
365
+ disable_web_page_preview=True,
366
+ )
367
+ except Exception as e2:
368
+ logger.error(f"Failed to send chunk {i+1} even as plain text: {e2}")
319
369
 
320
- # Small delay between chunks to respect rate limits
321
- if i < len(chunks) - 1:
322
- await asyncio.sleep(0.5)
370
+ # Small delay between chunks to respect rate limits
371
+ if i < len(chunks) - 1:
372
+ await asyncio.sleep(0.5)
323
373
 
324
374
 
325
375
  async def _create_session(oc_client, user_id, session_mgr, config):
@@ -339,7 +389,10 @@ async def _create_session(oc_client, user_id, session_mgr, config):
339
389
  if not session_id:
340
390
  raise ValueError(f"OpenCode server response did not contain a session ID: {result}")
341
391
 
342
- await session_mgr.set_active_session(user_id, session_id, config.opencode_model, work_dir=work_dir)
392
+ # Fetch user preferred model, falling back to config model
393
+ preferred_model = await session_mgr.get_user_preferred_model(user_id, config.opencode_model)
394
+
395
+ await session_mgr.set_active_session(user_id, session_id, preferred_model, work_dir=work_dir)
343
396
  return session_id
344
397
 
345
398
 
@@ -382,7 +435,7 @@ async def _listen_and_stream_events(
382
435
  session_id: str,
383
436
  server_url: str,
384
437
  is_streaming: bool,
385
- status_msg = None
438
+ status_msg_holder = None
386
439
  ):
387
440
  """Listens to global OpenCode events via SSE and handles tool progress/permission requests."""
388
441
  import aiohttp
@@ -396,6 +449,7 @@ async def _listen_and_stream_events(
396
449
  notified_calls = set()
397
450
  completed_calls = set()
398
451
  last_update_time = [0.0]
452
+ last_status_text = ["🧠 <b>Thinking...</b>\n<i>Analyzing request and preparing a plan...</i>"]
399
453
 
400
454
  def truncate(text, max_len=500):
401
455
  if not text:
@@ -407,10 +461,11 @@ async def _listen_and_stream_events(
407
461
 
408
462
  async def update_status(text: str):
409
463
  now = time.time()
464
+ last_status_text[0] = text
410
465
  # Throttling to respect Telegram API rate limits (minimum 1.5 seconds between message edits)
411
- if status_msg and (now - last_update_time[0] >= 1.5):
466
+ if status_msg_holder and status_msg_holder[0] and (now - last_update_time[0] >= 1.5):
412
467
  try:
413
- await status_msg.edit_text(text, parse_mode="HTML")
468
+ await status_msg_holder[0].edit_text(text, parse_mode="HTML")
414
469
  last_update_time[0] = now
415
470
  except Exception as e:
416
471
  logger.debug(f"Failed to update status message: {e}")
@@ -434,14 +489,82 @@ async def _listen_and_stream_events(
434
489
  if not isinstance(properties, dict):
435
490
  continue
436
491
 
437
- event_session_id = properties.get("sessionID", "")
492
+ event_session_id = (
493
+ properties.get("sessionID")
494
+ or properties.get("sessionId")
495
+ or properties.get("session_id")
496
+ or payload.get("sessionID")
497
+ or payload.get("sessionId")
498
+ or payload.get("session_id")
499
+ or ""
500
+ )
438
501
  if event_session_id != session_id:
439
502
  continue
440
503
 
441
504
  event_type = payload.get("type", "")
442
505
 
443
- # A. Handle Permission Requested Popup (Always Enabled)
444
- if event_type == "permission.asked":
506
+ # A. Handle Intermediate Assistant Message Completion (Real-time Streaming)
507
+ if event_type == "message.updated":
508
+ info = properties.get("info", {})
509
+ msg_id = info.get("id")
510
+ role = info.get("role")
511
+ completed = info.get("time", {}).get("completed")
512
+
513
+ if role == "assistant" and completed:
514
+ sent_message_ids = context.user_data.setdefault("sent_message_ids", set())
515
+ if msg_id not in sent_message_ids:
516
+ sent_message_ids.add(msg_id)
517
+ try:
518
+ oc_client = context.bot_data["opencode_client"]
519
+ messages = await oc_client.list_messages(session_id)
520
+ target_msg = next((m for m in messages if m.get("info", {}).get("id") == msg_id), None)
521
+ if target_msg:
522
+ parts = target_msg.get("parts", [])
523
+ content_text = ""
524
+ if isinstance(parts, list):
525
+ text_parts = [
526
+ p.get("text", "")
527
+ for p in parts
528
+ if isinstance(p, dict) and p.get("type") == "text"
529
+ ]
530
+ content_text = "".join(text_parts)
531
+
532
+ if content_text.strip():
533
+ # Delete the old status message at the top
534
+ if status_msg_holder and status_msg_holder[0]:
535
+ try:
536
+ await status_msg_holder[0].delete()
537
+ except Exception:
538
+ pass
539
+ status_msg_holder[0] = None
540
+
541
+ from utils.formatting import format_opencode_response, split_message
542
+ formatted = format_opencode_response(content_text)
543
+ chunks = split_message(formatted, context.bot_data["config"].max_message_length)
544
+ for i, chunk in enumerate(chunks):
545
+ await update.message.reply_text(
546
+ chunk,
547
+ parse_mode="HTML",
548
+ disable_web_page_preview=True,
549
+ )
550
+ if i < len(chunks) - 1:
551
+ await asyncio.sleep(0.5)
552
+
553
+ # Recreate the status indicator at the very bottom
554
+ if status_msg_holder:
555
+ try:
556
+ status_msg_holder[0] = await update.message.reply_text(
557
+ last_status_text[0],
558
+ parse_mode="HTML"
559
+ )
560
+ last_update_time[0] = time.time()
561
+ except Exception as e:
562
+ logger.warning(f"Failed to recreate status message at bottom: {e}")
563
+ except Exception as e:
564
+ logger.warning(f"Failed to stream intermediate message {msg_id}: {e}")
565
+
566
+ # B. Handle Permission Requested Popup (Always Enabled)
567
+ elif event_type == "permission.asked":
445
568
  perm_id = properties.get("id") or properties.get("permissionID") or payload.get("id")
446
569
  perm_type = properties.get("permission") or properties.get("type") or "execute"
447
570
  patterns = properties.get("patterns", [])
@@ -514,7 +637,7 @@ async def _listen_and_stream_events(
514
637
  metadata = {}
515
638
 
516
639
  # ── 1. Update In-Place Status Message (Always Active) ──
517
- if status in ("pending", "running") and status_msg:
640
+ if status in ("pending", "running") and status_msg_holder and status_msg_holder[0]:
518
641
  status_text = ""
519
642
  if tool_name == "bash":
520
643
  cmd = input_data.get("command") or input_data.get("content") or ""
@@ -734,6 +857,8 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
734
857
  "🧠 <b>Thinking...</b>\n<i>Analyzing request and preparing a plan...</i>",
735
858
  parse_mode="HTML"
736
859
  )
860
+
861
+ status_msg_holder = [status_msg]
737
862
 
738
863
  sse_task = asyncio.create_task(
739
864
  _listen_and_stream_events(
@@ -742,7 +867,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
742
867
  session_id=session_id,
743
868
  server_url=config.opencode_server_url,
744
869
  is_streaming=bool(is_streaming == 1),
745
- status_msg=status_msg
870
+ status_msg_holder=status_msg_holder
746
871
  )
747
872
  )
748
873
 
@@ -750,10 +875,20 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
750
875
  _keep_typing(update, config.response_timeout)
751
876
  )
752
877
 
878
+ before_ids = set()
879
+ sent_message_ids = context.user_data.setdefault("sent_message_ids", set())
880
+ sent_message_ids.clear()
753
881
  try:
754
882
  session_info = await session_mgr.get_session_info(user_id)
755
883
  session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
756
884
 
885
+ # Fetch message IDs before sending the prompt
886
+ try:
887
+ before_messages = await oc_client.list_messages(session_id)
888
+ before_ids = {m.get("info", {}).get("id") for m in before_messages if m.get("info", {}).get("id")}
889
+ except Exception as e:
890
+ logger.warning(f"Failed to fetch messages before document prompt: {e}")
891
+
757
892
  response_text = await _send_to_opencode(
758
893
  oc_client=oc_client,
759
894
  session_id=session_id,
@@ -765,9 +900,54 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
765
900
  await session_mgr.increment_message_count(user_id, prompt=caption)
766
901
 
767
902
  # Send response back to user
768
- if response_text and response_text != "ABORTED":
769
- formatted = format_opencode_response(response_text)
903
+ # Fetch messages after prompt completes to get all multi-step assistant messages
904
+ response_texts = []
905
+ try:
906
+ after_messages = await oc_client.list_messages(session_id)
907
+ new_messages = [
908
+ m for m in after_messages
909
+ if m.get("info", {}).get("id") not in before_ids
910
+ and m.get("info", {}).get("id") not in sent_message_ids
911
+ and m.get("info", {}).get("role") == "assistant"
912
+ ]
913
+ for m in new_messages:
914
+ parts = m.get("parts", [])
915
+ content_text = ""
916
+ if isinstance(parts, list):
917
+ text_parts = [
918
+ p.get("text", "")
919
+ for p in parts
920
+ if isinstance(p, dict) and p.get("type") == "text"
921
+ ]
922
+ content_text = "".join(text_parts)
923
+ if content_text.strip():
924
+ response_texts.append(content_text)
925
+ except Exception as e:
926
+ logger.warning(f"Failed to fetch messages after document prompt: {e}")
927
+
928
+ # Fallback to standard response if no intermediate texts were retrieved
929
+ all_responses = response_texts if response_texts else ([response_text] if response_text else [])
930
+
931
+ if not all_responses:
932
+ if response_text == "ABORTED":
933
+ return
934
+ # Send a user-friendly status message to prevent getting stuck silently
935
+ await update.message.reply_text(
936
+ "ℹ️ <b>OpenCode finished execution.</b>\n<i>(No conversational text response was returned)</i>",
937
+ parse_mode="HTML"
938
+ )
939
+ return
940
+
941
+ for resp in all_responses:
942
+ if not resp or resp == "ABORTED":
943
+ continue
944
+
945
+ # Format OpenCode output for Telegram
946
+ formatted = format_opencode_response(resp)
947
+
948
+ # Split into chunks if too long
770
949
  chunks = split_message(formatted, config.max_message_length)
950
+
771
951
  for i, chunk in enumerate(chunks):
772
952
  try:
773
953
  await update.message.reply_text(
@@ -796,9 +976,9 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
796
976
  typing_task.cancel()
797
977
  if sse_task:
798
978
  sse_task.cancel()
799
- if status_msg:
979
+ if status_msg_holder and status_msg_holder[0]:
800
980
  try:
801
- await status_msg.delete()
981
+ await status_msg_holder[0].delete()
802
982
  except Exception:
803
983
  pass
804
984
 
@@ -274,6 +274,20 @@ class OpenCodeClient:
274
274
  return result.get("sessions", result.get("data", []))
275
275
  return []
276
276
 
277
+ async def list_messages(self, session_id: str) -> List[Dict[str, Any]]:
278
+ """List all messages in an OpenCode session.
279
+
280
+ Parameters:
281
+ session_id: The session identifier.
282
+
283
+ Returns:
284
+ A list of message dictionaries.
285
+ """
286
+ result = await self._request("GET", f"/session/{session_id}/message")
287
+ if isinstance(result, list):
288
+ return result
289
+ return []
290
+
277
291
  async def create_session(self, directory: Optional[str] = None) -> Dict[str, Any]:
278
292
  """Create a new OpenCode session.
279
293
 
@@ -103,34 +103,37 @@ async def stop_server() -> None:
103
103
  # Also kill any stray opencode serve processes on our port
104
104
  if platform.system() == "Windows":
105
105
  try:
106
- # Query netstat to find process ID listening on the port
107
- cmd = f'netstat -ano | findstr LISTENING | findstr :{_server_port}'
108
- proc = await asyncio.create_subprocess_shell(
109
- cmd,
110
- stdout=asyncio.subprocess.PIPE,
111
- stderr=asyncio.subprocess.DEVNULL
106
+ # Query netstat to find process ID listening on the port synchronously
107
+ proc = subprocess.run(
108
+ f'netstat -ano | findstr LISTENING | findstr :{_server_port}',
109
+ shell=True,
110
+ stdout=subprocess.PIPE,
111
+ stderr=subprocess.DEVNULL,
112
+ text=True,
113
+ timeout=5
112
114
  )
113
- stdout, _ = await proc.communicate()
114
- lines = stdout.decode().strip().split('\n')
115
+ stdout = proc.stdout
116
+ lines = stdout.strip().split('\n')
115
117
  for line in lines:
116
118
  parts = line.strip().split()
117
119
  if len(parts) >= 5:
118
120
  pid = parts[-1]
119
121
  if pid.isdigit() and int(pid) > 0:
120
- os.system(f"taskkill /F /T /PID {pid}")
122
+ subprocess.run(f"taskkill /F /T /PID {pid}", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
121
123
  logger.info(f"Killed stray Windows PID={pid} on port {_server_port}")
122
124
  except Exception as e:
123
125
  logger.warning(f"Failed to kill stray Windows process: {e}")
124
126
  else:
125
- # Unix lsof implementation
127
+ # Unix lsof implementation synchronously
126
128
  try:
127
- proc = await asyncio.create_subprocess_exec(
128
- "lsof", "-ti", f":{_server_port}", "-sTCP:LISTEN",
129
- stdout=asyncio.subprocess.PIPE,
130
- stderr=asyncio.subprocess.DEVNULL,
129
+ proc = subprocess.run(
130
+ ["lsof", "-ti", f":{_server_port}", "-sTCP:LISTEN"],
131
+ stdout=subprocess.PIPE,
132
+ stderr=subprocess.DEVNULL,
133
+ text=True,
134
+ timeout=5
131
135
  )
132
- stdout, _ = await proc.communicate()
133
- pids = stdout.decode().strip().split()
136
+ pids = proc.stdout.strip().split()
134
137
  for pid in pids:
135
138
  if pid.isdigit():
136
139
  try:
@@ -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.6"
7
+ version = "0.1.7"
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"
@@ -65,6 +65,11 @@ class SessionManager:
65
65
  await self._db.execute("ALTER TABLE user_settings ADD COLUMN streaming INTEGER DEFAULT 0")
66
66
  except aiosqlite.OperationalError:
67
67
  pass
68
+
69
+ try:
70
+ await self._db.execute("ALTER TABLE user_settings ADD COLUMN model TEXT DEFAULT ''")
71
+ except aiosqlite.OperationalError:
72
+ pass
68
73
 
69
74
  await self._db.execute("""
70
75
  CREATE INDEX IF NOT EXISTS idx_user_active
@@ -174,7 +179,11 @@ class SessionManager:
174
179
  await self._db.commit()
175
180
 
176
181
  async def set_model(self, user_id: int, model: str) -> None:
177
- """Set the model for a user's active session."""
182
+ """Set the model for a user's active session and also save as preferred settings."""
183
+ # 1. Save in user_settings
184
+ await self.set_user_preferred_model(user_id, model)
185
+
186
+ # 2. Update active session if exists
178
187
  if user_id in self._active_sessions:
179
188
  self._active_sessions[user_id]["model"] = model
180
189
  await self._db.execute(
@@ -182,6 +191,27 @@ class SessionManager:
182
191
  (model, user_id)
183
192
  )
184
193
  await self._db.commit()
194
+
195
+ async def get_user_preferred_model(self, user_id: int, default_model: str) -> str:
196
+ """Get the preferred model for a specific user, falling back to default."""
197
+ async with self._db.execute(
198
+ "SELECT model FROM user_settings WHERE user_id = ?",
199
+ (user_id,)
200
+ ) as cursor:
201
+ row = await cursor.fetchone()
202
+ if row and row[0]:
203
+ return row[0]
204
+ return default_model
205
+
206
+ async def set_user_preferred_model(self, user_id: int, model: str) -> None:
207
+ """Save or update the preferred model for a user in their settings."""
208
+ await self._db.execute(
209
+ "INSERT INTO user_settings (user_id, work_dir, model) VALUES (?, '', ?) "
210
+ "ON CONFLICT(user_id) DO UPDATE SET model = excluded.model",
211
+ (user_id, model)
212
+ )
213
+ await self._db.commit()
214
+ logger.info(f"Set preferred model for user {user_id}: {model}")
185
215
 
186
216
  async def clear_session(self, user_id: int) -> None:
187
217
  """Deactivate the current session for a user (they'll get a new one on next message)."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telegram-opencode-bridge-bot
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -47,7 +47,7 @@ A lightweight Python bot that bridges your Telegram messages directly to [OpenCo
47
47
 
48
48
  ### Quick Installation:
49
49
  ```bash
50
- pip install telegram-opencode-bridge-bot==0.1.6
50
+ pip install telegram-opencode-bridge-bot==0.1.7
51
51
  telegram-opencode-bot
52
52
  telegram-opencode-bot --env (use --env flag if u want to re-configure later anytime)
53
53
  ```
@@ -35,7 +35,9 @@ def format_opencode_response(text: str) -> str:
35
35
  formatted_parts.append(f'<pre>{escaped}</pre>')
36
36
  else:
37
37
  # Regular text — convert markdown to HTML
38
- formatted_parts.append(_markdown_to_html(content))
38
+ html_converted = _markdown_to_html(content)
39
+ # Parse and convert markdown tables inside the HTML-converted text
40
+ formatted_parts.append(_convert_markdown_tables(html_converted))
39
41
 
40
42
  return "\n".join(formatted_parts)
41
43
 
@@ -98,6 +100,154 @@ def _markdown_to_html(text: str) -> str:
98
100
  return text
99
101
 
100
102
 
103
+ def _convert_markdown_tables(text: str) -> str:
104
+ """Detect and convert Markdown tables to Telegram-friendly HTML cards or lists."""
105
+ lines = text.split("\n")
106
+ processed_lines = []
107
+
108
+ in_table = False
109
+ table_headers = []
110
+ table_rows = []
111
+
112
+ def flush_table():
113
+ nonlocal in_table, table_headers, table_rows
114
+ if not table_headers:
115
+ in_table = False
116
+ table_rows = []
117
+ return []
118
+
119
+ formatted_table = []
120
+ num_cols = len(table_headers)
121
+
122
+ if num_cols == 2:
123
+ # 2 Columns (Checklist / Key-Value Card)
124
+ header_left = table_headers[0].strip()
125
+ header_right = table_headers[1].strip()
126
+
127
+ # Strip tags for clean header title
128
+ h_left_raw = re.sub(r'<[^>]+>', '', header_left).strip()
129
+ formatted_table.append(f"<b>📋 {h_left_raw}</b>\n")
130
+
131
+ for row in table_rows:
132
+ if len(row) >= 2:
133
+ key = row[0].strip()
134
+ val = row[1].strip()
135
+
136
+ # Strip any redundant bold tags from key for clean styling
137
+ clean_key = key
138
+ if clean_key.startswith("<b>") and clean_key.endswith("</b>"):
139
+ clean_key = clean_key[3:-4]
140
+
141
+ formatted_table.append(f"• <b>{clean_key}:</b> {val}")
142
+ formatted_table.append("") # Empty line after table
143
+
144
+ else:
145
+ # 3+ Columns (API Card Layout)
146
+ method_idx = -1
147
+ path_idx = -1
148
+ other_idxs = []
149
+
150
+ for idx, h in enumerate(table_headers):
151
+ h_clean = h.strip().lower()
152
+ h_raw = re.sub(r'<[^>]+>', '', h_clean)
153
+ if "method" in h_raw:
154
+ method_idx = idx
155
+ elif "path" in h_raw or "endpoint" in h_raw:
156
+ path_idx = idx
157
+ else:
158
+ other_idxs.append(idx)
159
+
160
+ for row in table_rows:
161
+ method_val = row[method_idx].strip() if method_idx >= 0 and method_idx < len(row) else ""
162
+ path_val = row[path_idx].strip() if path_idx >= 0 and path_idx < len(row) else ""
163
+
164
+ # Clean method and path from any HTML tags
165
+ method_raw = re.sub(r'<[^>]+>', '', method_val).upper().strip()
166
+ path_raw = re.sub(r'<[^>]+>', '', path_val).strip()
167
+
168
+ # Format method badge with colored emojis
169
+ method_html = ""
170
+ if method_raw:
171
+ emoji = "⚪"
172
+ if "GET" in method_raw and "POST" in method_raw:
173
+ emoji = "🟢/🔵"
174
+ elif "GET" in method_raw:
175
+ emoji = "🟢"
176
+ elif "POST" in method_raw:
177
+ emoji = "🔵"
178
+ elif "PUT" in method_raw:
179
+ emoji = "🟡"
180
+ elif "DELETE" in method_raw:
181
+ emoji = "🔴"
182
+ elif "PATCH" in method_raw:
183
+ emoji = "🟣"
184
+
185
+ method_html = f"{emoji} <b>{method_raw}</b>"
186
+
187
+ # Format path in monospaced code blocks
188
+ path_html = ""
189
+ if path_raw:
190
+ path_html = f" <code>{path_raw}</code>"
191
+
192
+ card_title = f"{method_html}{path_html}".strip()
193
+ if card_title:
194
+ formatted_table.append(card_title)
195
+
196
+ # Add other columns as key-value bullets under this card
197
+ for idx in other_idxs:
198
+ if idx < len(row):
199
+ col_name = table_headers[idx].strip()
200
+ col_val = row[idx].strip()
201
+
202
+ col_name_raw = re.sub(r'<[^>]+>', '', col_name).strip()
203
+
204
+ if col_val:
205
+ formatted_table.append(f"• <b>{col_name_raw}:</b> {col_val}")
206
+
207
+ formatted_table.append("") # Empty line between cards
208
+
209
+ # Reset state
210
+ in_table = False
211
+ table_headers = []
212
+ table_rows = []
213
+
214
+ return formatted_table
215
+
216
+ i = 0
217
+ while i < len(lines):
218
+ line = lines[i]
219
+ line_stripped = line.strip()
220
+
221
+ if line_stripped.startswith("|"):
222
+ cells = [c.strip() for c in line_stripped.split("|")[1:-1]]
223
+
224
+ is_separator = False
225
+ if cells:
226
+ is_separator = all(re.match(r'^:?-+:?$', c) for c in cells)
227
+
228
+ if is_separator:
229
+ i += 1
230
+ continue
231
+
232
+ if not in_table:
233
+ in_table = True
234
+ table_headers = cells
235
+ else:
236
+ table_rows.append(cells)
237
+ else:
238
+ if in_table:
239
+ processed_lines.extend(flush_table())
240
+ processed_lines.append(line)
241
+
242
+ i += 1
243
+
244
+ if in_table:
245
+ processed_lines.extend(flush_table())
246
+
247
+ return "\n".join(processed_lines)
248
+
249
+
250
+
101
251
  def split_message(text: str, max_length: int = DEFAULT_MAX_LENGTH) -> List[str]:
102
252
  """Split a long message into chunks that fit Telegram's limit.
103
253