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,295 @@
|
|
|
1
|
+
"""Shared voice-note flow for messaging platform adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from core.anthropic import format_user_error_preview
|
|
15
|
+
|
|
16
|
+
from ..models import IncomingMessage
|
|
17
|
+
from ..voice import PendingVoiceRegistry, VoiceTranscriptionService
|
|
18
|
+
|
|
19
|
+
AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
|
|
20
|
+
VOICE_DISABLED_MESSAGE = "Voice notes are disabled."
|
|
21
|
+
VOICE_TRANSCRIPTION_ERROR_MESSAGE = (
|
|
22
|
+
"Could not transcribe voice note. Please try again or send text."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MessageHandler = Callable[[IncomingMessage], Awaitable[None]]
|
|
26
|
+
QueueSend = Callable[..., Awaitable[str | None]]
|
|
27
|
+
QueueDelete = Callable[..., Awaitable[None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class VoiceNoteRequest:
|
|
32
|
+
"""Platform-normalized voice-note input."""
|
|
33
|
+
|
|
34
|
+
platform: str
|
|
35
|
+
chat_id: str
|
|
36
|
+
user_id: str
|
|
37
|
+
message_id: str
|
|
38
|
+
raw_event: Any
|
|
39
|
+
content_type: str
|
|
40
|
+
temp_suffix: str
|
|
41
|
+
status_text: str
|
|
42
|
+
download_to: Callable[[Path], Awaitable[None]]
|
|
43
|
+
reply_text: Callable[[str], Awaitable[None]]
|
|
44
|
+
reply_to_message_id: str | None = None
|
|
45
|
+
status_parse_mode: str | None = None
|
|
46
|
+
message_thread_id: str | None = None
|
|
47
|
+
username: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_audio_metadata(filename: str | None, content_type: str | None) -> bool:
|
|
51
|
+
"""Return whether attachment metadata describes an audio file."""
|
|
52
|
+
normalized_content_type = (content_type or "").lower()
|
|
53
|
+
normalized_filename = (filename or "").lower()
|
|
54
|
+
return normalized_content_type.startswith("audio/") or any(
|
|
55
|
+
normalized_filename.endswith(extension) for extension in AUDIO_EXTENSIONS
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def audio_suffix_from_metadata(
|
|
60
|
+
*,
|
|
61
|
+
filename: str | None = None,
|
|
62
|
+
content_type: str | None = None,
|
|
63
|
+
default: str = ".ogg",
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Choose a temp-file suffix from platform attachment metadata."""
|
|
66
|
+
normalized_filename = (filename or "").lower()
|
|
67
|
+
normalized_content_type = (content_type or "").lower()
|
|
68
|
+
|
|
69
|
+
if "m4a" in normalized_content_type:
|
|
70
|
+
return ".m4a"
|
|
71
|
+
if "mp4" in normalized_content_type:
|
|
72
|
+
if normalized_filename.endswith(".m4a"):
|
|
73
|
+
return ".m4a"
|
|
74
|
+
return ".mp4"
|
|
75
|
+
if "mpeg" in normalized_content_type or "mp3" in normalized_content_type:
|
|
76
|
+
return ".mp3"
|
|
77
|
+
if "wav" in normalized_content_type:
|
|
78
|
+
return ".wav"
|
|
79
|
+
|
|
80
|
+
for extension in AUDIO_EXTENSIONS:
|
|
81
|
+
if normalized_filename.endswith(extension):
|
|
82
|
+
return extension
|
|
83
|
+
return default
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class VoiceNoteFlow:
|
|
87
|
+
"""Own common voice transcription state and control flow."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
voice_note_enabled: bool,
|
|
93
|
+
whisper_model: str,
|
|
94
|
+
whisper_device: str,
|
|
95
|
+
hf_token: str,
|
|
96
|
+
nvidia_nim_api_key: str,
|
|
97
|
+
log_raw_messaging_content: bool,
|
|
98
|
+
log_api_error_tracebacks: bool,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._voice_note_enabled = voice_note_enabled
|
|
101
|
+
self._whisper_model = whisper_model
|
|
102
|
+
self._whisper_device = whisper_device
|
|
103
|
+
self._log_raw_messaging_content = log_raw_messaging_content
|
|
104
|
+
self._log_api_error_tracebacks = log_api_error_tracebacks
|
|
105
|
+
self._pending_voice = PendingVoiceRegistry()
|
|
106
|
+
self._voice_transcription = VoiceTranscriptionService(
|
|
107
|
+
hf_token=hf_token,
|
|
108
|
+
nvidia_nim_api_key=nvidia_nim_api_key,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_enabled(self) -> bool:
|
|
113
|
+
"""Return whether voice-note handling is enabled."""
|
|
114
|
+
return self._voice_note_enabled
|
|
115
|
+
|
|
116
|
+
async def reply_if_disabled(
|
|
117
|
+
self, reply_text: Callable[[str], Awaitable[None]]
|
|
118
|
+
) -> bool:
|
|
119
|
+
"""Reply with the disabled message when voice-note handling is disabled."""
|
|
120
|
+
if self._voice_note_enabled:
|
|
121
|
+
return False
|
|
122
|
+
await reply_text(VOICE_DISABLED_MESSAGE)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
async def register_pending_voice(
|
|
126
|
+
self, chat_id: str, voice_msg_id: str, status_msg_id: str
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Register a voice note as pending transcription."""
|
|
129
|
+
await self._pending_voice.register(chat_id, voice_msg_id, status_msg_id)
|
|
130
|
+
|
|
131
|
+
async def cancel_pending_voice(
|
|
132
|
+
self, chat_id: str, reply_id: str
|
|
133
|
+
) -> tuple[str, str] | None:
|
|
134
|
+
"""Cancel a pending voice transcription."""
|
|
135
|
+
return await self._pending_voice.cancel(chat_id, reply_id)
|
|
136
|
+
|
|
137
|
+
async def is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
|
|
138
|
+
"""Return whether a voice note is still pending."""
|
|
139
|
+
return await self._pending_voice.is_pending(chat_id, voice_msg_id)
|
|
140
|
+
|
|
141
|
+
async def complete_pending_voice(
|
|
142
|
+
self, chat_id: str, voice_msg_id: str, status_msg_id: str
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Mark a voice note as no longer pending."""
|
|
145
|
+
await self._pending_voice.complete(chat_id, voice_msg_id, status_msg_id)
|
|
146
|
+
|
|
147
|
+
async def handle(
|
|
148
|
+
self,
|
|
149
|
+
request: VoiceNoteRequest,
|
|
150
|
+
*,
|
|
151
|
+
message_handler: MessageHandler | None,
|
|
152
|
+
queue_send_message: QueueSend,
|
|
153
|
+
queue_delete_message: QueueDelete,
|
|
154
|
+
) -> bool:
|
|
155
|
+
"""Transcribe a voice note and hand the resulting turn to messaging."""
|
|
156
|
+
if await self.reply_if_disabled(request.reply_text):
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
if message_handler is None:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
status_msg_id = await queue_send_message(
|
|
163
|
+
request.chat_id,
|
|
164
|
+
request.status_text,
|
|
165
|
+
reply_to=request.message_id,
|
|
166
|
+
parse_mode=request.status_parse_mode,
|
|
167
|
+
fire_and_forget=False,
|
|
168
|
+
message_thread_id=request.message_thread_id,
|
|
169
|
+
)
|
|
170
|
+
status_msg_id_text = str(status_msg_id)
|
|
171
|
+
await self.register_pending_voice(
|
|
172
|
+
request.chat_id,
|
|
173
|
+
request.message_id,
|
|
174
|
+
status_msg_id_text,
|
|
175
|
+
)
|
|
176
|
+
handed_off = False
|
|
177
|
+
|
|
178
|
+
with tempfile.NamedTemporaryFile(
|
|
179
|
+
suffix=request.temp_suffix, delete=False
|
|
180
|
+
) as tmp:
|
|
181
|
+
tmp_path = Path(tmp.name)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
await request.download_to(tmp_path)
|
|
185
|
+
|
|
186
|
+
transcribed = await self._voice_transcription.transcribe(
|
|
187
|
+
tmp_path,
|
|
188
|
+
request.content_type,
|
|
189
|
+
whisper_model=self._whisper_model,
|
|
190
|
+
whisper_device=self._whisper_device,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if not await self.is_voice_still_pending(
|
|
194
|
+
request.chat_id,
|
|
195
|
+
request.message_id,
|
|
196
|
+
):
|
|
197
|
+
await queue_delete_message(request.chat_id, status_msg_id_text)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
await self.complete_pending_voice(
|
|
201
|
+
request.chat_id,
|
|
202
|
+
request.message_id,
|
|
203
|
+
status_msg_id_text,
|
|
204
|
+
)
|
|
205
|
+
handed_off = True
|
|
206
|
+
|
|
207
|
+
incoming = IncomingMessage(
|
|
208
|
+
text=transcribed,
|
|
209
|
+
chat_id=request.chat_id,
|
|
210
|
+
user_id=request.user_id,
|
|
211
|
+
message_id=request.message_id,
|
|
212
|
+
platform=request.platform,
|
|
213
|
+
reply_to_message_id=request.reply_to_message_id,
|
|
214
|
+
message_thread_id=request.message_thread_id,
|
|
215
|
+
username=request.username,
|
|
216
|
+
raw_event=request.raw_event,
|
|
217
|
+
status_message_id=status_msg_id,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
self._log_transcription(request, transcribed)
|
|
221
|
+
await message_handler(incoming)
|
|
222
|
+
return True
|
|
223
|
+
except ValueError as e:
|
|
224
|
+
await self._clear_failed_pending_voice(
|
|
225
|
+
request,
|
|
226
|
+
status_msg_id_text,
|
|
227
|
+
queue_delete_message,
|
|
228
|
+
handed_off=handed_off,
|
|
229
|
+
)
|
|
230
|
+
await request.reply_text(format_user_error_preview(e))
|
|
231
|
+
return True
|
|
232
|
+
except ImportError as e:
|
|
233
|
+
await self._clear_failed_pending_voice(
|
|
234
|
+
request,
|
|
235
|
+
status_msg_id_text,
|
|
236
|
+
queue_delete_message,
|
|
237
|
+
handed_off=handed_off,
|
|
238
|
+
)
|
|
239
|
+
await request.reply_text(format_user_error_preview(e))
|
|
240
|
+
return True
|
|
241
|
+
except Exception as e:
|
|
242
|
+
await self._clear_failed_pending_voice(
|
|
243
|
+
request,
|
|
244
|
+
status_msg_id_text,
|
|
245
|
+
queue_delete_message,
|
|
246
|
+
handed_off=handed_off,
|
|
247
|
+
)
|
|
248
|
+
if self._log_api_error_tracebacks:
|
|
249
|
+
logger.error("Voice transcription failed: {}", e)
|
|
250
|
+
else:
|
|
251
|
+
logger.error(
|
|
252
|
+
"Voice transcription failed: exc_type={}",
|
|
253
|
+
type(e).__name__,
|
|
254
|
+
)
|
|
255
|
+
await request.reply_text(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
|
|
256
|
+
return True
|
|
257
|
+
finally:
|
|
258
|
+
with contextlib.suppress(OSError):
|
|
259
|
+
tmp_path.unlink(missing_ok=True)
|
|
260
|
+
|
|
261
|
+
async def _clear_failed_pending_voice(
|
|
262
|
+
self,
|
|
263
|
+
request: VoiceNoteRequest,
|
|
264
|
+
status_msg_id: str,
|
|
265
|
+
queue_delete_message: QueueDelete,
|
|
266
|
+
*,
|
|
267
|
+
handed_off: bool,
|
|
268
|
+
) -> None:
|
|
269
|
+
await self.complete_pending_voice(
|
|
270
|
+
request.chat_id,
|
|
271
|
+
request.message_id,
|
|
272
|
+
status_msg_id,
|
|
273
|
+
)
|
|
274
|
+
if not handed_off:
|
|
275
|
+
with contextlib.suppress(Exception):
|
|
276
|
+
await queue_delete_message(request.chat_id, status_msg_id)
|
|
277
|
+
|
|
278
|
+
def _log_transcription(self, request: VoiceNoteRequest, transcribed: str) -> None:
|
|
279
|
+
label = request.platform.upper()
|
|
280
|
+
if self._log_raw_messaging_content:
|
|
281
|
+
logger.info(
|
|
282
|
+
"{}_VOICE: chat_id={} message_id={} transcribed={!r}",
|
|
283
|
+
label,
|
|
284
|
+
request.chat_id,
|
|
285
|
+
request.message_id,
|
|
286
|
+
(transcribed[:80] + "..." if len(transcribed) > 80 else transcribed),
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
logger.info(
|
|
290
|
+
"{}_VOICE: chat_id={} message_id={} transcribed_len={}",
|
|
291
|
+
label,
|
|
292
|
+
request.chat_id,
|
|
293
|
+
request.message_id,
|
|
294
|
+
len(transcribed),
|
|
295
|
+
)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Discord markdown utilities.
|
|
2
|
+
|
|
3
|
+
Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```.
|
|
4
|
+
Used by the message handler and Discord platform adapter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from markdown_it import MarkdownIt
|
|
8
|
+
|
|
9
|
+
from .markdown_tables import normalize_gfm_tables
|
|
10
|
+
|
|
11
|
+
# Discord escapes: \ * _ ` ~ | >
|
|
12
|
+
DISCORD_SPECIAL = set("\\*_`~|>")
|
|
13
|
+
|
|
14
|
+
_MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
|
|
15
|
+
_MD.enable("strikethrough")
|
|
16
|
+
_MD.enable("table")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def escape_discord(text: str) -> str:
|
|
20
|
+
"""Escape text for Discord markdown (bold, italic, etc.)."""
|
|
21
|
+
return "".join(f"\\{ch}" if ch in DISCORD_SPECIAL else ch for ch in text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def escape_discord_code(text: str) -> str:
|
|
25
|
+
"""Escape text for Discord code spans/blocks."""
|
|
26
|
+
return text.replace("\\", "\\\\").replace("`", "\\`")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def discord_bold(text: str) -> str:
|
|
30
|
+
"""Format text as bold in Discord (uses **)."""
|
|
31
|
+
return f"**{escape_discord(text)}**"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discord_code_inline(text: str) -> str:
|
|
35
|
+
"""Format text as inline code in Discord."""
|
|
36
|
+
return f"`{escape_discord_code(text)}`"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_status_discord(label: str, suffix: str | None = None) -> str:
|
|
40
|
+
"""Format a status message for Discord (label in bold, optional suffix)."""
|
|
41
|
+
base = discord_bold(label)
|
|
42
|
+
if suffix:
|
|
43
|
+
return f"{base} {escape_discord(suffix)}"
|
|
44
|
+
return base
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_status(emoji: str, label: str, suffix: str | None = None) -> str:
|
|
48
|
+
"""Format a status message with emoji for Discord (matches Telegram API)."""
|
|
49
|
+
base = f"{emoji} {discord_bold(label)}"
|
|
50
|
+
if suffix:
|
|
51
|
+
return f"{base} {escape_discord(suffix)}"
|
|
52
|
+
return base
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_markdown_to_discord(text: str) -> str:
|
|
56
|
+
"""Render common Markdown into Discord-compatible format."""
|
|
57
|
+
if not text:
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
text = normalize_gfm_tables(text)
|
|
61
|
+
tokens = _MD.parse(text)
|
|
62
|
+
|
|
63
|
+
def render_inline_table_plain(children) -> str:
|
|
64
|
+
out: list[str] = []
|
|
65
|
+
for tok in children:
|
|
66
|
+
if tok.type == "text" or tok.type == "code_inline":
|
|
67
|
+
out.append(tok.content)
|
|
68
|
+
elif tok.type in {"softbreak", "hardbreak"}:
|
|
69
|
+
out.append(" ")
|
|
70
|
+
elif tok.type == "image" and tok.content:
|
|
71
|
+
out.append(tok.content)
|
|
72
|
+
return "".join(out)
|
|
73
|
+
|
|
74
|
+
def render_inline(children) -> str:
|
|
75
|
+
out: list[str] = []
|
|
76
|
+
i = 0
|
|
77
|
+
while i < len(children):
|
|
78
|
+
tok = children[i]
|
|
79
|
+
t = tok.type
|
|
80
|
+
if t == "text":
|
|
81
|
+
out.append(escape_discord(tok.content))
|
|
82
|
+
elif t in {"softbreak", "hardbreak"}:
|
|
83
|
+
out.append("\n")
|
|
84
|
+
elif t == "em_open" or t == "em_close":
|
|
85
|
+
out.append("*")
|
|
86
|
+
elif t == "strong_open" or t == "strong_close":
|
|
87
|
+
out.append("**")
|
|
88
|
+
elif t == "s_open" or t == "s_close":
|
|
89
|
+
out.append("~~")
|
|
90
|
+
elif t == "code_inline":
|
|
91
|
+
out.append(f"`{escape_discord_code(tok.content)}`")
|
|
92
|
+
elif t == "link_open":
|
|
93
|
+
href = ""
|
|
94
|
+
if tok.attrs:
|
|
95
|
+
if isinstance(tok.attrs, dict):
|
|
96
|
+
href = tok.attrs.get("href", "")
|
|
97
|
+
else:
|
|
98
|
+
for key, val in tok.attrs:
|
|
99
|
+
if key == "href":
|
|
100
|
+
href = val
|
|
101
|
+
break
|
|
102
|
+
inner_tokens = []
|
|
103
|
+
i += 1
|
|
104
|
+
while i < len(children) and children[i].type != "link_close":
|
|
105
|
+
inner_tokens.append(children[i])
|
|
106
|
+
i += 1
|
|
107
|
+
link_text = ""
|
|
108
|
+
for child in inner_tokens:
|
|
109
|
+
if child.type == "text" or child.type == "code_inline":
|
|
110
|
+
link_text += child.content
|
|
111
|
+
out.append(f"[{escape_discord(link_text)}]({href})")
|
|
112
|
+
elif t == "image":
|
|
113
|
+
href = ""
|
|
114
|
+
alt = tok.content or ""
|
|
115
|
+
if tok.attrs:
|
|
116
|
+
if isinstance(tok.attrs, dict):
|
|
117
|
+
href = tok.attrs.get("src", "")
|
|
118
|
+
else:
|
|
119
|
+
for key, val in tok.attrs:
|
|
120
|
+
if key == "src":
|
|
121
|
+
href = val
|
|
122
|
+
break
|
|
123
|
+
if alt:
|
|
124
|
+
out.append(f"{escape_discord(alt)} ({href})")
|
|
125
|
+
else:
|
|
126
|
+
out.append(href)
|
|
127
|
+
else:
|
|
128
|
+
out.append(escape_discord(tok.content or ""))
|
|
129
|
+
i += 1
|
|
130
|
+
return "".join(out)
|
|
131
|
+
|
|
132
|
+
out: list[str] = []
|
|
133
|
+
list_stack: list[dict] = []
|
|
134
|
+
pending_prefix: str | None = None
|
|
135
|
+
blockquote_level = 0
|
|
136
|
+
in_heading = False
|
|
137
|
+
|
|
138
|
+
def apply_blockquote(val: str) -> str:
|
|
139
|
+
if blockquote_level <= 0:
|
|
140
|
+
return val
|
|
141
|
+
prefix = "> " * blockquote_level
|
|
142
|
+
return prefix + val.replace("\n", "\n" + prefix)
|
|
143
|
+
|
|
144
|
+
i = 0
|
|
145
|
+
while i < len(tokens):
|
|
146
|
+
tok = tokens[i]
|
|
147
|
+
t = tok.type
|
|
148
|
+
if t == "paragraph_open":
|
|
149
|
+
pass
|
|
150
|
+
elif t == "paragraph_close":
|
|
151
|
+
out.append("\n")
|
|
152
|
+
elif t == "heading_open":
|
|
153
|
+
in_heading = True
|
|
154
|
+
elif t == "heading_close":
|
|
155
|
+
in_heading = False
|
|
156
|
+
out.append("\n")
|
|
157
|
+
elif t == "bullet_list_open":
|
|
158
|
+
list_stack.append({"type": "bullet", "index": 1})
|
|
159
|
+
elif t == "bullet_list_close":
|
|
160
|
+
if list_stack:
|
|
161
|
+
list_stack.pop()
|
|
162
|
+
out.append("\n")
|
|
163
|
+
elif t == "ordered_list_open":
|
|
164
|
+
start = 1
|
|
165
|
+
if tok.attrs:
|
|
166
|
+
if isinstance(tok.attrs, dict):
|
|
167
|
+
val = tok.attrs.get("start")
|
|
168
|
+
if val is not None:
|
|
169
|
+
try:
|
|
170
|
+
start = int(val)
|
|
171
|
+
except TypeError, ValueError:
|
|
172
|
+
start = 1
|
|
173
|
+
else:
|
|
174
|
+
for key, val in tok.attrs:
|
|
175
|
+
if key == "start":
|
|
176
|
+
try:
|
|
177
|
+
start = int(val)
|
|
178
|
+
except TypeError, ValueError:
|
|
179
|
+
start = 1
|
|
180
|
+
break
|
|
181
|
+
list_stack.append({"type": "ordered", "index": start})
|
|
182
|
+
elif t == "ordered_list_close":
|
|
183
|
+
if list_stack:
|
|
184
|
+
list_stack.pop()
|
|
185
|
+
out.append("\n")
|
|
186
|
+
elif t == "list_item_open":
|
|
187
|
+
if list_stack:
|
|
188
|
+
top = list_stack[-1]
|
|
189
|
+
if top["type"] == "bullet":
|
|
190
|
+
pending_prefix = "- "
|
|
191
|
+
else:
|
|
192
|
+
pending_prefix = f"{top['index']}. "
|
|
193
|
+
top["index"] += 1
|
|
194
|
+
elif t == "list_item_close":
|
|
195
|
+
out.append("\n")
|
|
196
|
+
elif t == "blockquote_open":
|
|
197
|
+
blockquote_level += 1
|
|
198
|
+
elif t == "blockquote_close":
|
|
199
|
+
blockquote_level = max(0, blockquote_level - 1)
|
|
200
|
+
out.append("\n")
|
|
201
|
+
elif t == "table_open":
|
|
202
|
+
if pending_prefix:
|
|
203
|
+
out.append(apply_blockquote(pending_prefix.rstrip()))
|
|
204
|
+
out.append("\n")
|
|
205
|
+
pending_prefix = None
|
|
206
|
+
|
|
207
|
+
rows: list[list[str]] = []
|
|
208
|
+
row_is_header: list[bool] = []
|
|
209
|
+
|
|
210
|
+
j = i + 1
|
|
211
|
+
in_thead = False
|
|
212
|
+
in_row = False
|
|
213
|
+
current_row: list[str] = []
|
|
214
|
+
current_row_header = False
|
|
215
|
+
|
|
216
|
+
in_cell = False
|
|
217
|
+
cell_parts: list[str] = []
|
|
218
|
+
|
|
219
|
+
while j < len(tokens):
|
|
220
|
+
tt = tokens[j].type
|
|
221
|
+
if tt == "thead_open":
|
|
222
|
+
in_thead = True
|
|
223
|
+
elif tt == "thead_close":
|
|
224
|
+
in_thead = False
|
|
225
|
+
elif tt == "tr_open":
|
|
226
|
+
in_row = True
|
|
227
|
+
current_row = []
|
|
228
|
+
current_row_header = in_thead
|
|
229
|
+
elif tt in {"th_open", "td_open"}:
|
|
230
|
+
in_cell = True
|
|
231
|
+
cell_parts = []
|
|
232
|
+
elif tt == "inline" and in_cell:
|
|
233
|
+
cell_parts.append(
|
|
234
|
+
render_inline_table_plain(tokens[j].children or [])
|
|
235
|
+
)
|
|
236
|
+
elif tt in {"th_close", "td_close"} and in_cell:
|
|
237
|
+
cell = " ".join(cell_parts).strip()
|
|
238
|
+
current_row.append(cell)
|
|
239
|
+
in_cell = False
|
|
240
|
+
cell_parts = []
|
|
241
|
+
elif tt == "tr_close" and in_row:
|
|
242
|
+
rows.append(current_row)
|
|
243
|
+
row_is_header.append(bool(current_row_header))
|
|
244
|
+
in_row = False
|
|
245
|
+
elif tt == "table_close":
|
|
246
|
+
break
|
|
247
|
+
j += 1
|
|
248
|
+
|
|
249
|
+
if rows:
|
|
250
|
+
col_count = max((len(r) for r in rows), default=0)
|
|
251
|
+
norm_rows: list[list[str]] = []
|
|
252
|
+
for r in rows:
|
|
253
|
+
if len(r) < col_count:
|
|
254
|
+
r = r + [""] * (col_count - len(r))
|
|
255
|
+
norm_rows.append(r)
|
|
256
|
+
|
|
257
|
+
widths: list[int] = []
|
|
258
|
+
for c in range(col_count):
|
|
259
|
+
w = max((len(r[c]) for r in norm_rows), default=0)
|
|
260
|
+
widths.append(max(w, 3))
|
|
261
|
+
|
|
262
|
+
def fmt_row(
|
|
263
|
+
r: list[str], _w: list[int] = widths, _c: int = col_count
|
|
264
|
+
) -> str:
|
|
265
|
+
cells = [r[c].ljust(_w[c]) for c in range(_c)]
|
|
266
|
+
return "| " + " | ".join(cells) + " |"
|
|
267
|
+
|
|
268
|
+
def fmt_sep(_w: list[int] = widths, _c: int = col_count) -> str:
|
|
269
|
+
cells = ["-" * _w[c] for c in range(_c)]
|
|
270
|
+
return "| " + " | ".join(cells) + " |"
|
|
271
|
+
|
|
272
|
+
last_header_idx = -1
|
|
273
|
+
for idx, is_h in enumerate(row_is_header):
|
|
274
|
+
if is_h:
|
|
275
|
+
last_header_idx = idx
|
|
276
|
+
|
|
277
|
+
lines: list[str] = []
|
|
278
|
+
for idx, r in enumerate(norm_rows):
|
|
279
|
+
lines.append(fmt_row(r))
|
|
280
|
+
if idx == last_header_idx:
|
|
281
|
+
lines.append(fmt_sep())
|
|
282
|
+
|
|
283
|
+
table_text = "\n".join(lines).rstrip()
|
|
284
|
+
out.append(f"```\n{escape_discord_code(table_text)}\n```")
|
|
285
|
+
out.append("\n")
|
|
286
|
+
|
|
287
|
+
i = j + 1
|
|
288
|
+
continue
|
|
289
|
+
elif t in {"code_block", "fence"}:
|
|
290
|
+
code = escape_discord_code(tok.content.rstrip("\n"))
|
|
291
|
+
out.append(f"```\n{code}\n```")
|
|
292
|
+
out.append("\n")
|
|
293
|
+
elif t == "inline":
|
|
294
|
+
rendered = render_inline(tok.children or [])
|
|
295
|
+
if in_heading:
|
|
296
|
+
rendered = f"**{render_inline(tok.children or [])}**"
|
|
297
|
+
if pending_prefix:
|
|
298
|
+
rendered = pending_prefix + rendered
|
|
299
|
+
pending_prefix = None
|
|
300
|
+
rendered = apply_blockquote(rendered)
|
|
301
|
+
out.append(rendered)
|
|
302
|
+
else:
|
|
303
|
+
if tok.content:
|
|
304
|
+
out.append(escape_discord(tok.content))
|
|
305
|
+
i += 1
|
|
306
|
+
|
|
307
|
+
return "".join(out).rstrip()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = [
|
|
311
|
+
"discord_bold",
|
|
312
|
+
"discord_code_inline",
|
|
313
|
+
"escape_discord",
|
|
314
|
+
"escape_discord_code",
|
|
315
|
+
"format_status",
|
|
316
|
+
"format_status_discord",
|
|
317
|
+
"render_markdown_to_discord",
|
|
318
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared Markdown table pre-normalization for platform renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
|
8
|
+
_FENCE_RE = re.compile(r"^\s*```")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_gfm_table_header_line(line: str) -> bool:
|
|
12
|
+
"""Return whether a line looks like a GFM table header."""
|
|
13
|
+
if "|" not in line:
|
|
14
|
+
return False
|
|
15
|
+
if _TABLE_SEP_RE.match(line):
|
|
16
|
+
return False
|
|
17
|
+
parts = [part.strip() for part in line.strip().strip("|").split("|")]
|
|
18
|
+
return len([part for part in parts if part]) >= 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_gfm_tables(text: str) -> str:
|
|
22
|
+
"""Insert blank lines before detected tables outside fenced code blocks."""
|
|
23
|
+
lines = text.splitlines()
|
|
24
|
+
if len(lines) < 2:
|
|
25
|
+
return text
|
|
26
|
+
|
|
27
|
+
out_lines: list[str] = []
|
|
28
|
+
in_fence = False
|
|
29
|
+
|
|
30
|
+
for idx, line in enumerate(lines):
|
|
31
|
+
if _FENCE_RE.match(line):
|
|
32
|
+
in_fence = not in_fence
|
|
33
|
+
out_lines.append(line)
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
not in_fence
|
|
38
|
+
and idx + 1 < len(lines)
|
|
39
|
+
and _is_gfm_table_header_line(line)
|
|
40
|
+
and _TABLE_SEP_RE.match(lines[idx + 1])
|
|
41
|
+
and out_lines
|
|
42
|
+
and out_lines[-1].strip() != ""
|
|
43
|
+
):
|
|
44
|
+
indent_match = re.match(r"^(\s*)", line)
|
|
45
|
+
out_lines.append(indent_match.group(1) if indent_match else "")
|
|
46
|
+
|
|
47
|
+
out_lines.append(line)
|
|
48
|
+
|
|
49
|
+
return "\n".join(out_lines)
|