codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -12,6 +12,16 @@ from ...core.circuit_breaker import CircuitBreaker
12
12
  from ...core.exceptions import CodexError, PermanentError, TransientError
13
13
  from ...core.logging_utils import log_event
14
14
  from ...core.retry import retry_transient
15
+ from .api_schemas import (
16
+ TelegramAudioSchema,
17
+ TelegramDocumentSchema,
18
+ TelegramMessageEntitySchema,
19
+ TelegramPhotoSizeSchema,
20
+ TelegramVoiceSchema,
21
+ parse_callback_query_payload,
22
+ parse_message_payload,
23
+ parse_update_payload,
24
+ )
15
25
  from .constants import TELEGRAM_CALLBACK_DATA_LIMIT, TELEGRAM_MAX_MESSAGE_LENGTH
16
26
  from .retry import _extract_retry_after_seconds
17
27
 
@@ -120,6 +130,12 @@ class TelegramMessage:
120
130
  voice: Optional[TelegramVoice] = None
121
131
  media_group_id: Optional[str] = None
122
132
 
133
+ # Extra metadata used for trigger gating / UX (optional, depends on update payload).
134
+ chat_type: Optional[str] = None
135
+ reply_to_message_id: Optional[int] = None
136
+ reply_to_is_bot: bool = False
137
+ reply_to_username: Optional[str] = None
138
+
123
139
 
124
140
  @dataclass(frozen=True)
125
141
  class TelegramCallbackQuery:
@@ -227,12 +243,6 @@ class ReviewCommitCallback:
227
243
  sha: str
228
244
 
229
245
 
230
- @dataclass(frozen=True)
231
- class PrFlowStartCallback:
232
- slug: str
233
- number: int
234
-
235
-
236
246
  @dataclass(frozen=True)
237
247
  class CancelCallback:
238
248
  kind: str
@@ -249,6 +259,17 @@ class PageCallback:
249
259
  page: int
250
260
 
251
261
 
262
+ @dataclass(frozen=True)
263
+ class FlowCallback:
264
+ action: str
265
+ run_id: Optional[str] = None
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class FlowRunCallback:
270
+ run_id: str
271
+
272
+
252
273
  def parse_command(
253
274
  text: Optional[str],
254
275
  *,
@@ -323,111 +344,123 @@ def is_interrupt_alias(text: Optional[str]) -> bool:
323
344
 
324
345
 
325
346
  def parse_update(update: dict[str, Any]) -> Optional[TelegramUpdate]:
326
- update_id = update.get("update_id")
327
- if not isinstance(update_id, int):
347
+ try:
348
+ schema = parse_update_payload(update)
349
+ except Exception:
328
350
  return None
329
- message = _parse_message(update_id, update.get("message"), edited=False)
351
+ message = _parse_message(schema.update_id, schema.message, edited=False)
330
352
  if message is None:
331
- message = _parse_message(update_id, update.get("edited_message"), edited=True)
332
- callback = _parse_callback(update_id, update.get("callback_query"))
353
+ message = _parse_message(schema.update_id, schema.edited_message, edited=True)
354
+ callback = _parse_callback(schema.update_id, schema.callback_query)
333
355
  if message is None and callback is None:
334
356
  return None
335
- return TelegramUpdate(update_id=update_id, message=message, callback=callback)
357
+ return TelegramUpdate(
358
+ update_id=schema.update_id, message=message, callback=callback
359
+ )
336
360
 
337
361
 
338
362
  def _parse_message(
339
363
  update_id: int, payload: Any, *, edited: bool = False
340
364
  ) -> Optional[TelegramMessage]:
341
- if not isinstance(payload, dict):
365
+ schema = parse_message_payload(payload)
366
+ if schema is None:
342
367
  return None
343
- message_id = payload.get("message_id")
344
- chat = payload.get("chat")
345
- if not isinstance(message_id, int) or not isinstance(chat, dict):
346
- return None
347
- chat_id = chat.get("id")
368
+
369
+ chat_id = schema.chat.get("id") if isinstance(schema.chat, dict) else None
348
370
  if not isinstance(chat_id, int):
349
371
  return None
350
- thread_id = payload.get("message_thread_id")
351
- if thread_id is not None and not isinstance(thread_id, int):
352
- thread_id = None
353
- sender = payload.get("from")
354
- from_user_id = sender.get("id") if isinstance(sender, dict) else None
372
+
373
+ chat_type = schema.chat.get("type") if isinstance(schema.chat, dict) else None
374
+ if chat_type is not None and not isinstance(chat_type, str):
375
+ chat_type = None
376
+
377
+ reply_to_message_id: Optional[int] = None
378
+ reply_to_is_bot = False
379
+ reply_to_username: Optional[str] = None
380
+ if isinstance(schema.reply_to_message, dict):
381
+ rmid = schema.reply_to_message.get("message_id")
382
+ if isinstance(rmid, int):
383
+ reply_to_message_id = rmid
384
+ reply_from = schema.reply_to_message.get("from")
385
+ if isinstance(reply_from, dict):
386
+ is_bot = reply_from.get("is_bot")
387
+ if isinstance(is_bot, bool):
388
+ reply_to_is_bot = is_bot
389
+ username = reply_from.get("username")
390
+ if isinstance(username, str):
391
+ reply_to_username = username
392
+
393
+ from_user_id = (
394
+ schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
395
+ )
355
396
  if from_user_id is not None and not isinstance(from_user_id, int):
356
397
  from_user_id = None
357
- text = payload.get("text")
358
- if text is not None and not isinstance(text, str):
359
- text = None
360
- caption = payload.get("caption")
361
- if caption is not None and not isinstance(caption, str):
362
- caption = None
363
- entities = _parse_entities(payload.get("entities"))
364
- caption_entities = _parse_entities(payload.get("caption_entities"))
365
- photos = _parse_photo_sizes(payload.get("photo"))
366
- document = _parse_document(payload.get("document"))
367
- audio = _parse_audio(payload.get("audio"))
368
- voice = _parse_voice(payload.get("voice"))
369
- media_group_id = payload.get("media_group_id")
370
- if media_group_id is not None and not isinstance(media_group_id, str):
371
- media_group_id = None
372
- date = payload.get("date")
373
- if date is not None and not isinstance(date, int):
374
- date = None
375
- is_topic_message = bool(payload.get("is_topic_message"))
398
+
399
+ entities = _parse_entities(schema.entities)
400
+ caption_entities = _parse_entities(schema.caption_entities)
401
+ photos = _parse_photo_sizes(schema.photo)
402
+ document = _parse_document(schema.document)
403
+ audio = _parse_audio(schema.audio)
404
+ voice = _parse_voice(schema.voice)
405
+
376
406
  return TelegramMessage(
377
407
  update_id=update_id,
378
- message_id=message_id,
408
+ message_id=schema.message_id,
379
409
  chat_id=chat_id,
380
- thread_id=thread_id,
410
+ thread_id=schema.message_thread_id,
381
411
  from_user_id=from_user_id,
382
- text=text,
383
- date=date,
384
- is_topic_message=is_topic_message,
412
+ text=schema.text,
413
+ date=schema.date,
414
+ is_topic_message=schema.is_topic_message,
385
415
  is_edited=edited,
386
- caption=caption,
416
+ caption=schema.caption,
387
417
  entities=entities,
388
418
  caption_entities=caption_entities,
389
419
  photos=photos,
390
420
  document=document,
391
421
  audio=audio,
392
422
  voice=voice,
393
- media_group_id=media_group_id,
423
+ media_group_id=schema.media_group_id,
424
+ chat_type=chat_type,
425
+ reply_to_message_id=reply_to_message_id,
426
+ reply_to_is_bot=reply_to_is_bot,
427
+ reply_to_username=reply_to_username,
394
428
  )
395
429
 
396
430
 
397
431
  def _parse_callback(update_id: int, payload: Any) -> Optional[TelegramCallbackQuery]:
398
- if not isinstance(payload, dict):
432
+ schema = parse_callback_query_payload(payload)
433
+ if schema is None:
399
434
  return None
400
- callback_id = payload.get("id")
401
- if not isinstance(callback_id, str):
402
- return None
403
- sender = payload.get("from")
404
- from_user_id = sender.get("id") if isinstance(sender, dict) else None
435
+
436
+ from_user_id = (
437
+ schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
438
+ )
405
439
  if from_user_id is not None and not isinstance(from_user_id, int):
406
440
  from_user_id = None
407
- data = payload.get("data")
408
- if data is not None and not isinstance(data, str):
409
- data = None
410
- message = payload.get("message")
441
+
411
442
  message_id = None
412
443
  chat_id = None
413
444
  thread_id = None
414
- if isinstance(message, dict):
415
- message_id = message.get("message_id")
416
- chat = message.get("chat")
445
+ if isinstance(schema.message, dict):
446
+ message_id = schema.message.get("message_id")
447
+ chat = schema.message.get("chat")
417
448
  if isinstance(chat, dict):
418
449
  chat_id = chat.get("id")
419
- thread_id = message.get("message_thread_id")
450
+ thread_id = schema.message.get("message_thread_id")
451
+
420
452
  if message_id is not None and not isinstance(message_id, int):
421
453
  message_id = None
422
454
  if chat_id is not None and not isinstance(chat_id, int):
423
455
  chat_id = None
424
456
  if thread_id is not None and not isinstance(thread_id, int):
425
457
  thread_id = None
458
+
426
459
  return TelegramCallbackQuery(
427
460
  update_id=update_id,
428
- callback_id=callback_id,
461
+ callback_id=schema.id,
429
462
  from_user_id=from_user_id,
430
- data=data,
463
+ data=schema.data,
431
464
  message_id=message_id,
432
465
  chat_id=chat_id,
433
466
  thread_id=thread_id,
@@ -441,26 +474,17 @@ def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
441
474
  for item in payload:
442
475
  if not isinstance(item, dict):
443
476
  continue
444
- file_id = item.get("file_id")
445
- if not isinstance(file_id, str) or not file_id:
446
- continue
447
- file_unique_id = item.get("file_unique_id")
448
- if file_unique_id is not None and not isinstance(file_unique_id, str):
449
- file_unique_id = None
450
- width = item.get("width")
451
- height = item.get("height")
452
- if not isinstance(width, int) or not isinstance(height, int):
477
+ try:
478
+ schema = TelegramPhotoSizeSchema.model_validate(item)
479
+ except Exception:
453
480
  continue
454
- file_size = item.get("file_size")
455
- if file_size is not None and not isinstance(file_size, int):
456
- file_size = None
457
481
  sizes.append(
458
482
  TelegramPhotoSize(
459
- file_id=file_id,
460
- file_unique_id=file_unique_id,
461
- width=width,
462
- height=height,
463
- file_size=file_size,
483
+ file_id=schema.file_id,
484
+ file_unique_id=schema.file_unique_id,
485
+ width=schema.width,
486
+ height=schema.height,
487
+ file_size=schema.file_size,
464
488
  )
465
489
  )
466
490
  return tuple(sizes)
@@ -469,85 +493,49 @@ def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
469
493
  def _parse_document(payload: Any) -> Optional[TelegramDocument]:
470
494
  if not isinstance(payload, dict):
471
495
  return None
472
- file_id = payload.get("file_id")
473
- if not isinstance(file_id, str) or not file_id:
496
+ try:
497
+ schema = TelegramDocumentSchema.model_validate(payload)
498
+ except Exception:
474
499
  return None
475
- file_unique_id = payload.get("file_unique_id")
476
- if file_unique_id is not None and not isinstance(file_unique_id, str):
477
- file_unique_id = None
478
- file_name = payload.get("file_name")
479
- if file_name is not None and not isinstance(file_name, str):
480
- file_name = None
481
- mime_type = payload.get("mime_type")
482
- if mime_type is not None and not isinstance(mime_type, str):
483
- mime_type = None
484
- file_size = payload.get("file_size")
485
- if file_size is not None and not isinstance(file_size, int):
486
- file_size = None
487
500
  return TelegramDocument(
488
- file_id=file_id,
489
- file_unique_id=file_unique_id,
490
- file_name=file_name,
491
- mime_type=mime_type,
492
- file_size=file_size,
501
+ file_id=schema.file_id,
502
+ file_unique_id=schema.file_unique_id,
503
+ file_name=schema.file_name,
504
+ mime_type=schema.mime_type,
505
+ file_size=schema.file_size,
493
506
  )
494
507
 
495
508
 
496
509
  def _parse_audio(payload: Any) -> Optional[TelegramAudio]:
497
510
  if not isinstance(payload, dict):
498
511
  return None
499
- file_id = payload.get("file_id")
500
- if not isinstance(file_id, str) or not file_id:
512
+ try:
513
+ schema = TelegramAudioSchema.model_validate(payload)
514
+ except Exception:
501
515
  return None
502
- file_unique_id = payload.get("file_unique_id")
503
- if file_unique_id is not None and not isinstance(file_unique_id, str):
504
- file_unique_id = None
505
- duration = payload.get("duration")
506
- if duration is not None and not isinstance(duration, int):
507
- duration = None
508
- file_name = payload.get("file_name")
509
- if file_name is not None and not isinstance(file_name, str):
510
- file_name = None
511
- mime_type = payload.get("mime_type")
512
- if mime_type is not None and not isinstance(mime_type, str):
513
- mime_type = None
514
- file_size = payload.get("file_size")
515
- if file_size is not None and not isinstance(file_size, int):
516
- file_size = None
517
516
  return TelegramAudio(
518
- file_id=file_id,
519
- file_unique_id=file_unique_id,
520
- duration=duration,
521
- file_name=file_name,
522
- mime_type=mime_type,
523
- file_size=file_size,
517
+ file_id=schema.file_id,
518
+ file_unique_id=schema.file_unique_id,
519
+ duration=schema.duration,
520
+ file_name=schema.file_name,
521
+ mime_type=schema.mime_type,
522
+ file_size=schema.file_size,
524
523
  )
525
524
 
526
525
 
527
526
  def _parse_voice(payload: Any) -> Optional[TelegramVoice]:
528
527
  if not isinstance(payload, dict):
529
528
  return None
530
- file_id = payload.get("file_id")
531
- if not isinstance(file_id, str) or not file_id:
529
+ try:
530
+ schema = TelegramVoiceSchema.model_validate(payload)
531
+ except Exception:
532
532
  return None
533
- file_unique_id = payload.get("file_unique_id")
534
- if file_unique_id is not None and not isinstance(file_unique_id, str):
535
- file_unique_id = None
536
- duration = payload.get("duration")
537
- if duration is not None and not isinstance(duration, int):
538
- duration = None
539
- mime_type = payload.get("mime_type")
540
- if mime_type is not None and not isinstance(mime_type, str):
541
- mime_type = None
542
- file_size = payload.get("file_size")
543
- if file_size is not None and not isinstance(file_size, int):
544
- file_size = None
545
533
  return TelegramVoice(
546
- file_id=file_id,
547
- file_unique_id=file_unique_id,
548
- duration=duration,
549
- mime_type=mime_type,
550
- file_size=file_size,
534
+ file_id=schema.file_id,
535
+ file_unique_id=schema.file_unique_id,
536
+ duration=schema.duration,
537
+ mime_type=schema.mime_type,
538
+ file_size=schema.file_size,
551
539
  )
552
540
 
553
541
 
@@ -558,14 +546,15 @@ def _parse_entities(payload: Any) -> tuple[TelegramMessageEntity, ...]:
558
546
  for item in payload:
559
547
  if not isinstance(item, dict):
560
548
  continue
561
- kind = item.get("type")
562
- offset = item.get("offset")
563
- length = item.get("length")
564
- if not isinstance(kind, str):
565
- continue
566
- if not isinstance(offset, int) or not isinstance(length, int):
549
+ try:
550
+ schema = TelegramMessageEntitySchema.model_validate(item)
551
+ except Exception:
567
552
  continue
568
- entities.append(TelegramMessageEntity(type=kind, offset=offset, length=length))
553
+ entities.append(
554
+ TelegramMessageEntity(
555
+ type=schema.type, offset=schema.offset, length=schema.length
556
+ )
557
+ )
569
558
  return tuple(entities)
570
559
 
571
560
 
@@ -757,14 +746,29 @@ def encode_page_callback(kind: str, page: int) -> str:
757
746
  return data
758
747
 
759
748
 
760
- def encode_pr_flow_start_callback(slug: str, number: int) -> str:
761
- data = f"pr_flow_start:{slug}#{number}"
749
+ def encode_compact_callback(action: str) -> str:
750
+ data = f"compact:{action}"
751
+ _validate_callback_data(data)
752
+ return data
753
+
754
+
755
+ def encode_flow_callback(action: str, run_id: Optional[str] = None) -> str:
756
+ action = str(action or "").strip()
757
+ if not action:
758
+ raise ValueError("flow action required")
759
+ if run_id:
760
+ data = f"flow:{action}:{run_id}"
761
+ else:
762
+ data = f"flow:{action}"
762
763
  _validate_callback_data(data)
763
764
  return data
764
765
 
765
766
 
766
- def encode_compact_callback(action: str) -> str:
767
- data = f"compact:{action}"
767
+ def encode_flow_run_callback(run_id: str) -> str:
768
+ run_id = str(run_id or "").strip()
769
+ if not run_id:
770
+ raise ValueError("flow run id required")
771
+ data = f"flow_run:{run_id}"
768
772
  _validate_callback_data(data)
769
773
  return data
770
774
 
@@ -786,9 +790,10 @@ def parse_callback_data(
786
790
  UpdateCallback,
787
791
  UpdateConfirmCallback,
788
792
  ReviewCommitCallback,
789
- PrFlowStartCallback,
790
793
  CancelCallback,
791
794
  CompactCallback,
795
+ FlowCallback,
796
+ FlowRunCallback,
792
797
  PageCallback,
793
798
  ]
794
799
  ]:
@@ -868,16 +873,6 @@ def parse_callback_data(
868
873
  if not sha:
869
874
  return None
870
875
  return ReviewCommitCallback(sha=sha)
871
- if data.startswith("pr_flow_start:"):
872
- _, _, rest = data.partition(":")
873
- if not rest:
874
- return None
875
- if "#" not in rest:
876
- return None
877
- slug, _, number_str = rest.partition("#")
878
- if not slug or not number_str or not number_str.isdigit():
879
- return None
880
- return PrFlowStartCallback(slug=slug, number=int(number_str))
881
876
  if data.startswith("cancel:"):
882
877
  _, _, kind = data.partition(":")
883
878
  if not kind:
@@ -896,6 +891,19 @@ def parse_callback_data(
896
891
  if not page.isdigit():
897
892
  return None
898
893
  return PageCallback(kind=kind, page=int(page))
894
+ if data.startswith("flow:"):
895
+ _, _, rest = data.partition(":")
896
+ action, sep, run_id = rest.partition(":")
897
+ if not action:
898
+ return None
899
+ if sep and not run_id:
900
+ return None
901
+ return FlowCallback(action=action, run_id=run_id or None)
902
+ if data.startswith("flow_run:"):
903
+ _, _, run_id = data.partition(":")
904
+ if not run_id:
905
+ return None
906
+ return FlowRunCallback(run_id=run_id)
899
907
  return None
900
908
 
901
909
 
@@ -1101,6 +1109,24 @@ def build_bind_keyboard(
1101
1109
  return build_inline_keyboard(rows)
1102
1110
 
1103
1111
 
1112
+ def build_flow_runs_keyboard(
1113
+ options: Sequence[tuple[str, str]],
1114
+ *,
1115
+ page_button: Optional[tuple[str, str]] = None,
1116
+ include_cancel: bool = False,
1117
+ ) -> dict[str, Any]:
1118
+ rows = [
1119
+ [InlineButton(label, encode_flow_run_callback(run_id))]
1120
+ for run_id, label in options
1121
+ ]
1122
+ if page_button:
1123
+ label, callback_data = page_button
1124
+ rows.append([InlineButton(label, callback_data)])
1125
+ if include_cancel:
1126
+ rows.append([InlineButton("Cancel", encode_cancel_callback("flow-runs"))])
1127
+ return build_inline_keyboard(rows)
1128
+
1129
+
1104
1130
  def _validate_callback_data(data: str) -> None:
1105
1131
  if len(data.encode("utf-8")) > TELEGRAM_CALLBACK_DATA_LIMIT:
1106
1132
  raise ValueError("callback_data exceeds Telegram limit")
@@ -1447,6 +1473,7 @@ class TelegramBotClient:
1447
1473
  message_id: int,
1448
1474
  text: str,
1449
1475
  *,
1476
+ message_thread_id: Optional[int] = None,
1450
1477
  reply_markup: Optional[dict[str, Any]] = None,
1451
1478
  parse_mode: Optional[str] = None,
1452
1479
  disable_web_page_preview: bool = True,
@@ -1456,6 +1483,7 @@ class TelegramBotClient:
1456
1483
  logging.INFO,
1457
1484
  "telegram.edit_message",
1458
1485
  chat_id=chat_id,
1486
+ thread_id=message_thread_id,
1459
1487
  message_id=message_id,
1460
1488
  text_len=len(text),
1461
1489
  has_markup=reply_markup is not None,
@@ -1468,6 +1496,8 @@ class TelegramBotClient:
1468
1496
  "text": text,
1469
1497
  "disable_web_page_preview": disable_web_page_preview,
1470
1498
  }
1499
+ if message_thread_id is not None:
1500
+ payload["message_thread_id"] = message_thread_id
1471
1501
  if reply_markup is not None:
1472
1502
  payload["reply_markup"] = reply_markup
1473
1503
  if parse_mode is not None:
@@ -1479,12 +1509,15 @@ class TelegramBotClient:
1479
1509
  self,
1480
1510
  chat_id: Union[int, str],
1481
1511
  message_id: int,
1512
+ *,
1513
+ message_thread_id: Optional[int] = None,
1482
1514
  ) -> bool:
1483
1515
  log_event(
1484
1516
  self._logger,
1485
1517
  logging.INFO,
1486
1518
  "telegram.delete_message",
1487
1519
  chat_id=chat_id,
1520
+ thread_id=message_thread_id,
1488
1521
  message_id=message_id,
1489
1522
  )
1490
1523
  payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
@@ -1495,6 +1528,9 @@ class TelegramBotClient:
1495
1528
  self,
1496
1529
  callback_query_id: str,
1497
1530
  *,
1531
+ chat_id: Optional[int] = None,
1532
+ thread_id: Optional[int] = None,
1533
+ message_id: Optional[int] = None,
1498
1534
  text: Optional[str] = None,
1499
1535
  show_alert: bool = False,
1500
1536
  ) -> dict[str, Any]:
@@ -1503,6 +1539,9 @@ class TelegramBotClient:
1503
1539
  logging.INFO,
1504
1540
  "telegram.answer_callback",
1505
1541
  callback_query_id=callback_query_id,
1542
+ chat_id=chat_id,
1543
+ thread_id=thread_id,
1544
+ message_id=message_id,
1506
1545
  text_len=len(text) if text else 0,
1507
1546
  show_alert=show_alert,
1508
1547
  )
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ __all__ = [
8
+ "BaseTelegramSchema",
9
+ "TelegramPhotoSizeSchema",
10
+ "TelegramDocumentSchema",
11
+ "TelegramAudioSchema",
12
+ "TelegramVoiceSchema",
13
+ "TelegramMessageEntitySchema",
14
+ "TelegramMessageSchema",
15
+ "TelegramCallbackQuerySchema",
16
+ "TelegramUpdateSchema",
17
+ "parse_update_payload",
18
+ "parse_message_payload",
19
+ "parse_callback_query_payload",
20
+ ]
21
+
22
+
23
+ class BaseTelegramSchema(BaseModel):
24
+ model_config = {"extra": "ignore", "validate_assignment": False}
25
+
26
+
27
+ class TelegramPhotoSizeSchema(BaseTelegramSchema):
28
+ file_id: str
29
+ file_unique_id: Optional[str] = None
30
+ width: int
31
+ height: int
32
+ file_size: Optional[int] = None
33
+
34
+
35
+ class TelegramDocumentSchema(BaseTelegramSchema):
36
+ file_id: str
37
+ file_unique_id: Optional[str] = None
38
+ file_name: Optional[str] = None
39
+ mime_type: Optional[str] = None
40
+ file_size: Optional[int] = None
41
+
42
+
43
+ class TelegramAudioSchema(BaseTelegramSchema):
44
+ file_id: str
45
+ file_unique_id: Optional[str] = None
46
+ duration: Optional[int] = None
47
+ file_name: Optional[str] = None
48
+ mime_type: Optional[str] = None
49
+ file_size: Optional[int] = None
50
+
51
+
52
+ class TelegramVoiceSchema(BaseTelegramSchema):
53
+ file_id: str
54
+ file_unique_id: Optional[str] = None
55
+ duration: Optional[int] = None
56
+ mime_type: Optional[str] = None
57
+ file_size: Optional[int] = None
58
+
59
+
60
+ class TelegramMessageEntitySchema(BaseTelegramSchema):
61
+ type: str
62
+ offset: int
63
+ length: int
64
+
65
+
66
+ class TelegramMessageSchema(BaseTelegramSchema):
67
+ message_id: int
68
+ chat: dict[str, Any] = Field(default_factory=dict)
69
+ from_user: Optional[dict[str, Any]] = Field(default=None, alias="from")
70
+ message_thread_id: Optional[int] = None
71
+ date: Optional[int] = None
72
+ text: Optional[str] = None
73
+ caption: Optional[str] = None
74
+ entities: Optional[list[dict[str, Any]]] = None
75
+ caption_entities: Optional[list[dict[str, Any]]] = None
76
+ photo: Optional[list[dict[str, Any]]] = None
77
+ document: Optional[dict[str, Any]] = None
78
+ audio: Optional[dict[str, Any]] = None
79
+ voice: Optional[dict[str, Any]] = None
80
+ media_group_id: Optional[str] = None
81
+ is_topic_message: bool = False
82
+ reply_to_message: Optional[dict[str, Any]] = None
83
+
84
+
85
+ class TelegramCallbackQuerySchema(BaseTelegramSchema):
86
+ id: str
87
+ from_user: dict[str, Any] = Field(alias="from")
88
+ data: Optional[str] = None
89
+ message: Optional[dict[str, Any]] = None
90
+
91
+
92
+ class TelegramUpdateSchema(BaseTelegramSchema):
93
+ update_id: int
94
+ message: Optional[dict[str, Any]] = None
95
+ edited_message: Optional[dict[str, Any]] = Field(
96
+ default=None, alias="edited_message"
97
+ )
98
+ callback_query: Optional[dict[str, Any]] = Field(
99
+ default=None, alias="callback_query"
100
+ )
101
+
102
+
103
+ def parse_update_payload(payload: dict[str, Any]) -> TelegramUpdateSchema:
104
+ return TelegramUpdateSchema.model_validate(payload)
105
+
106
+
107
+ def parse_message_payload(payload: dict[str, Any]) -> Optional[TelegramMessageSchema]:
108
+ try:
109
+ return TelegramMessageSchema.model_validate(payload)
110
+ except Exception:
111
+ return None
112
+
113
+
114
+ def parse_callback_query_payload(
115
+ payload: dict[str, Any],
116
+ ) -> Optional[TelegramCallbackQuerySchema]:
117
+ try:
118
+ return TelegramCallbackQuerySchema.model_validate(payload)
119
+ except Exception:
120
+ return None