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,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,3 @@
1
+ """Markdown rendering utilities for messaging platforms."""
2
+
3
+ __all__: list[str] = []
@@ -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)