telegram-opencode-bridge-bot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handlers/__init__.py +1 -0
- handlers/commands.py +943 -0
- handlers/messages.py +482 -0
- opencode/__init__.py +8 -0
- opencode/client.py +443 -0
- opencode/server.py +144 -0
- sessions/__init__.py +1 -0
- sessions/manager.py +342 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/METADATA +156 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/RECORD +16 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/WHEEL +5 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/entry_points.txt +2 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +1 -0
- utils/formatting.py +218 -0
- utils/security.py +115 -0
utils/formatting.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import html
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Telegram's hard limit is 4096 chars; use 4000 for safety
|
|
9
|
+
DEFAULT_MAX_LENGTH = 4000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_opencode_response(text: str) -> str:
|
|
13
|
+
"""Convert OpenCode output to Telegram-safe HTML.
|
|
14
|
+
|
|
15
|
+
Handles:
|
|
16
|
+
- Code blocks with syntax highlighting
|
|
17
|
+
- Inline code
|
|
18
|
+
- Bold/italic markdown to HTML
|
|
19
|
+
- Escaping special chars outside code blocks
|
|
20
|
+
"""
|
|
21
|
+
if not text:
|
|
22
|
+
return "<i>No response received.</i>"
|
|
23
|
+
|
|
24
|
+
# Split into code blocks and non-code segments
|
|
25
|
+
segments = _split_code_blocks(text)
|
|
26
|
+
formatted_parts = []
|
|
27
|
+
|
|
28
|
+
for is_code, content, lang in segments:
|
|
29
|
+
if is_code:
|
|
30
|
+
# Code blocks — minimal escaping (only HTML entities)
|
|
31
|
+
escaped = html.escape(content)
|
|
32
|
+
if lang:
|
|
33
|
+
formatted_parts.append(f'<pre><code class="{html.escape(lang)}">{escaped}</code></pre>')
|
|
34
|
+
else:
|
|
35
|
+
formatted_parts.append(f'<pre>{escaped}</pre>')
|
|
36
|
+
else:
|
|
37
|
+
# Regular text — convert markdown to HTML
|
|
38
|
+
formatted_parts.append(_markdown_to_html(content))
|
|
39
|
+
|
|
40
|
+
return "\n".join(formatted_parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _split_code_blocks(text: str) -> List[Tuple[bool, str, str]]:
|
|
44
|
+
"""Split text into segments: (is_code, content, language)."""
|
|
45
|
+
segments = []
|
|
46
|
+
# Match ```language\n...\n``` blocks
|
|
47
|
+
pattern = re.compile(r'```(\w*)\n?(.*?)```', re.DOTALL)
|
|
48
|
+
|
|
49
|
+
last_end = 0
|
|
50
|
+
for match in pattern.finditer(text):
|
|
51
|
+
# Text before this code block
|
|
52
|
+
before = text[last_end:match.start()]
|
|
53
|
+
if before.strip():
|
|
54
|
+
segments.append((False, before, ""))
|
|
55
|
+
|
|
56
|
+
lang = match.group(1)
|
|
57
|
+
code = match.group(2).strip()
|
|
58
|
+
segments.append((True, code, lang))
|
|
59
|
+
last_end = match.end()
|
|
60
|
+
|
|
61
|
+
# Remaining text after last code block
|
|
62
|
+
after = text[last_end:]
|
|
63
|
+
if after.strip():
|
|
64
|
+
segments.append((False, after, ""))
|
|
65
|
+
|
|
66
|
+
# If no code blocks found, return the whole text as non-code
|
|
67
|
+
if not segments:
|
|
68
|
+
segments.append((False, text, ""))
|
|
69
|
+
|
|
70
|
+
return segments
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _markdown_to_html(text: str) -> str:
|
|
74
|
+
"""Convert common markdown to Telegram HTML."""
|
|
75
|
+
# Escape HTML first
|
|
76
|
+
text = html.escape(text)
|
|
77
|
+
|
|
78
|
+
# Bold: **text** or __text__
|
|
79
|
+
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|
80
|
+
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
|
81
|
+
|
|
82
|
+
# Italic: *text* or _text_
|
|
83
|
+
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
|
84
|
+
text = re.sub(r'(?<!\w)_(.+?)_(?!\w)', r'<i>\1</i>', text)
|
|
85
|
+
|
|
86
|
+
# Inline code: `code`
|
|
87
|
+
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
|
88
|
+
|
|
89
|
+
# Headers: # Header → bold
|
|
90
|
+
text = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', text, flags=re.MULTILINE)
|
|
91
|
+
|
|
92
|
+
# Strikethrough: ~~text~~
|
|
93
|
+
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
|
|
94
|
+
|
|
95
|
+
# Links: [text](url)
|
|
96
|
+
text = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2">\1</a>', text)
|
|
97
|
+
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def split_message(text: str, max_length: int = DEFAULT_MAX_LENGTH) -> List[str]:
|
|
102
|
+
"""Split a long message into chunks that fit Telegram's limit.
|
|
103
|
+
|
|
104
|
+
Splits intelligently:
|
|
105
|
+
- At newlines when possible
|
|
106
|
+
- Never in the middle of a code block
|
|
107
|
+
- Adds page indicators for multi-part messages
|
|
108
|
+
"""
|
|
109
|
+
if len(text) <= max_length:
|
|
110
|
+
return [text]
|
|
111
|
+
|
|
112
|
+
chunks = []
|
|
113
|
+
remaining = text
|
|
114
|
+
|
|
115
|
+
while remaining:
|
|
116
|
+
if len(remaining) <= max_length:
|
|
117
|
+
chunks.append(remaining)
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
# Find a good split point
|
|
121
|
+
split_at = _find_split_point(remaining, max_length)
|
|
122
|
+
chunks.append(remaining[:split_at].rstrip())
|
|
123
|
+
remaining = remaining[split_at:].lstrip()
|
|
124
|
+
|
|
125
|
+
# Add page indicators if multiple chunks
|
|
126
|
+
if len(chunks) > 1:
|
|
127
|
+
total = len(chunks)
|
|
128
|
+
chunks = [f"{chunk}\n\n📄 <i>{i+1}/{total}</i>" for i, chunk in enumerate(chunks)]
|
|
129
|
+
|
|
130
|
+
return chunks
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _find_split_point(text: str, max_length: int) -> int:
|
|
134
|
+
"""Find the best point to split text."""
|
|
135
|
+
# Check if we're inside a code block
|
|
136
|
+
pre_open = text[:max_length].rfind('<pre')
|
|
137
|
+
pre_close = text[:max_length].rfind('</pre>')
|
|
138
|
+
|
|
139
|
+
if pre_open > pre_close:
|
|
140
|
+
# We're inside a <pre> block — split before it
|
|
141
|
+
split_at = pre_open
|
|
142
|
+
if split_at > 0:
|
|
143
|
+
return split_at
|
|
144
|
+
|
|
145
|
+
# Try to split at double newline (paragraph break)
|
|
146
|
+
search_region = text[:max_length]
|
|
147
|
+
double_nl = search_region.rfind('\n\n')
|
|
148
|
+
if double_nl > max_length // 2: # Only use if it's in the second half
|
|
149
|
+
return double_nl + 1
|
|
150
|
+
|
|
151
|
+
# Try single newline
|
|
152
|
+
single_nl = search_region.rfind('\n')
|
|
153
|
+
if single_nl > max_length // 3:
|
|
154
|
+
return single_nl + 1
|
|
155
|
+
|
|
156
|
+
# Last resort: split at space
|
|
157
|
+
space = search_region.rfind(' ')
|
|
158
|
+
if space > max_length // 3:
|
|
159
|
+
return space + 1
|
|
160
|
+
|
|
161
|
+
# Absolute last resort: hard split
|
|
162
|
+
return max_length
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def format_session_info(session: dict) -> str:
|
|
166
|
+
"""Format session info for display."""
|
|
167
|
+
active = "🟢 Active" if session.get("is_active") else "⚪ Archived"
|
|
168
|
+
session_id = session.get('session_id', 'unknown')
|
|
169
|
+
short_id = session_id[:8] if len(session_id) > 8 else session_id
|
|
170
|
+
model = session.get('model', 'default') or 'default'
|
|
171
|
+
mode = session.get('mode', 'build')
|
|
172
|
+
msgs = session.get('message_count', 0)
|
|
173
|
+
created = session.get('created_at', 'unknown')[:16] # Trim to datetime
|
|
174
|
+
name = session.get('name', '') or ''
|
|
175
|
+
|
|
176
|
+
name_line = f" <b>Conversation:</b> {html.escape(name)}\n" if name else ""
|
|
177
|
+
return (
|
|
178
|
+
f"{active} <code>{short_id}</code>\n"
|
|
179
|
+
f"{name_line}"
|
|
180
|
+
f" Model: {html.escape(model)} | Mode: {mode} | Messages: {msgs}\n"
|
|
181
|
+
f" Created: {created}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_error(error_msg: str) -> str:
|
|
186
|
+
"""Format an error message for Telegram."""
|
|
187
|
+
return f"⚠️ <b>Error</b>\n\n<pre>{html.escape(str(error_msg))}</pre>"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_status(
|
|
191
|
+
opencode_available: bool,
|
|
192
|
+
session_info: dict | None,
|
|
193
|
+
model: str,
|
|
194
|
+
) -> str:
|
|
195
|
+
"""Format bot status for display."""
|
|
196
|
+
oc_status = "🟢 Connected" if opencode_available else "🔴 Disconnected"
|
|
197
|
+
|
|
198
|
+
lines = [
|
|
199
|
+
"<b>📊 Bot Status</b>",
|
|
200
|
+
"",
|
|
201
|
+
f"OpenCode Server: {oc_status}",
|
|
202
|
+
f"Default Model: <code>{html.escape(model)}</code>",
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if session_info:
|
|
206
|
+
sid = session_info.get('session_id', 'none')[:8]
|
|
207
|
+
lines.extend([
|
|
208
|
+
"",
|
|
209
|
+
"<b>Current Session:</b>",
|
|
210
|
+
f" ID: <code>{sid}</code>",
|
|
211
|
+
f" Mode: {session_info.get('mode', 'build')}",
|
|
212
|
+
f" Messages: {session_info.get('message_count', 0)}",
|
|
213
|
+
f" Model: <code>{html.escape(session_info.get('model', 'default') or 'default')}</code>",
|
|
214
|
+
])
|
|
215
|
+
else:
|
|
216
|
+
lines.append("\nNo active session. Send a message to start one.")
|
|
217
|
+
|
|
218
|
+
return "\n".join(lines)
|
utils/security.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Callable, List, Set
|
|
5
|
+
|
|
6
|
+
from telegram import Update
|
|
7
|
+
from telegram.ext import ContextTypes
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserAuthorizer:
|
|
13
|
+
"""Manages user authorization via whitelist."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, authorized_user_ids: List[int]):
|
|
16
|
+
self._authorized: Set[int] = set(authorized_user_ids)
|
|
17
|
+
logger.info(f"Authorizer initialized with {len(self._authorized)} authorized users")
|
|
18
|
+
|
|
19
|
+
def is_authorized(self, user_id: int) -> bool:
|
|
20
|
+
return user_id in self._authorized
|
|
21
|
+
|
|
22
|
+
def add_user(self, user_id: int) -> None:
|
|
23
|
+
self._authorized.add(user_id)
|
|
24
|
+
logger.info(f"User {user_id} added to authorized list")
|
|
25
|
+
|
|
26
|
+
def remove_user(self, user_id: int) -> None:
|
|
27
|
+
self._authorized.discard(user_id)
|
|
28
|
+
logger.info(f"User {user_id} removed from authorized list")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimiter:
|
|
32
|
+
"""Simple token bucket rate limiter per user."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
|
35
|
+
self.max_requests = max_requests
|
|
36
|
+
self.window_seconds = window_seconds
|
|
37
|
+
self._requests: dict[int, list[float]] = {}
|
|
38
|
+
|
|
39
|
+
def is_allowed(self, user_id: int) -> bool:
|
|
40
|
+
now = time.time()
|
|
41
|
+
if user_id not in self._requests:
|
|
42
|
+
self._requests[user_id] = []
|
|
43
|
+
|
|
44
|
+
# Remove expired timestamps
|
|
45
|
+
self._requests[user_id] = [
|
|
46
|
+
t for t in self._requests[user_id]
|
|
47
|
+
if now - t < self.window_seconds
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
if len(self._requests[user_id]) >= self.max_requests:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
self._requests[user_id].append(now)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
def time_until_allowed(self, user_id: int) -> float:
|
|
57
|
+
if user_id not in self._requests or not self._requests[user_id]:
|
|
58
|
+
return 0.0
|
|
59
|
+
oldest = min(self._requests[user_id])
|
|
60
|
+
return max(0.0, self.window_seconds - (time.time() - oldest))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sanitize_input(text: str) -> str:
|
|
64
|
+
"""Basic input sanitization.
|
|
65
|
+
|
|
66
|
+
We don't need heavy sanitization since OpenCode handles its own security,
|
|
67
|
+
but we strip some obvious problematic patterns.
|
|
68
|
+
"""
|
|
69
|
+
if not text:
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
# Strip leading/trailing whitespace
|
|
73
|
+
text = text.strip()
|
|
74
|
+
|
|
75
|
+
# Limit length (prevent abuse)
|
|
76
|
+
max_input_length = 10000
|
|
77
|
+
if len(text) > max_input_length:
|
|
78
|
+
text = text[:max_input_length] + "\n[Input truncated]"
|
|
79
|
+
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def authorized(authorizer: UserAuthorizer, rate_limiter: RateLimiter | None = None):
|
|
84
|
+
"""Decorator to restrict handler to authorized users with optional rate limiting."""
|
|
85
|
+
def decorator(func: Callable):
|
|
86
|
+
@functools.wraps(func)
|
|
87
|
+
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
|
88
|
+
user = update.effective_user
|
|
89
|
+
if not user:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
user_id = user.id
|
|
93
|
+
|
|
94
|
+
# Check authorization
|
|
95
|
+
if not authorizer.is_authorized(user_id):
|
|
96
|
+
logger.warning(f"Unauthorized access attempt from user {user_id} ({user.username})")
|
|
97
|
+
await update.message.reply_text(
|
|
98
|
+
"🚫 You are not authorized to use this bot.\n"
|
|
99
|
+
f"Your user ID is: <code>{user_id}</code>",
|
|
100
|
+
parse_mode="HTML",
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Check rate limit
|
|
105
|
+
if rate_limiter and not rate_limiter.is_allowed(user_id):
|
|
106
|
+
wait_time = rate_limiter.time_until_allowed(user_id)
|
|
107
|
+
await update.message.reply_text(
|
|
108
|
+
f"⏳ Rate limited. Please wait {wait_time:.0f} seconds.",
|
|
109
|
+
parse_mode="HTML",
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
return await func(update, context, *args, **kwargs)
|
|
114
|
+
return wrapper
|
|
115
|
+
return decorator
|