codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import os
5
6
  import time
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import Callable, Dict, Optional, Sequence
9
10
 
10
11
  from ...core.logging_utils import log_event
12
+ from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
11
13
  from ...workspace import canonical_workspace_root, workspace_id_for_path
12
14
  from .client import ApprovalHandler, CodexAppServerClient, NotificationHandler
13
15
 
@@ -34,8 +36,17 @@ class WorkspaceAppServerSupervisor:
34
36
  approval_handler: Optional[ApprovalHandler] = None,
35
37
  notification_handler: Optional[NotificationHandler] = None,
36
38
  logger: Optional[logging.Logger] = None,
37
- auto_restart: bool = True,
39
+ auto_restart: Optional[bool] = None,
38
40
  request_timeout: Optional[float] = None,
41
+ turn_stall_timeout_seconds: Optional[float] = None,
42
+ turn_stall_poll_interval_seconds: Optional[float] = None,
43
+ turn_stall_recovery_min_interval_seconds: Optional[float] = None,
44
+ max_message_bytes: Optional[int] = None,
45
+ oversize_preview_bytes: Optional[int] = None,
46
+ max_oversize_drain_bytes: Optional[int] = None,
47
+ restart_backoff_initial_seconds: Optional[float] = None,
48
+ restart_backoff_max_seconds: Optional[float] = None,
49
+ restart_backoff_jitter_ratio: Optional[float] = None,
39
50
  default_approval_decision: str = "cancel",
40
51
  max_handles: Optional[int] = None,
41
52
  idle_ttl_seconds: Optional[float] = None,
@@ -46,8 +57,27 @@ class WorkspaceAppServerSupervisor:
46
57
  self._approval_handler = approval_handler
47
58
  self._notification_handler = notification_handler
48
59
  self._logger = logger or logging.getLogger(__name__)
49
- self._auto_restart = auto_restart
60
+ disable_restart_env = os.environ.get(
61
+ "CODEX_DISABLE_APP_SERVER_AUTORESTART_FOR_TESTS"
62
+ )
63
+ if disable_restart_env:
64
+ self._auto_restart = False
65
+ elif auto_restart is None:
66
+ self._auto_restart = True
67
+ else:
68
+ self._auto_restart = auto_restart
50
69
  self._request_timeout = request_timeout
70
+ self._turn_stall_timeout_seconds = turn_stall_timeout_seconds
71
+ self._turn_stall_poll_interval_seconds = turn_stall_poll_interval_seconds
72
+ self._turn_stall_recovery_min_interval_seconds = (
73
+ turn_stall_recovery_min_interval_seconds
74
+ )
75
+ self._max_message_bytes = max_message_bytes
76
+ self._oversize_preview_bytes = oversize_preview_bytes
77
+ self._max_oversize_drain_bytes = max_oversize_drain_bytes
78
+ self._restart_backoff_initial_seconds = restart_backoff_initial_seconds
79
+ self._restart_backoff_max_seconds = restart_backoff_max_seconds
80
+ self._restart_backoff_jitter_ratio = restart_backoff_jitter_ratio
51
81
  self._default_approval_decision = default_approval_decision
52
82
  self._max_handles = max_handles
53
83
  self._idle_ttl_seconds = idle_ttl_seconds
@@ -78,7 +108,8 @@ class WorkspaceAppServerSupervisor:
78
108
  last_used_at=handle.last_used_at,
79
109
  )
80
110
  await handle.client.close()
81
- except Exception:
111
+ except Exception as exc:
112
+ self._logger.debug("Failed to close handle: %s", exc)
82
113
  continue
83
114
 
84
115
  async def prune_idle(self) -> int:
@@ -100,7 +131,8 @@ class WorkspaceAppServerSupervisor:
100
131
  )
101
132
  await handle.client.close()
102
133
  closed += 1
103
- except Exception:
134
+ except Exception as exc:
135
+ self._logger.debug("Failed to prune handle: %s", exc)
104
136
  continue
105
137
  return closed
106
138
 
@@ -129,6 +161,15 @@ class WorkspaceAppServerSupervisor:
129
161
  default_approval_decision=self._default_approval_decision,
130
162
  auto_restart=self._auto_restart,
131
163
  request_timeout=self._request_timeout,
164
+ turn_stall_timeout_seconds=self._turn_stall_timeout_seconds,
165
+ turn_stall_poll_interval_seconds=self._turn_stall_poll_interval_seconds,
166
+ turn_stall_recovery_min_interval_seconds=self._turn_stall_recovery_min_interval_seconds,
167
+ max_message_bytes=self._max_message_bytes,
168
+ oversize_preview_bytes=self._oversize_preview_bytes,
169
+ max_oversize_drain_bytes=self._max_oversize_drain_bytes,
170
+ restart_backoff_initial_seconds=self._restart_backoff_initial_seconds,
171
+ restart_backoff_max_seconds=self._restart_backoff_max_seconds,
172
+ restart_backoff_jitter_ratio=self._restart_backoff_jitter_ratio,
132
173
  notification_handler=self._notification_handler,
133
174
  logger=self._logger,
134
175
  )
@@ -157,7 +198,8 @@ class WorkspaceAppServerSupervisor:
157
198
  last_used_at=handle.last_used_at,
158
199
  )
159
200
  await handle.client.close()
160
- except Exception:
201
+ except Exception as exc:
202
+ self._logger.debug("Failed to close handle: %s", exc)
161
203
  continue
162
204
  return handle
163
205
 
@@ -173,35 +215,19 @@ class WorkspaceAppServerSupervisor:
173
215
  return self._pop_idle_handles_locked()
174
216
 
175
217
  def _pop_idle_handles_locked(self) -> list[AppServerHandle]:
176
- if not self._idle_ttl_seconds or self._idle_ttl_seconds <= 0:
177
- return []
178
- cutoff = time.monotonic() - self._idle_ttl_seconds
179
- stale: list[AppServerHandle] = []
180
- for handle in list(self._handles.values()):
181
- if handle.last_used_at and handle.last_used_at < cutoff:
182
- self._handles.pop(handle.workspace_id, None)
183
- stale.append(handle)
184
- return stale
218
+ return pop_idle_handles_locked(
219
+ self._handles,
220
+ self._idle_ttl_seconds,
221
+ self._logger,
222
+ "app_server",
223
+ last_used_at_getter=lambda h: h.last_used_at,
224
+ )
185
225
 
186
226
  def _evict_lru_handle_locked(self) -> Optional[AppServerHandle]:
187
- if not self._max_handles or self._max_handles <= 0:
188
- return None
189
- if len(self._handles) < self._max_handles:
190
- return None
191
- lru_handle = min(
192
- self._handles.values(),
193
- key=lambda handle: handle.last_used_at or 0.0,
194
- )
195
- log_event(
227
+ return evict_lru_handle_locked(
228
+ self._handles,
229
+ self._max_handles,
196
230
  self._logger,
197
- logging.INFO,
198
- "app_server.handle.evicted",
199
- reason="max_handles",
200
- workspace_id=lru_handle.workspace_id,
201
- workspace_root=str(lru_handle.workspace_root),
202
- max_handles=self._max_handles,
203
- handle_count=len(self._handles),
204
- last_used_at=lru_handle.last_used_at,
231
+ "app_server",
232
+ last_used_at_getter=lambda h: h.last_used_at or 0.0,
205
233
  )
206
- self._handles.pop(lru_handle.workspace_id, None)
207
- return lru_handle
@@ -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
@@ -323,111 +333,123 @@ def is_interrupt_alias(text: Optional[str]) -> bool:
323
333
 
324
334
 
325
335
  def parse_update(update: dict[str, Any]) -> Optional[TelegramUpdate]:
326
- update_id = update.get("update_id")
327
- if not isinstance(update_id, int):
336
+ try:
337
+ schema = parse_update_payload(update)
338
+ except Exception:
328
339
  return None
329
- message = _parse_message(update_id, update.get("message"), edited=False)
340
+ message = _parse_message(schema.update_id, schema.message, edited=False)
330
341
  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"))
342
+ message = _parse_message(schema.update_id, schema.edited_message, edited=True)
343
+ callback = _parse_callback(schema.update_id, schema.callback_query)
333
344
  if message is None and callback is None:
334
345
  return None
335
- return TelegramUpdate(update_id=update_id, message=message, callback=callback)
346
+ return TelegramUpdate(
347
+ update_id=schema.update_id, message=message, callback=callback
348
+ )
336
349
 
337
350
 
338
351
  def _parse_message(
339
352
  update_id: int, payload: Any, *, edited: bool = False
340
353
  ) -> Optional[TelegramMessage]:
341
- if not isinstance(payload, dict):
354
+ schema = parse_message_payload(payload)
355
+ if schema is None:
342
356
  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")
357
+
358
+ chat_id = schema.chat.get("id") if isinstance(schema.chat, dict) else None
348
359
  if not isinstance(chat_id, int):
349
360
  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
361
+
362
+ chat_type = schema.chat.get("type") if isinstance(schema.chat, dict) else None
363
+ if chat_type is not None and not isinstance(chat_type, str):
364
+ chat_type = None
365
+
366
+ reply_to_message_id: Optional[int] = None
367
+ reply_to_is_bot = False
368
+ reply_to_username: Optional[str] = None
369
+ if isinstance(schema.reply_to_message, dict):
370
+ rmid = schema.reply_to_message.get("message_id")
371
+ if isinstance(rmid, int):
372
+ reply_to_message_id = rmid
373
+ reply_from = schema.reply_to_message.get("from")
374
+ if isinstance(reply_from, dict):
375
+ is_bot = reply_from.get("is_bot")
376
+ if isinstance(is_bot, bool):
377
+ reply_to_is_bot = is_bot
378
+ username = reply_from.get("username")
379
+ if isinstance(username, str):
380
+ reply_to_username = username
381
+
382
+ from_user_id = (
383
+ schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
384
+ )
355
385
  if from_user_id is not None and not isinstance(from_user_id, int):
356
386
  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"))
387
+
388
+ entities = _parse_entities(schema.entities)
389
+ caption_entities = _parse_entities(schema.caption_entities)
390
+ photos = _parse_photo_sizes(schema.photo)
391
+ document = _parse_document(schema.document)
392
+ audio = _parse_audio(schema.audio)
393
+ voice = _parse_voice(schema.voice)
394
+
376
395
  return TelegramMessage(
377
396
  update_id=update_id,
378
- message_id=message_id,
397
+ message_id=schema.message_id,
379
398
  chat_id=chat_id,
380
- thread_id=thread_id,
399
+ thread_id=schema.message_thread_id,
381
400
  from_user_id=from_user_id,
382
- text=text,
383
- date=date,
384
- is_topic_message=is_topic_message,
401
+ text=schema.text,
402
+ date=schema.date,
403
+ is_topic_message=schema.is_topic_message,
385
404
  is_edited=edited,
386
- caption=caption,
405
+ caption=schema.caption,
387
406
  entities=entities,
388
407
  caption_entities=caption_entities,
389
408
  photos=photos,
390
409
  document=document,
391
410
  audio=audio,
392
411
  voice=voice,
393
- media_group_id=media_group_id,
412
+ media_group_id=schema.media_group_id,
413
+ chat_type=chat_type,
414
+ reply_to_message_id=reply_to_message_id,
415
+ reply_to_is_bot=reply_to_is_bot,
416
+ reply_to_username=reply_to_username,
394
417
  )
395
418
 
396
419
 
397
420
  def _parse_callback(update_id: int, payload: Any) -> Optional[TelegramCallbackQuery]:
398
- if not isinstance(payload, dict):
399
- return None
400
- callback_id = payload.get("id")
401
- if not isinstance(callback_id, str):
421
+ schema = parse_callback_query_payload(payload)
422
+ if schema is None:
402
423
  return None
403
- sender = payload.get("from")
404
- from_user_id = sender.get("id") if isinstance(sender, dict) else None
424
+
425
+ from_user_id = (
426
+ schema.from_user.get("id") if isinstance(schema.from_user, dict) else None
427
+ )
405
428
  if from_user_id is not None and not isinstance(from_user_id, int):
406
429
  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")
430
+
411
431
  message_id = None
412
432
  chat_id = None
413
433
  thread_id = None
414
- if isinstance(message, dict):
415
- message_id = message.get("message_id")
416
- chat = message.get("chat")
434
+ if isinstance(schema.message, dict):
435
+ message_id = schema.message.get("message_id")
436
+ chat = schema.message.get("chat")
417
437
  if isinstance(chat, dict):
418
438
  chat_id = chat.get("id")
419
- thread_id = message.get("message_thread_id")
439
+ thread_id = schema.message.get("message_thread_id")
440
+
420
441
  if message_id is not None and not isinstance(message_id, int):
421
442
  message_id = None
422
443
  if chat_id is not None and not isinstance(chat_id, int):
423
444
  chat_id = None
424
445
  if thread_id is not None and not isinstance(thread_id, int):
425
446
  thread_id = None
447
+
426
448
  return TelegramCallbackQuery(
427
449
  update_id=update_id,
428
- callback_id=callback_id,
450
+ callback_id=schema.id,
429
451
  from_user_id=from_user_id,
430
- data=data,
452
+ data=schema.data,
431
453
  message_id=message_id,
432
454
  chat_id=chat_id,
433
455
  thread_id=thread_id,
@@ -441,26 +463,17 @@ def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
441
463
  for item in payload:
442
464
  if not isinstance(item, dict):
443
465
  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):
466
+ try:
467
+ schema = TelegramPhotoSizeSchema.model_validate(item)
468
+ except Exception:
453
469
  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
470
  sizes.append(
458
471
  TelegramPhotoSize(
459
- file_id=file_id,
460
- file_unique_id=file_unique_id,
461
- width=width,
462
- height=height,
463
- file_size=file_size,
472
+ file_id=schema.file_id,
473
+ file_unique_id=schema.file_unique_id,
474
+ width=schema.width,
475
+ height=schema.height,
476
+ file_size=schema.file_size,
464
477
  )
465
478
  )
466
479
  return tuple(sizes)
@@ -469,85 +482,49 @@ def _parse_photo_sizes(payload: Any) -> tuple[TelegramPhotoSize, ...]:
469
482
  def _parse_document(payload: Any) -> Optional[TelegramDocument]:
470
483
  if not isinstance(payload, dict):
471
484
  return None
472
- file_id = payload.get("file_id")
473
- if not isinstance(file_id, str) or not file_id:
485
+ try:
486
+ schema = TelegramDocumentSchema.model_validate(payload)
487
+ except Exception:
474
488
  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
489
  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,
490
+ file_id=schema.file_id,
491
+ file_unique_id=schema.file_unique_id,
492
+ file_name=schema.file_name,
493
+ mime_type=schema.mime_type,
494
+ file_size=schema.file_size,
493
495
  )
494
496
 
495
497
 
496
498
  def _parse_audio(payload: Any) -> Optional[TelegramAudio]:
497
499
  if not isinstance(payload, dict):
498
500
  return None
499
- file_id = payload.get("file_id")
500
- if not isinstance(file_id, str) or not file_id:
501
+ try:
502
+ schema = TelegramAudioSchema.model_validate(payload)
503
+ except Exception:
501
504
  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
505
  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,
506
+ file_id=schema.file_id,
507
+ file_unique_id=schema.file_unique_id,
508
+ duration=schema.duration,
509
+ file_name=schema.file_name,
510
+ mime_type=schema.mime_type,
511
+ file_size=schema.file_size,
524
512
  )
525
513
 
526
514
 
527
515
  def _parse_voice(payload: Any) -> Optional[TelegramVoice]:
528
516
  if not isinstance(payload, dict):
529
517
  return None
530
- file_id = payload.get("file_id")
531
- if not isinstance(file_id, str) or not file_id:
518
+ try:
519
+ schema = TelegramVoiceSchema.model_validate(payload)
520
+ except Exception:
532
521
  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
522
  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,
523
+ file_id=schema.file_id,
524
+ file_unique_id=schema.file_unique_id,
525
+ duration=schema.duration,
526
+ mime_type=schema.mime_type,
527
+ file_size=schema.file_size,
551
528
  )
552
529
 
553
530
 
@@ -558,14 +535,15 @@ def _parse_entities(payload: Any) -> tuple[TelegramMessageEntity, ...]:
558
535
  for item in payload:
559
536
  if not isinstance(item, dict):
560
537
  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):
538
+ try:
539
+ schema = TelegramMessageEntitySchema.model_validate(item)
540
+ except Exception:
567
541
  continue
568
- entities.append(TelegramMessageEntity(type=kind, offset=offset, length=length))
542
+ entities.append(
543
+ TelegramMessageEntity(
544
+ type=schema.type, offset=schema.offset, length=schema.length
545
+ )
546
+ )
569
547
  return tuple(entities)
570
548
 
571
549
 
@@ -757,12 +735,6 @@ def encode_page_callback(kind: str, page: int) -> str:
757
735
  return data
758
736
 
759
737
 
760
- def encode_pr_flow_start_callback(slug: str, number: int) -> str:
761
- data = f"pr_flow_start:{slug}#{number}"
762
- _validate_callback_data(data)
763
- return data
764
-
765
-
766
738
  def encode_compact_callback(action: str) -> str:
767
739
  data = f"compact:{action}"
768
740
  _validate_callback_data(data)
@@ -786,7 +758,6 @@ def parse_callback_data(
786
758
  UpdateCallback,
787
759
  UpdateConfirmCallback,
788
760
  ReviewCommitCallback,
789
- PrFlowStartCallback,
790
761
  CancelCallback,
791
762
  CompactCallback,
792
763
  PageCallback,
@@ -868,16 +839,6 @@ def parse_callback_data(
868
839
  if not sha:
869
840
  return None
870
841
  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
842
  if data.startswith("cancel:"):
882
843
  _, _, kind = data.partition(":")
883
844
  if not kind:
@@ -1447,6 +1408,7 @@ class TelegramBotClient:
1447
1408
  message_id: int,
1448
1409
  text: str,
1449
1410
  *,
1411
+ message_thread_id: Optional[int] = None,
1450
1412
  reply_markup: Optional[dict[str, Any]] = None,
1451
1413
  parse_mode: Optional[str] = None,
1452
1414
  disable_web_page_preview: bool = True,
@@ -1456,6 +1418,7 @@ class TelegramBotClient:
1456
1418
  logging.INFO,
1457
1419
  "telegram.edit_message",
1458
1420
  chat_id=chat_id,
1421
+ thread_id=message_thread_id,
1459
1422
  message_id=message_id,
1460
1423
  text_len=len(text),
1461
1424
  has_markup=reply_markup is not None,
@@ -1468,6 +1431,8 @@ class TelegramBotClient:
1468
1431
  "text": text,
1469
1432
  "disable_web_page_preview": disable_web_page_preview,
1470
1433
  }
1434
+ if message_thread_id is not None:
1435
+ payload["message_thread_id"] = message_thread_id
1471
1436
  if reply_markup is not None:
1472
1437
  payload["reply_markup"] = reply_markup
1473
1438
  if parse_mode is not None:
@@ -1479,12 +1444,15 @@ class TelegramBotClient:
1479
1444
  self,
1480
1445
  chat_id: Union[int, str],
1481
1446
  message_id: int,
1447
+ *,
1448
+ message_thread_id: Optional[int] = None,
1482
1449
  ) -> bool:
1483
1450
  log_event(
1484
1451
  self._logger,
1485
1452
  logging.INFO,
1486
1453
  "telegram.delete_message",
1487
1454
  chat_id=chat_id,
1455
+ thread_id=message_thread_id,
1488
1456
  message_id=message_id,
1489
1457
  )
1490
1458
  payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
@@ -1495,6 +1463,9 @@ class TelegramBotClient:
1495
1463
  self,
1496
1464
  callback_query_id: str,
1497
1465
  *,
1466
+ chat_id: Optional[int] = None,
1467
+ thread_id: Optional[int] = None,
1468
+ message_id: Optional[int] = None,
1498
1469
  text: Optional[str] = None,
1499
1470
  show_alert: bool = False,
1500
1471
  ) -> dict[str, Any]:
@@ -1503,6 +1474,9 @@ class TelegramBotClient:
1503
1474
  logging.INFO,
1504
1475
  "telegram.answer_callback",
1505
1476
  callback_query_id=callback_query_id,
1477
+ chat_id=chat_id,
1478
+ thread_id=thread_id,
1479
+ message_id=message_id,
1506
1480
  text_len=len(text) if text else 0,
1507
1481
  show_alert=show_alert,
1508
1482
  )