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.
Files changed (56) hide show
  1. ragnarbot/__init__.py +6 -0
  2. ragnarbot/__main__.py +8 -0
  3. ragnarbot/agent/__init__.py +8 -0
  4. ragnarbot/agent/context.py +223 -0
  5. ragnarbot/agent/loop.py +365 -0
  6. ragnarbot/agent/memory.py +109 -0
  7. ragnarbot/agent/skills.py +228 -0
  8. ragnarbot/agent/subagent.py +241 -0
  9. ragnarbot/agent/tools/__init__.py +6 -0
  10. ragnarbot/agent/tools/base.py +102 -0
  11. ragnarbot/agent/tools/cron.py +114 -0
  12. ragnarbot/agent/tools/filesystem.py +191 -0
  13. ragnarbot/agent/tools/message.py +86 -0
  14. ragnarbot/agent/tools/registry.py +73 -0
  15. ragnarbot/agent/tools/shell.py +141 -0
  16. ragnarbot/agent/tools/spawn.py +65 -0
  17. ragnarbot/agent/tools/web.py +163 -0
  18. ragnarbot/bus/__init__.py +6 -0
  19. ragnarbot/bus/events.py +37 -0
  20. ragnarbot/bus/queue.py +81 -0
  21. ragnarbot/channels/__init__.py +6 -0
  22. ragnarbot/channels/base.py +121 -0
  23. ragnarbot/channels/manager.py +129 -0
  24. ragnarbot/channels/telegram.py +302 -0
  25. ragnarbot/cli/__init__.py +1 -0
  26. ragnarbot/cli/commands.py +568 -0
  27. ragnarbot/config/__init__.py +6 -0
  28. ragnarbot/config/loader.py +95 -0
  29. ragnarbot/config/schema.py +114 -0
  30. ragnarbot/cron/__init__.py +6 -0
  31. ragnarbot/cron/service.py +346 -0
  32. ragnarbot/cron/types.py +59 -0
  33. ragnarbot/heartbeat/__init__.py +5 -0
  34. ragnarbot/heartbeat/service.py +130 -0
  35. ragnarbot/providers/__init__.py +6 -0
  36. ragnarbot/providers/base.py +69 -0
  37. ragnarbot/providers/litellm_provider.py +135 -0
  38. ragnarbot/providers/transcription.py +67 -0
  39. ragnarbot/session/__init__.py +5 -0
  40. ragnarbot/session/manager.py +202 -0
  41. ragnarbot/skills/README.md +24 -0
  42. ragnarbot/skills/cron/SKILL.md +40 -0
  43. ragnarbot/skills/github/SKILL.md +48 -0
  44. ragnarbot/skills/skill-creator/SKILL.md +371 -0
  45. ragnarbot/skills/summarize/SKILL.md +67 -0
  46. ragnarbot/skills/tmux/SKILL.md +121 -0
  47. ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  48. ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  49. ragnarbot/skills/weather/SKILL.md +49 -0
  50. ragnarbot/utils/__init__.py +5 -0
  51. ragnarbot/utils/helpers.py +91 -0
  52. ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
  53. ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
  54. ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
  55. ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
  56. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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."""