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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- 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
|