devcopilot 0.2.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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,567 @@
1
+ """
2
+ Discord Platform Adapter
3
+
4
+ Implements MessagingPlatform for Discord using discord.py.
5
+ """
6
+
7
+ import asyncio
8
+ import contextlib
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any, cast
11
+
12
+ from loguru import logger
13
+
14
+ from core.anthropic import format_user_error_preview
15
+
16
+ from ..models import IncomingMessage
17
+ from ..rendering.discord_markdown import format_status_discord
18
+ from .base import MessagingPlatform
19
+ from .outbox import PlatformOutbox
20
+ from .voice_flow import (
21
+ VoiceNoteFlow,
22
+ VoiceNoteRequest,
23
+ audio_suffix_from_metadata,
24
+ is_audio_metadata,
25
+ )
26
+
27
+ _discord_module: Any = None
28
+ try:
29
+ import discord as _discord_import
30
+
31
+ _discord_module = _discord_import
32
+ DISCORD_AVAILABLE = True
33
+ except ImportError:
34
+ DISCORD_AVAILABLE = False
35
+
36
+ DISCORD_MESSAGE_LIMIT = 2000
37
+
38
+
39
+ def _get_discord() -> Any:
40
+ """Return the discord module. Raises if not available."""
41
+ if not DISCORD_AVAILABLE or _discord_module is None:
42
+ raise ImportError(
43
+ "discord.py is required. Install with: pip install discord.py"
44
+ )
45
+ return _discord_module
46
+
47
+
48
+ def _parse_allowed_channels(raw: str | None) -> set[str]:
49
+ """Parse comma-separated channel IDs into a set of strings."""
50
+ if not raw or not raw.strip():
51
+ return set()
52
+ return {s.strip() for s in raw.split(",") if s.strip()}
53
+
54
+
55
+ if DISCORD_AVAILABLE and _discord_module is not None:
56
+ _discord = _discord_module
57
+
58
+ class _DiscordClient(_discord.Client):
59
+ """Internal Discord client that forwards events to DiscordPlatform."""
60
+
61
+ def __init__(
62
+ self,
63
+ platform: DiscordPlatform,
64
+ intents: _discord.Intents,
65
+ ) -> None:
66
+ super().__init__(intents=intents)
67
+ self._platform = platform
68
+
69
+ async def on_ready(self) -> None:
70
+ """Called when the bot is ready."""
71
+ self._platform._connected = True
72
+ logger.info("Discord platform connected")
73
+
74
+ async def on_message(self, message: Any) -> None:
75
+ """Handle incoming Discord messages."""
76
+ await self._platform._handle_client_message(message)
77
+ else:
78
+ _DiscordClient = None
79
+
80
+
81
+ class DiscordPlatform(MessagingPlatform):
82
+ """
83
+ Discord messaging platform adapter.
84
+
85
+ Uses discord.py for Discord access.
86
+ Requires a Bot Token from Discord Developer Portal and message_content intent.
87
+ """
88
+
89
+ name = "discord"
90
+
91
+ def __init__(
92
+ self,
93
+ bot_token: str | None = None,
94
+ allowed_channel_ids: str | None = None,
95
+ *,
96
+ voice_note_enabled: bool = True,
97
+ whisper_model: str = "base",
98
+ whisper_device: str = "cpu",
99
+ hf_token: str = "",
100
+ nvidia_nim_api_key: str = "",
101
+ messaging_rate_limit: int = 1,
102
+ messaging_rate_window: float = 1.0,
103
+ log_raw_messaging_content: bool = False,
104
+ log_api_error_tracebacks: bool = False,
105
+ ):
106
+ if not DISCORD_AVAILABLE:
107
+ raise ImportError(
108
+ "discord.py is required. Install with: pip install discord.py"
109
+ )
110
+
111
+ self.bot_token = bot_token
112
+ self.allowed_channel_ids = _parse_allowed_channels(allowed_channel_ids)
113
+
114
+ if not self.bot_token:
115
+ logger.warning("DISCORD_BOT_TOKEN not set")
116
+
117
+ discord = _get_discord()
118
+ intents = discord.Intents.default()
119
+ intents.message_content = True
120
+
121
+ assert _DiscordClient is not None
122
+ self._client = _DiscordClient(self, intents)
123
+ self._message_handler: Callable[[IncomingMessage], Awaitable[None]] | None = (
124
+ None
125
+ )
126
+ self._connected = False
127
+ self._limiter: Any | None = None
128
+ self._start_task: asyncio.Task | None = None
129
+
130
+ async def send_operation(
131
+ chat_id: str,
132
+ text: str,
133
+ reply_to: str | None,
134
+ parse_mode: str | None,
135
+ message_thread_id: str | None,
136
+ ) -> str:
137
+ return await self.send_message(
138
+ chat_id,
139
+ text,
140
+ reply_to,
141
+ parse_mode,
142
+ message_thread_id,
143
+ )
144
+
145
+ async def edit_operation(
146
+ chat_id: str,
147
+ message_id: str,
148
+ text: str,
149
+ parse_mode: str | None,
150
+ ) -> None:
151
+ await self.edit_message(chat_id, message_id, text, parse_mode)
152
+
153
+ async def delete_operation(chat_id: str, message_id: str) -> None:
154
+ await self.delete_message(chat_id, message_id)
155
+
156
+ async def delete_many_operation(
157
+ chat_id: str,
158
+ message_ids: list[str],
159
+ ) -> None:
160
+ await self.delete_messages(chat_id, message_ids)
161
+
162
+ self._outbox = PlatformOutbox(
163
+ get_limiter=lambda: self._limiter,
164
+ send=send_operation,
165
+ edit=edit_operation,
166
+ delete=delete_operation,
167
+ delete_many=delete_many_operation,
168
+ )
169
+ self._voice_flow = VoiceNoteFlow(
170
+ voice_note_enabled=voice_note_enabled,
171
+ whisper_model=whisper_model,
172
+ whisper_device=whisper_device,
173
+ hf_token=hf_token,
174
+ nvidia_nim_api_key=nvidia_nim_api_key,
175
+ log_raw_messaging_content=log_raw_messaging_content,
176
+ log_api_error_tracebacks=log_api_error_tracebacks,
177
+ )
178
+ self._messaging_rate_limit = messaging_rate_limit
179
+ self._messaging_rate_window = messaging_rate_window
180
+ self._log_raw_messaging_content = log_raw_messaging_content
181
+ self._log_api_error_tracebacks = log_api_error_tracebacks
182
+
183
+ async def _handle_client_message(self, message: Any) -> None:
184
+ """Adapter entry point used by the internal discord client."""
185
+ await self._on_discord_message(message)
186
+
187
+ async def _register_pending_voice(
188
+ self, chat_id: str, voice_msg_id: str, status_msg_id: str
189
+ ) -> None:
190
+ """Register a voice note as pending transcription."""
191
+ await self._voice_flow.register_pending_voice(
192
+ chat_id,
193
+ voice_msg_id,
194
+ status_msg_id,
195
+ )
196
+
197
+ async def cancel_pending_voice(
198
+ self, chat_id: str, reply_id: str
199
+ ) -> tuple[str, str] | None:
200
+ """Cancel a pending voice transcription. Returns (voice_msg_id, status_msg_id) if found."""
201
+ return await self._voice_flow.cancel_pending_voice(chat_id, reply_id)
202
+
203
+ async def _is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
204
+ """Check if a voice note is still pending (not cancelled)."""
205
+ return await self._voice_flow.is_voice_still_pending(chat_id, voice_msg_id)
206
+
207
+ def _get_audio_attachment(self, message: Any) -> Any | None:
208
+ """Return first audio attachment, or None."""
209
+ for att in message.attachments:
210
+ if is_audio_metadata(att.filename, att.content_type):
211
+ return att
212
+ return None
213
+
214
+ async def _handle_voice_note(
215
+ self, message: Any, attachment: Any, channel_id: str
216
+ ) -> bool:
217
+ """Handle voice/audio attachment. Returns True if handled."""
218
+ user_id = str(message.author.id)
219
+ message_id = str(message.id)
220
+ reply_to = (
221
+ str(message.reference.message_id)
222
+ if message.reference and message.reference.message_id
223
+ else None
224
+ )
225
+ ct = attachment.content_type or "audio/ogg"
226
+
227
+ async def _download_to(tmp_path) -> None:
228
+ await attachment.save(str(tmp_path))
229
+
230
+ async def _reply_text(text: str) -> None:
231
+ await message.reply(text)
232
+
233
+ return await self._voice_flow.handle(
234
+ VoiceNoteRequest(
235
+ platform="discord",
236
+ chat_id=channel_id,
237
+ user_id=user_id,
238
+ message_id=message_id,
239
+ raw_event=message,
240
+ content_type=ct,
241
+ temp_suffix=audio_suffix_from_metadata(
242
+ filename=attachment.filename,
243
+ content_type=attachment.content_type,
244
+ ),
245
+ status_text=format_status_discord("Transcribing voice note..."),
246
+ reply_to_message_id=reply_to,
247
+ username=message.author.display_name,
248
+ download_to=_download_to,
249
+ reply_text=_reply_text,
250
+ ),
251
+ message_handler=self._message_handler,
252
+ queue_send_message=self.queue_send_message,
253
+ queue_delete_message=self.queue_delete_message,
254
+ )
255
+
256
+ async def _on_discord_message(self, message: Any) -> None:
257
+ """Handle incoming Discord messages."""
258
+ if message.author.bot:
259
+ return
260
+
261
+ channel_id = str(message.channel.id)
262
+
263
+ if not self.allowed_channel_ids or channel_id not in self.allowed_channel_ids:
264
+ return
265
+
266
+ # Handle voice/audio attachments when message has no text content
267
+ if not message.content:
268
+ audio_att = self._get_audio_attachment(message)
269
+ if audio_att:
270
+ await self._handle_voice_note(message, audio_att, channel_id)
271
+ return
272
+ return
273
+
274
+ user_id = str(message.author.id)
275
+ message_id = str(message.id)
276
+ reply_to = (
277
+ str(message.reference.message_id)
278
+ if message.reference and message.reference.message_id
279
+ else None
280
+ )
281
+
282
+ raw_content = message.content or ""
283
+ if self._log_raw_messaging_content:
284
+ text_preview = raw_content[:80]
285
+ if len(raw_content) > 80:
286
+ text_preview += "..."
287
+ logger.info(
288
+ "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}",
289
+ channel_id,
290
+ message_id,
291
+ reply_to,
292
+ text_preview,
293
+ )
294
+ else:
295
+ logger.info(
296
+ "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_len={}",
297
+ channel_id,
298
+ message_id,
299
+ reply_to,
300
+ len(raw_content),
301
+ )
302
+
303
+ if not self._message_handler:
304
+ return
305
+
306
+ incoming = IncomingMessage(
307
+ text=message.content,
308
+ chat_id=channel_id,
309
+ user_id=user_id,
310
+ message_id=message_id,
311
+ platform="discord",
312
+ reply_to_message_id=reply_to,
313
+ username=message.author.display_name,
314
+ raw_event=message,
315
+ )
316
+
317
+ try:
318
+ await self._message_handler(incoming)
319
+ except Exception as e:
320
+ if self._log_api_error_tracebacks:
321
+ logger.error("Error handling message: {}", e)
322
+ else:
323
+ logger.error("Error handling message: exc_type={}", type(e).__name__)
324
+ with contextlib.suppress(Exception):
325
+ await self.send_message(
326
+ channel_id,
327
+ format_status_discord("Error:", format_user_error_preview(e)),
328
+ reply_to=message_id,
329
+ )
330
+
331
+ def _truncate(self, text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str:
332
+ """Truncate text to Discord's message limit."""
333
+ if len(text) <= limit:
334
+ return text
335
+ return text[: limit - 3] + "..."
336
+
337
+ async def start(self) -> None:
338
+ """Initialize and connect to Discord."""
339
+ if not self.bot_token:
340
+ raise ValueError("DISCORD_BOT_TOKEN is required")
341
+
342
+ from ..limiter import MessagingRateLimiter
343
+
344
+ self._limiter = await MessagingRateLimiter.get_instance(
345
+ rate_limit=self._messaging_rate_limit,
346
+ rate_window=self._messaging_rate_window,
347
+ )
348
+
349
+ self._start_task = asyncio.create_task(
350
+ self._client.start(self.bot_token),
351
+ name="discord-client-start",
352
+ )
353
+
354
+ max_wait = 30
355
+ waited = 0
356
+ while not self._connected and waited < max_wait:
357
+ await asyncio.sleep(0.5)
358
+ waited += 0.5
359
+
360
+ if not self._connected:
361
+ raise RuntimeError("Discord client failed to connect within timeout")
362
+
363
+ logger.info("Discord platform started")
364
+
365
+ async def stop(self) -> None:
366
+ """Stop the bot."""
367
+ if self._client.is_closed():
368
+ self._connected = False
369
+ return
370
+
371
+ await self._client.close()
372
+ if self._start_task and not self._start_task.done():
373
+ try:
374
+ await asyncio.wait_for(self._start_task, timeout=5.0)
375
+ except TimeoutError, asyncio.CancelledError:
376
+ self._start_task.cancel()
377
+ with contextlib.suppress(asyncio.CancelledError):
378
+ await self._start_task
379
+
380
+ self._connected = False
381
+ logger.info("Discord platform stopped")
382
+
383
+ async def _send_message_raw(
384
+ self,
385
+ chat_id: str,
386
+ text: str,
387
+ reply_to: str | None = None,
388
+ parse_mode: str | None = None,
389
+ message_thread_id: str | None = None,
390
+ ) -> str:
391
+ """Send a message to a channel."""
392
+ channel = self._client.get_channel(int(chat_id))
393
+ if not channel or not hasattr(channel, "send"):
394
+ raise RuntimeError(f"Channel {chat_id} not found")
395
+
396
+ text = self._truncate(text)
397
+ channel = cast(Any, channel)
398
+
399
+ discord = _get_discord()
400
+ if reply_to:
401
+ ref = discord.MessageReference(
402
+ message_id=int(reply_to),
403
+ channel_id=int(chat_id),
404
+ )
405
+ msg = await channel.send(content=text, reference=ref)
406
+ else:
407
+ msg = await channel.send(content=text)
408
+
409
+ return str(msg.id)
410
+
411
+ async def send_message(
412
+ self,
413
+ chat_id: str,
414
+ text: str,
415
+ reply_to: str | None = None,
416
+ parse_mode: str | None = None,
417
+ message_thread_id: str | None = None,
418
+ ) -> str:
419
+ """Send a message to a channel."""
420
+ return await self._send_message_raw(
421
+ chat_id,
422
+ text,
423
+ reply_to,
424
+ parse_mode,
425
+ message_thread_id,
426
+ )
427
+
428
+ async def _edit_message_raw(
429
+ self,
430
+ chat_id: str,
431
+ message_id: str,
432
+ text: str,
433
+ parse_mode: str | None = None,
434
+ ) -> None:
435
+ """Edit an existing message."""
436
+ channel = self._client.get_channel(int(chat_id))
437
+ if not channel or not hasattr(channel, "fetch_message"):
438
+ raise RuntimeError(f"Channel {chat_id} not found")
439
+
440
+ discord = _get_discord()
441
+ channel = cast(Any, channel)
442
+ try:
443
+ msg = await channel.fetch_message(int(message_id))
444
+ except discord.NotFound:
445
+ return
446
+
447
+ text = self._truncate(text)
448
+ await msg.edit(content=text)
449
+
450
+ async def edit_message(
451
+ self,
452
+ chat_id: str,
453
+ message_id: str,
454
+ text: str,
455
+ parse_mode: str | None = None,
456
+ ) -> None:
457
+ """Edit an existing message."""
458
+ await self._edit_message_raw(chat_id, message_id, text, parse_mode)
459
+
460
+ async def _delete_message_raw(
461
+ self,
462
+ chat_id: str,
463
+ message_id: str,
464
+ ) -> None:
465
+ """Delete a message from a channel."""
466
+ channel = self._client.get_channel(int(chat_id))
467
+ if not channel or not hasattr(channel, "fetch_message"):
468
+ return
469
+
470
+ discord = _get_discord()
471
+ channel = cast(Any, channel)
472
+ try:
473
+ msg = await channel.fetch_message(int(message_id))
474
+ await msg.delete()
475
+ except discord.NotFound, discord.Forbidden:
476
+ pass
477
+
478
+ async def delete_message(
479
+ self,
480
+ chat_id: str,
481
+ message_id: str,
482
+ ) -> None:
483
+ """Delete a message from a channel."""
484
+ await self._delete_message_raw(chat_id, message_id)
485
+
486
+ async def _delete_messages_raw(self, chat_id: str, message_ids: list[str]) -> None:
487
+ """Delete multiple messages (best-effort)."""
488
+ for mid in message_ids:
489
+ await self._delete_message_raw(chat_id, mid)
490
+
491
+ async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None:
492
+ """Delete multiple messages (best-effort)."""
493
+ await self._delete_messages_raw(chat_id, message_ids)
494
+
495
+ async def queue_send_message(
496
+ self,
497
+ chat_id: str,
498
+ text: str,
499
+ reply_to: str | None = None,
500
+ parse_mode: str | None = None,
501
+ fire_and_forget: bool = True,
502
+ message_thread_id: str | None = None,
503
+ ) -> str | None:
504
+ """Enqueue a message to be sent."""
505
+ return await self._outbox.queue_send_message(
506
+ chat_id,
507
+ text,
508
+ reply_to,
509
+ parse_mode,
510
+ fire_and_forget,
511
+ message_thread_id,
512
+ )
513
+
514
+ async def queue_edit_message(
515
+ self,
516
+ chat_id: str,
517
+ message_id: str,
518
+ text: str,
519
+ parse_mode: str | None = None,
520
+ fire_and_forget: bool = True,
521
+ ) -> None:
522
+ """Enqueue a message edit."""
523
+ await self._outbox.queue_edit_message(
524
+ chat_id,
525
+ message_id,
526
+ text,
527
+ parse_mode,
528
+ fire_and_forget,
529
+ )
530
+
531
+ async def queue_delete_message(
532
+ self,
533
+ chat_id: str,
534
+ message_id: str,
535
+ fire_and_forget: bool = True,
536
+ ) -> None:
537
+ """Enqueue a message delete."""
538
+ await self._outbox.queue_delete_message(chat_id, message_id, fire_and_forget)
539
+
540
+ async def queue_delete_messages(
541
+ self,
542
+ chat_id: str,
543
+ message_ids: list[str],
544
+ fire_and_forget: bool = True,
545
+ ) -> None:
546
+ """Enqueue a bulk delete."""
547
+ await self._outbox.queue_delete_messages(
548
+ chat_id,
549
+ message_ids,
550
+ fire_and_forget,
551
+ )
552
+
553
+ def fire_and_forget(self, task: Awaitable[Any]) -> None:
554
+ """Execute a coroutine without awaiting it."""
555
+ self._outbox.fire_and_forget(task)
556
+
557
+ def on_message(
558
+ self,
559
+ handler: Callable[[IncomingMessage], Awaitable[None]],
560
+ ) -> None:
561
+ """Register a message handler callback."""
562
+ self._message_handler = handler
563
+
564
+ @property
565
+ def is_connected(self) -> bool:
566
+ """Check if connected."""
567
+ return self._connected
@@ -0,0 +1,103 @@
1
+ """Messaging platform factory.
2
+
3
+ Creates the appropriate messaging platform adapter based on configuration.
4
+ To add a new platform (e.g. Discord, Slack):
5
+ 1. Create a new class implementing MessagingPlatform in messaging/platforms/
6
+ 2. Add a case to create_messaging_platform() below
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from loguru import logger
14
+
15
+ from .base import MessagingPlatform
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class MessagingPlatformOptions:
20
+ """Typed wiring from :class:`~api.runtime.AppRuntime` into platform adapters."""
21
+
22
+ telegram_bot_token: str | None = None
23
+ allowed_telegram_user_id: str | None = None
24
+ discord_bot_token: str | None = None
25
+ allowed_discord_channels: str | None = None
26
+ voice_note_enabled: bool = True
27
+ whisper_model: str = "base"
28
+ whisper_device: str = "cpu"
29
+ hf_token: str = ""
30
+ nvidia_nim_api_key: str = ""
31
+ messaging_rate_limit: int = 1
32
+ messaging_rate_window: float = 1.0
33
+ log_raw_messaging_content: bool = False
34
+ log_api_error_tracebacks: bool = False
35
+
36
+
37
+ def create_messaging_platform(
38
+ platform_type: str,
39
+ options: MessagingPlatformOptions | None = None,
40
+ ) -> MessagingPlatform | None:
41
+ """Create a messaging platform instance based on type.
42
+
43
+ Args:
44
+ platform_type: Platform identifier (``telegram``, ``discord``, ``none``).
45
+ options: Token, allowlist, and voice / transcription settings.
46
+
47
+ Returns:
48
+ Configured :class:`MessagingPlatform` instance, or None if not configured.
49
+ """
50
+ opts = options or MessagingPlatformOptions()
51
+ if platform_type == "none":
52
+ logger.info("Messaging platform disabled by configuration")
53
+ return None
54
+
55
+ if platform_type == "telegram":
56
+ bot_token = opts.telegram_bot_token
57
+ if not bot_token:
58
+ logger.info("No Telegram bot token configured, skipping platform setup")
59
+ return None
60
+
61
+ from .telegram import TelegramPlatform
62
+
63
+ return TelegramPlatform(
64
+ bot_token=bot_token,
65
+ allowed_user_id=opts.allowed_telegram_user_id,
66
+ voice_note_enabled=opts.voice_note_enabled,
67
+ whisper_model=opts.whisper_model,
68
+ whisper_device=opts.whisper_device,
69
+ hf_token=opts.hf_token,
70
+ nvidia_nim_api_key=opts.nvidia_nim_api_key,
71
+ messaging_rate_limit=opts.messaging_rate_limit,
72
+ messaging_rate_window=opts.messaging_rate_window,
73
+ log_raw_messaging_content=opts.log_raw_messaging_content,
74
+ log_api_error_tracebacks=opts.log_api_error_tracebacks,
75
+ )
76
+
77
+ if platform_type == "discord":
78
+ bot_token = opts.discord_bot_token
79
+ if not bot_token:
80
+ logger.info("No Discord bot token configured, skipping platform setup")
81
+ return None
82
+
83
+ from .discord import DiscordPlatform
84
+
85
+ return DiscordPlatform(
86
+ bot_token=bot_token,
87
+ allowed_channel_ids=opts.allowed_discord_channels,
88
+ voice_note_enabled=opts.voice_note_enabled,
89
+ whisper_model=opts.whisper_model,
90
+ whisper_device=opts.whisper_device,
91
+ hf_token=opts.hf_token,
92
+ nvidia_nim_api_key=opts.nvidia_nim_api_key,
93
+ messaging_rate_limit=opts.messaging_rate_limit,
94
+ messaging_rate_window=opts.messaging_rate_window,
95
+ log_raw_messaging_content=opts.log_raw_messaging_content,
96
+ log_api_error_tracebacks=opts.log_api_error_tracebacks,
97
+ )
98
+
99
+ logger.warning(
100
+ f"Unknown messaging platform: '{platform_type}'. "
101
+ "Supported: 'none', 'telegram', 'discord'"
102
+ )
103
+ return None