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.
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