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/messages.py ADDED
@@ -0,0 +1,482 @@
1
+ """
2
+ Core message handler — bridges Telegram text messages to OpenCode.
3
+
4
+ This is the heart of the bot. Every non-command text message is routed
5
+ through here to OpenCode's HTTP API (or subprocess fallback).
6
+ """
7
+
8
+ import logging
9
+ import asyncio
10
+
11
+ from telegram import Update
12
+ from telegram.ext import ContextTypes
13
+ from telegram.constants import ChatAction
14
+
15
+ from utils.formatting import format_opencode_response, split_message, format_error
16
+ from utils.security import sanitize_input
17
+ from opencode.client import OpenCodeAPIError, OpenCodeConnectionError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def ensure_server_running(update: Update, context: ContextTypes.DEFAULT_TYPE, user_id: int) -> bool:
23
+ """Ensure the OpenCode serve process is running in the correct directory.
24
+
25
+ Uses an in-memory flag inside bot_data to avoid redundant local HTTP pings.
26
+ If the server is offline, it dynamically boots it scoped to the correct directory.
27
+
28
+ Returns True if the server is running, False otherwise.
29
+ """
30
+ bot_data = context.bot_data
31
+ config = bot_data["config"]
32
+ oc_client = bot_data["opencode_client"]
33
+ session_mgr = bot_data["session_manager"]
34
+
35
+ # 1. Check in-memory flag
36
+ if bot_data.get("server_started"):
37
+ return True
38
+
39
+ # 2. If flag is False, check if the server is already reachable (e.g. started externally)
40
+ if await oc_client.is_available():
41
+ bot_data["server_started"] = True
42
+ return True
43
+
44
+ # 3. Server is offline - lazy launch it scoped to the user's active folder
45
+ import html
46
+ startup_notice = await update.message.reply_text(
47
+ "⏳ <b>Initializing OpenCode server...</b>\n"
48
+ "This happens once on first startup to physically mount your workspace.",
49
+ parse_mode="HTML"
50
+ )
51
+
52
+ async def update_startup_status(text):
53
+ try:
54
+ await startup_notice.edit_text(text, parse_mode="HTML")
55
+ except Exception:
56
+ await update.message.reply_text(text, parse_mode="HTML")
57
+
58
+ # Resolve last active directory for this user, falling back to default OPENCODE_WORK_DIR
59
+ work_dir = await session_mgr.get_user_work_dir(user_id, config.opencode_work_dir)
60
+
61
+ from urllib.parse import urlparse
62
+ try:
63
+ url_parsed = urlparse(config.opencode_server_url)
64
+ hostname = url_parsed.hostname or "127.0.0.1"
65
+ port = url_parsed.port or 8080
66
+ except Exception:
67
+ hostname = "127.0.0.1"
68
+ port = 8080
69
+
70
+ from opencode.server import restart_server
71
+ logger.info(f"Lazy launching OpenCode server inside: {work_dir} on port {port}")
72
+
73
+ started = await restart_server(work_dir, port=port, hostname=hostname)
74
+
75
+ if not started:
76
+ await update_startup_status(
77
+ "❌ <b>Failed to start OpenCode server automatically.</b>\n\n"
78
+ "Please make sure <code>opencode</code> is installed on your system or check the bot logs."
79
+ )
80
+ return False
81
+
82
+ try:
83
+ await startup_notice.delete()
84
+ except Exception:
85
+ pass
86
+
87
+ bot_data["server_started"] = True
88
+ return True
89
+
90
+
91
+ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
92
+ """Handle incoming text messages by routing them to OpenCode.
93
+
94
+ Flow:
95
+ 1. Ensure the OpenCode server is running dynamically
96
+ 2. Get or create an OpenCode session for this user
97
+ 3. Send typing indicator
98
+ 4. Forward prompt to OpenCode (HTTP API → subprocess fallback)
99
+ 5. Format and split the response
100
+ 6. Send back to Telegram
101
+ """
102
+ user = update.effective_user
103
+ user_id = user.id
104
+ message_text = sanitize_input(update.message.text or "")
105
+
106
+ if not message_text or not message_text.strip():
107
+ return
108
+
109
+ bot_data = context.bot_data
110
+ session_mgr = bot_data["session_manager"]
111
+ oc_client = bot_data["opencode_client"]
112
+ config = bot_data["config"]
113
+
114
+ # ── 1. Ensure OpenCode server is running ────────────────
115
+ if not await ensure_server_running(update, context, user_id):
116
+ return
117
+
118
+ # ── 2. Send typing indicator ──────────────────────────
119
+ await update.message.chat.send_action(ChatAction.TYPING)
120
+
121
+ # ── 3. Get or create session ──────────────────────────
122
+ session_id = await session_mgr.get_active_session(user_id)
123
+
124
+ if not session_id:
125
+ # Create a new OpenCode session
126
+ try:
127
+ session_id = await _create_session(oc_client, user_id, session_mgr, config)
128
+ except Exception as e:
129
+ logger.error(f"Failed to create session: {e}", exc_info=True)
130
+ await update.message.reply_text(
131
+ format_error(f"Failed to create session: {e}"),
132
+ parse_mode="HTML",
133
+ )
134
+ return
135
+
136
+ # ── 4. Send prompt to OpenCode ────────────────────────
137
+ # Check if streaming is enabled
138
+ is_streaming = await session_mgr.get_user_streaming(user_id, 0)
139
+
140
+ sse_task = None
141
+ if is_streaming == 1:
142
+ sse_task = asyncio.create_task(
143
+ _listen_and_stream_events(update, session_id, config.opencode_server_url)
144
+ )
145
+
146
+ typing_task = asyncio.create_task(
147
+ _keep_typing(update, config.response_timeout)
148
+ )
149
+ try:
150
+ session_info = await session_mgr.get_session_info(user_id)
151
+ session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
152
+
153
+ try:
154
+ response_text = await _send_to_opencode(
155
+ oc_client=oc_client,
156
+ session_id=session_id,
157
+ prompt=message_text,
158
+ model=session_model,
159
+ )
160
+
161
+ if response_text is None:
162
+ # Session expired or was deleted/lost on the OpenCode server (e.g. server restart)
163
+ logger.warning(f"Session {session_id[:8]}... not found on server (returned null). Creating a new session and retrying...")
164
+ session_id = await _create_session(oc_client, user_id, session_mgr, config)
165
+ # Re-fetch model for safe retry
166
+ session_info = await session_mgr.get_session_info(user_id)
167
+ session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
168
+ response_text = await _send_to_opencode(
169
+ oc_client=oc_client,
170
+ session_id=session_id,
171
+ prompt=message_text,
172
+ model=session_model,
173
+ )
174
+ except OpenCodeConnectionError as conn_err:
175
+ # Connection crashed/failed - Reset flag and self-heal!
176
+ logger.warning(f"Connection lost to OpenCode server: {conn_err}. Attempting to recover...")
177
+ bot_data["server_started"] = False
178
+
179
+ await update.message.reply_text(
180
+ "⚠️ <i>Connection to OpenCode server was lost. Attempting to restart server and retry...</i>",
181
+ parse_mode="HTML",
182
+ )
183
+
184
+ if await ensure_server_running(update, context, user_id):
185
+ # Server is back up - recreate session and retry message!
186
+ session_id = await _create_session(oc_client, user_id, session_mgr, config)
187
+ session_info = await session_mgr.get_session_info(user_id)
188
+ session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
189
+
190
+ response_text = await _send_to_opencode(
191
+ oc_client=oc_client,
192
+ session_id=session_id,
193
+ prompt=message_text,
194
+ model=session_model,
195
+ )
196
+ else:
197
+ raise conn_err
198
+ except OpenCodeAPIError as e:
199
+ # Check if the session is missing on the server (404)
200
+ if e.status == 404:
201
+ logger.warning(f"Session {session_id[:8]}... not found on server (HTTP 404). Starting a new one...")
202
+ # Delete the deleted session from DB
203
+ try:
204
+ await session_mgr._db.execute(
205
+ "DELETE FROM sessions WHERE user_id = ? AND opencode_session_id = ?",
206
+ (user_id, session_id)
207
+ )
208
+ await session_mgr._db.commit()
209
+ except Exception:
210
+ pass
211
+
212
+ # Clear cache
213
+ if user_id in session_mgr._active_sessions:
214
+ del session_mgr._active_sessions[user_id]
215
+
216
+ # Create a brand new session and retry
217
+ session_id = await _create_session(oc_client, user_id, session_mgr, config)
218
+ session_info = await session_mgr.get_session_info(user_id)
219
+ session_model = (session_info or {}).get("model", config.opencode_model) or config.opencode_model
220
+
221
+ await update.message.reply_text(
222
+ "⚠️ <i>Active session was deleted or expired on the server. Starting a fresh session...</i>",
223
+ parse_mode="HTML",
224
+ )
225
+
226
+ # Retry sending
227
+ response_text = await _send_to_opencode(
228
+ oc_client=oc_client,
229
+ session_id=session_id,
230
+ prompt=message_text,
231
+ model=session_model,
232
+ )
233
+ else:
234
+ raise
235
+
236
+ except asyncio.TimeoutError:
237
+ await update.message.reply_text(
238
+ "⏰ <b>Request timed out.</b>\n\n"
239
+ "OpenCode took too long to respond. Try a simpler prompt or check the server.",
240
+ parse_mode="HTML",
241
+ )
242
+ return
243
+ except OpenCodeAPIError as e:
244
+ logger.error(f"OpenCode API error: {e}", exc_info=True)
245
+ await update.message.reply_text(
246
+ format_error(str(e)),
247
+ parse_mode="HTML",
248
+ )
249
+ return
250
+ except Exception as e:
251
+ logger.error(f"OpenCode error: {e}", exc_info=True)
252
+ await update.message.reply_text(
253
+ format_error(str(e)),
254
+ parse_mode="HTML",
255
+ )
256
+ return
257
+ finally:
258
+ if typing_task:
259
+ typing_task.cancel()
260
+ if sse_task:
261
+ sse_task.cancel()
262
+
263
+ # ── 4. Track the message ──────────────────────────────
264
+ await session_mgr.increment_message_count(user_id, prompt=message_text)
265
+
266
+ # ── 5. Format and send response ───────────────────────
267
+ if not response_text or response_text == "ABORTED":
268
+ # Silent return for aborted, cancelled, or empty responses
269
+ return
270
+
271
+ # Format OpenCode output for Telegram
272
+ formatted = format_opencode_response(response_text)
273
+
274
+ # Split into chunks if too long
275
+ chunks = split_message(formatted, config.max_message_length)
276
+
277
+ for i, chunk in enumerate(chunks):
278
+ try:
279
+ await update.message.reply_text(
280
+ chunk,
281
+ parse_mode="HTML",
282
+ disable_web_page_preview=True,
283
+ )
284
+ except Exception as e:
285
+ # If HTML parsing fails, try sending as plain text
286
+ logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}")
287
+ try:
288
+ # Strip HTML tags for plain text fallback
289
+ import re
290
+ plain = re.sub(r'<[^>]+>', '', chunk)
291
+ await update.message.reply_text(
292
+ plain,
293
+ disable_web_page_preview=True,
294
+ )
295
+ except Exception as e2:
296
+ logger.error(f"Failed to send chunk {i+1} even as plain text: {e2}")
297
+
298
+ # Small delay between chunks to respect rate limits
299
+ if i < len(chunks) - 1:
300
+ await asyncio.sleep(0.5)
301
+
302
+
303
+ async def _create_session(oc_client, user_id, session_mgr, config):
304
+ """Create a new OpenCode session and register it."""
305
+ # Fetch preferred user workspace directory, falling back to base configuration path
306
+ work_dir = await session_mgr.get_user_work_dir(user_id, config.opencode_work_dir)
307
+
308
+ result = await oc_client.create_session(directory=work_dir)
309
+ if not isinstance(result, dict):
310
+ raise ValueError(f"Invalid session response from OpenCode server: {result}")
311
+
312
+ session_id = (
313
+ result.get("id")
314
+ or result.get("session_id")
315
+ or result.get("sessionId")
316
+ )
317
+ if not session_id:
318
+ raise ValueError(f"OpenCode server response did not contain a session ID: {result}")
319
+
320
+ await session_mgr.set_active_session(user_id, session_id, config.opencode_model, work_dir=work_dir)
321
+ return session_id
322
+
323
+
324
+ async def _send_to_opencode(oc_client, session_id, prompt, model):
325
+ """Send a prompt to OpenCode HTTP API.
326
+
327
+ Returns:
328
+ The response text from OpenCode, or None if the session does not exist.
329
+ """
330
+ logger.info(f"Sending to OpenCode API: session={session_id[:8]}... model={model}")
331
+ response = await oc_client.send_message(session_id, prompt, model=model)
332
+ if response is None:
333
+ return None
334
+ return response.content
335
+
336
+
337
+ async def _keep_typing(update: Update, max_seconds: int = 3600) -> None:
338
+ """Keep sending typing indicators while we wait for OpenCode.
339
+
340
+ Telegram typing indicator expires after ~5 seconds, so we
341
+ refresh it every 4 seconds.
342
+ """
343
+ # If max_seconds is 0 or less, default to 1 hour (3600 seconds)
344
+ limit = max_seconds if max_seconds and max_seconds > 0 else 3600
345
+ try:
346
+ elapsed = 0
347
+ while elapsed < limit:
348
+ await update.message.chat.send_action(ChatAction.TYPING)
349
+ await asyncio.sleep(4)
350
+ elapsed += 4
351
+ except asyncio.CancelledError:
352
+ pass # Expected when response arrives
353
+ except Exception:
354
+ pass # Don't crash on typing indicator failures
355
+
356
+
357
+ async def _listen_and_stream_events(update: Update, session_id: str, server_url: str):
358
+ """Listens to global OpenCode events via SSE and posts tool-call progress live on Telegram."""
359
+ import aiohttp
360
+ import json
361
+ import html
362
+
363
+ url = f"{server_url.rstrip('/')}/global/event"
364
+ notified_calls = set()
365
+ completed_calls = set()
366
+
367
+ def truncate(text, max_len=500):
368
+ if not text:
369
+ return ""
370
+ text = str(text)
371
+ if len(text) > max_len:
372
+ return text[:max_len] + "\n... (truncated)"
373
+ return text
374
+
375
+ async with aiohttp.ClientSession() as sse_session:
376
+ try:
377
+ async with sse_session.get(url, headers={"Accept": "text/event-stream"}) as resp:
378
+ async for line in resp.content:
379
+ line_str = line.decode('utf-8').strip()
380
+ if not line_str or not line_str.startswith("data:"):
381
+ continue
382
+
383
+ data_content = line_str[5:].strip()
384
+ try:
385
+ event_obj = json.loads(data_content)
386
+ # Check if sessionID matches
387
+ payload = event_obj.get("payload", {})
388
+ if not isinstance(payload, dict):
389
+ continue
390
+
391
+ properties = payload.get("properties", {})
392
+ if not isinstance(properties, dict):
393
+ continue
394
+
395
+ event_session_id = properties.get("sessionID", "")
396
+ if event_session_id != session_id:
397
+ continue
398
+
399
+ event_type = payload.get("type", "")
400
+ if event_type == "message.part.updated":
401
+ part = properties.get("part", {})
402
+ if not isinstance(part, dict):
403
+ continue
404
+
405
+ part_type = part.get("type", "")
406
+ if part_type == "tool":
407
+ tool_name = part.get("tool", "unknown")
408
+ call_id = part.get("callID", "unknown")
409
+ state = part.get("state", {})
410
+ if not isinstance(state, dict):
411
+ continue
412
+
413
+ status = state.get("status", "")
414
+ input_data = state.get("input", {})
415
+ output_data = state.get("output", "")
416
+ metadata = state.get("metadata", {})
417
+ if not isinstance(metadata, dict):
418
+ metadata = {}
419
+
420
+ # 1. Tool Call Started / Running
421
+ if status in ("pending", "running") and call_id not in notified_calls:
422
+ notified_calls.add(call_id)
423
+
424
+ desc = input_data.get("description", "") if isinstance(input_data, dict) else ""
425
+ desc_text = f" — <i>\"{html.escape(desc)}\"</i>" if desc else ""
426
+
427
+ # Format arguments
428
+ arg_lines = []
429
+ if isinstance(input_data, dict):
430
+ for k, v in input_data.items():
431
+ if k not in ("description", "content"):
432
+ arg_lines.append(f"<b>{html.escape(str(k))}:</b> {html.escape(truncate(str(v)))}")
433
+ args_text = "\n".join(arg_lines)
434
+
435
+ msg = (
436
+ f"🛠️ <b>Calling Tool <code>{html.escape(tool_name)}</code></b>{desc_text}\n"
437
+ )
438
+ if args_text:
439
+ msg += f"{args_text}\n"
440
+
441
+ await update.message.reply_text(msg, parse_mode="HTML")
442
+
443
+ # 2. Tool Completed
444
+ elif status == "completed" and call_id not in completed_calls:
445
+ completed_calls.add(call_id)
446
+
447
+ exit_code = metadata.get("exit", 0)
448
+ output_cleaned = truncate(str(output_data))
449
+
450
+ msg = (
451
+ f"✅ <b>Tool <code>{html.escape(tool_name)}</code> Completed</b> (Exit <code>{exit_code}</code>)\n"
452
+ )
453
+ if output_cleaned.strip():
454
+ msg += f"<pre>{html.escape(output_cleaned)}</pre>"
455
+ else:
456
+ msg += f"<i>(No output returned)</i>"
457
+
458
+ await update.message.reply_text(msg, parse_mode="HTML")
459
+
460
+ # 3. Tool Failed
461
+ elif status in ("failed", "error") and call_id not in completed_calls:
462
+ completed_calls.add(call_id)
463
+
464
+ output_cleaned = truncate(str(output_data))
465
+
466
+ msg = (
467
+ f"❌ <b>Tool <code>{html.escape(tool_name)}</code> Failed</b>\n"
468
+ )
469
+ if output_cleaned.strip():
470
+ msg += f"<pre>{html.escape(output_cleaned)}</pre>"
471
+ else:
472
+ msg += f"<i>(No error description returned)</i>"
473
+
474
+ await update.message.reply_text(msg, parse_mode="HTML")
475
+
476
+ except Exception as e:
477
+ logger.debug(f"Error parsing SSE event in listener: {e}")
478
+
479
+ except asyncio.CancelledError:
480
+ pass
481
+ except Exception as e:
482
+ logger.warning(f"Error in SSE streaming task listener: {e}")
opencode/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """OpenCode async client package.
2
+
3
+ Provides an async HTTP client for interacting with the OpenCode serve API.
4
+ """
5
+
6
+ from opencode.client import OpenCodeClient, OpenCodeMessage
7
+
8
+ __all__ = ["OpenCodeClient", "OpenCodeMessage"]