ragnarbot-ai 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.
- ragnarbot/__init__.py +6 -0
- ragnarbot/__main__.py +8 -0
- ragnarbot/agent/__init__.py +8 -0
- ragnarbot/agent/context.py +223 -0
- ragnarbot/agent/loop.py +365 -0
- ragnarbot/agent/memory.py +109 -0
- ragnarbot/agent/skills.py +228 -0
- ragnarbot/agent/subagent.py +241 -0
- ragnarbot/agent/tools/__init__.py +6 -0
- ragnarbot/agent/tools/base.py +102 -0
- ragnarbot/agent/tools/cron.py +114 -0
- ragnarbot/agent/tools/filesystem.py +191 -0
- ragnarbot/agent/tools/message.py +86 -0
- ragnarbot/agent/tools/registry.py +73 -0
- ragnarbot/agent/tools/shell.py +141 -0
- ragnarbot/agent/tools/spawn.py +65 -0
- ragnarbot/agent/tools/web.py +163 -0
- ragnarbot/bus/__init__.py +6 -0
- ragnarbot/bus/events.py +37 -0
- ragnarbot/bus/queue.py +81 -0
- ragnarbot/channels/__init__.py +6 -0
- ragnarbot/channels/base.py +121 -0
- ragnarbot/channels/manager.py +129 -0
- ragnarbot/channels/telegram.py +302 -0
- ragnarbot/cli/__init__.py +1 -0
- ragnarbot/cli/commands.py +568 -0
- ragnarbot/config/__init__.py +6 -0
- ragnarbot/config/loader.py +95 -0
- ragnarbot/config/schema.py +114 -0
- ragnarbot/cron/__init__.py +6 -0
- ragnarbot/cron/service.py +346 -0
- ragnarbot/cron/types.py +59 -0
- ragnarbot/heartbeat/__init__.py +5 -0
- ragnarbot/heartbeat/service.py +130 -0
- ragnarbot/providers/__init__.py +6 -0
- ragnarbot/providers/base.py +69 -0
- ragnarbot/providers/litellm_provider.py +135 -0
- ragnarbot/providers/transcription.py +67 -0
- ragnarbot/session/__init__.py +5 -0
- ragnarbot/session/manager.py +202 -0
- ragnarbot/skills/README.md +24 -0
- ragnarbot/skills/cron/SKILL.md +40 -0
- ragnarbot/skills/github/SKILL.md +48 -0
- ragnarbot/skills/skill-creator/SKILL.md +371 -0
- ragnarbot/skills/summarize/SKILL.md +67 -0
- ragnarbot/skills/tmux/SKILL.md +121 -0
- ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
- ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
- ragnarbot/skills/weather/SKILL.md +49 -0
- ragnarbot/utils/__init__.py +5 -0
- ragnarbot/utils/helpers.py +91 -0
- ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
- ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
- ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
- ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- ragnarbot_ai-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Telegram channel implementation using python-telegram-bot."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from telegram import Update
|
|
8
|
+
from telegram.ext import Application, MessageHandler, filters, ContextTypes
|
|
9
|
+
|
|
10
|
+
from ragnarbot.bus.events import OutboundMessage
|
|
11
|
+
from ragnarbot.bus.queue import MessageBus
|
|
12
|
+
from ragnarbot.channels.base import BaseChannel
|
|
13
|
+
from ragnarbot.config.schema import TelegramConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _markdown_to_telegram_html(text: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Convert markdown to Telegram-safe HTML.
|
|
19
|
+
"""
|
|
20
|
+
if not text:
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
# 1. Extract and protect code blocks (preserve content from other processing)
|
|
24
|
+
code_blocks: list[str] = []
|
|
25
|
+
def save_code_block(m: re.Match) -> str:
|
|
26
|
+
code_blocks.append(m.group(1))
|
|
27
|
+
return f"\x00CB{len(code_blocks) - 1}\x00"
|
|
28
|
+
|
|
29
|
+
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text)
|
|
30
|
+
|
|
31
|
+
# 2. Extract and protect inline code
|
|
32
|
+
inline_codes: list[str] = []
|
|
33
|
+
def save_inline_code(m: re.Match) -> str:
|
|
34
|
+
inline_codes.append(m.group(1))
|
|
35
|
+
return f"\x00IC{len(inline_codes) - 1}\x00"
|
|
36
|
+
|
|
37
|
+
text = re.sub(r'`([^`]+)`', save_inline_code, text)
|
|
38
|
+
|
|
39
|
+
# 3. Headers # Title -> just the title text
|
|
40
|
+
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
|
|
41
|
+
|
|
42
|
+
# 4. Blockquotes > text -> just the text (before HTML escaping)
|
|
43
|
+
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
# 5. Escape HTML special characters
|
|
46
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
47
|
+
|
|
48
|
+
# 6. Links [text](url) - must be before bold/italic to handle nested cases
|
|
49
|
+
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
|
50
|
+
|
|
51
|
+
# 7. Bold **text** or __text__
|
|
52
|
+
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|
53
|
+
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
|
54
|
+
|
|
55
|
+
# 8. Italic _text_ (avoid matching inside words like some_var_name)
|
|
56
|
+
text = re.sub(r'(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])', r'<i>\1</i>', text)
|
|
57
|
+
|
|
58
|
+
# 9. Strikethrough ~~text~~
|
|
59
|
+
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
|
|
60
|
+
|
|
61
|
+
# 10. Bullet lists - item -> • item
|
|
62
|
+
text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
|
|
63
|
+
|
|
64
|
+
# 11. Restore inline code with HTML tags
|
|
65
|
+
for i, code in enumerate(inline_codes):
|
|
66
|
+
# Escape HTML in code content
|
|
67
|
+
escaped = code.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
68
|
+
text = text.replace(f"\x00IC{i}\x00", f"<code>{escaped}</code>")
|
|
69
|
+
|
|
70
|
+
# 12. Restore code blocks with HTML tags
|
|
71
|
+
for i, code in enumerate(code_blocks):
|
|
72
|
+
# Escape HTML in code content
|
|
73
|
+
escaped = code.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
74
|
+
text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")
|
|
75
|
+
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TelegramChannel(BaseChannel):
|
|
80
|
+
"""
|
|
81
|
+
Telegram channel using long polling.
|
|
82
|
+
|
|
83
|
+
Simple and reliable - no webhook/public IP needed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name = "telegram"
|
|
87
|
+
|
|
88
|
+
def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""):
|
|
89
|
+
super().__init__(config, bus)
|
|
90
|
+
self.config: TelegramConfig = config
|
|
91
|
+
self.groq_api_key = groq_api_key
|
|
92
|
+
self._app: Application | None = None
|
|
93
|
+
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
|
94
|
+
|
|
95
|
+
async def start(self) -> None:
|
|
96
|
+
"""Start the Telegram bot with long polling."""
|
|
97
|
+
if not self.config.token:
|
|
98
|
+
logger.error("Telegram bot token not configured")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._running = True
|
|
102
|
+
|
|
103
|
+
# Build the application
|
|
104
|
+
self._app = (
|
|
105
|
+
Application.builder()
|
|
106
|
+
.token(self.config.token)
|
|
107
|
+
.build()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Add message handler for text, photos, voice, documents
|
|
111
|
+
self._app.add_handler(
|
|
112
|
+
MessageHandler(
|
|
113
|
+
(filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL)
|
|
114
|
+
& ~filters.COMMAND,
|
|
115
|
+
self._on_message
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Add /start command handler
|
|
120
|
+
from telegram.ext import CommandHandler
|
|
121
|
+
self._app.add_handler(CommandHandler("start", self._on_start))
|
|
122
|
+
|
|
123
|
+
logger.info("Starting Telegram bot (polling mode)...")
|
|
124
|
+
|
|
125
|
+
# Initialize and start polling
|
|
126
|
+
await self._app.initialize()
|
|
127
|
+
await self._app.start()
|
|
128
|
+
|
|
129
|
+
# Get bot info
|
|
130
|
+
bot_info = await self._app.bot.get_me()
|
|
131
|
+
logger.info(f"Telegram bot @{bot_info.username} connected")
|
|
132
|
+
|
|
133
|
+
# Start polling (this runs until stopped)
|
|
134
|
+
await self._app.updater.start_polling(
|
|
135
|
+
allowed_updates=["message"],
|
|
136
|
+
drop_pending_updates=True # Ignore old messages on startup
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Keep running until stopped
|
|
140
|
+
while self._running:
|
|
141
|
+
await asyncio.sleep(1)
|
|
142
|
+
|
|
143
|
+
async def stop(self) -> None:
|
|
144
|
+
"""Stop the Telegram bot."""
|
|
145
|
+
self._running = False
|
|
146
|
+
|
|
147
|
+
if self._app:
|
|
148
|
+
logger.info("Stopping Telegram bot...")
|
|
149
|
+
await self._app.updater.stop()
|
|
150
|
+
await self._app.stop()
|
|
151
|
+
await self._app.shutdown()
|
|
152
|
+
self._app = None
|
|
153
|
+
|
|
154
|
+
async def send(self, msg: OutboundMessage) -> None:
|
|
155
|
+
"""Send a message through Telegram."""
|
|
156
|
+
if not self._app:
|
|
157
|
+
logger.warning("Telegram bot not running")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
# chat_id should be the Telegram chat ID (integer)
|
|
162
|
+
chat_id = int(msg.chat_id)
|
|
163
|
+
# Convert markdown to Telegram HTML
|
|
164
|
+
html_content = _markdown_to_telegram_html(msg.content)
|
|
165
|
+
await self._app.bot.send_message(
|
|
166
|
+
chat_id=chat_id,
|
|
167
|
+
text=html_content,
|
|
168
|
+
parse_mode="HTML"
|
|
169
|
+
)
|
|
170
|
+
except ValueError:
|
|
171
|
+
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
# Fallback to plain text if HTML parsing fails
|
|
174
|
+
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
|
175
|
+
try:
|
|
176
|
+
await self._app.bot.send_message(
|
|
177
|
+
chat_id=int(msg.chat_id),
|
|
178
|
+
text=msg.content
|
|
179
|
+
)
|
|
180
|
+
except Exception as e2:
|
|
181
|
+
logger.error(f"Error sending Telegram message: {e2}")
|
|
182
|
+
|
|
183
|
+
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
184
|
+
"""Handle /start command."""
|
|
185
|
+
if not update.message or not update.effective_user:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
user = update.effective_user
|
|
189
|
+
await update.message.reply_text(
|
|
190
|
+
f"👋 Hi {user.first_name}! I'm ragnarbot.\n\n"
|
|
191
|
+
"Send me a message and I'll respond!"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
195
|
+
"""Handle incoming messages (text, photos, voice, documents)."""
|
|
196
|
+
if not update.message or not update.effective_user:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
message = update.message
|
|
200
|
+
user = update.effective_user
|
|
201
|
+
chat_id = message.chat_id
|
|
202
|
+
|
|
203
|
+
# Use stable numeric ID, but keep username for allowlist compatibility
|
|
204
|
+
sender_id = str(user.id)
|
|
205
|
+
if user.username:
|
|
206
|
+
sender_id = f"{sender_id}|{user.username}"
|
|
207
|
+
|
|
208
|
+
# Store chat_id for replies
|
|
209
|
+
self._chat_ids[sender_id] = chat_id
|
|
210
|
+
|
|
211
|
+
# Build content from text and/or media
|
|
212
|
+
content_parts = []
|
|
213
|
+
media_paths = []
|
|
214
|
+
|
|
215
|
+
# Text content
|
|
216
|
+
if message.text:
|
|
217
|
+
content_parts.append(message.text)
|
|
218
|
+
if message.caption:
|
|
219
|
+
content_parts.append(message.caption)
|
|
220
|
+
|
|
221
|
+
# Handle media files
|
|
222
|
+
media_file = None
|
|
223
|
+
media_type = None
|
|
224
|
+
|
|
225
|
+
if message.photo:
|
|
226
|
+
media_file = message.photo[-1] # Largest photo
|
|
227
|
+
media_type = "image"
|
|
228
|
+
elif message.voice:
|
|
229
|
+
media_file = message.voice
|
|
230
|
+
media_type = "voice"
|
|
231
|
+
elif message.audio:
|
|
232
|
+
media_file = message.audio
|
|
233
|
+
media_type = "audio"
|
|
234
|
+
elif message.document:
|
|
235
|
+
media_file = message.document
|
|
236
|
+
media_type = "file"
|
|
237
|
+
|
|
238
|
+
# Download media if present
|
|
239
|
+
if media_file and self._app:
|
|
240
|
+
try:
|
|
241
|
+
file = await self._app.bot.get_file(media_file.file_id)
|
|
242
|
+
ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None))
|
|
243
|
+
|
|
244
|
+
# Save to workspace/media/
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
media_dir = Path.home() / ".ragnarbot" / "media"
|
|
247
|
+
media_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
|
|
249
|
+
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
|
250
|
+
await file.download_to_drive(str(file_path))
|
|
251
|
+
|
|
252
|
+
media_paths.append(str(file_path))
|
|
253
|
+
|
|
254
|
+
# Handle voice transcription
|
|
255
|
+
if media_type == "voice" or media_type == "audio":
|
|
256
|
+
from ragnarbot.providers.transcription import GroqTranscriptionProvider
|
|
257
|
+
transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
|
|
258
|
+
transcription = await transcriber.transcribe(file_path)
|
|
259
|
+
if transcription:
|
|
260
|
+
logger.info(f"Transcribed {media_type}: {transcription[:50]}...")
|
|
261
|
+
content_parts.append(f"[transcription: {transcription}]")
|
|
262
|
+
else:
|
|
263
|
+
content_parts.append(f"[{media_type}: {file_path}]")
|
|
264
|
+
else:
|
|
265
|
+
content_parts.append(f"[{media_type}: {file_path}]")
|
|
266
|
+
|
|
267
|
+
logger.debug(f"Downloaded {media_type} to {file_path}")
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.error(f"Failed to download media: {e}")
|
|
270
|
+
content_parts.append(f"[{media_type}: download failed]")
|
|
271
|
+
|
|
272
|
+
content = "\n".join(content_parts) if content_parts else "[empty message]"
|
|
273
|
+
|
|
274
|
+
logger.debug(f"Telegram message from {sender_id}: {content[:50]}...")
|
|
275
|
+
|
|
276
|
+
# Forward to the message bus
|
|
277
|
+
await self._handle_message(
|
|
278
|
+
sender_id=sender_id,
|
|
279
|
+
chat_id=str(chat_id),
|
|
280
|
+
content=content,
|
|
281
|
+
media=media_paths,
|
|
282
|
+
metadata={
|
|
283
|
+
"message_id": message.message_id,
|
|
284
|
+
"user_id": user.id,
|
|
285
|
+
"username": user.username,
|
|
286
|
+
"first_name": user.first_name,
|
|
287
|
+
"is_group": message.chat.type != "private"
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
|
|
292
|
+
"""Get file extension based on media type."""
|
|
293
|
+
if mime_type:
|
|
294
|
+
ext_map = {
|
|
295
|
+
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
|
296
|
+
"audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a",
|
|
297
|
+
}
|
|
298
|
+
if mime_type in ext_map:
|
|
299
|
+
return ext_map[mime_type]
|
|
300
|
+
|
|
301
|
+
type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""}
|
|
302
|
+
return type_map.get(media_type, "")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for ragnarbot."""
|