codex-autorunner 0.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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_PAGE_SIZE = 10
4
+ TELEGRAM_MAX_MESSAGE_LENGTH = 4096
5
+ TELEGRAM_CALLBACK_DATA_LIMIT = 64
6
+ THREAD_LIST_PAGE_LIMIT = 100
7
+ THREAD_LIST_MAX_PAGES = 5
8
+ DEFAULT_MODEL_LIST_LIMIT = 25
9
+ DEFAULT_MCP_LIST_LIMIT = 50
10
+ DEFAULT_SKILLS_LIST_LIMIT = 50
11
+ MAX_TOPIC_THREAD_HISTORY = 50
12
+ RESUME_BUTTON_PREVIEW_LIMIT = 60
13
+ RESUME_PREVIEW_USER_LIMIT = 1000
14
+ RESUME_PREVIEW_ASSISTANT_LIMIT = 1000
15
+ RESUME_PREVIEW_SCAN_LINES = 200
16
+ RESUME_MISSING_IDS_LOG_LIMIT = 10
17
+ RESUME_REFRESH_LIMIT = 10
18
+ TOKEN_USAGE_CACHE_LIMIT = 256
19
+ TOKEN_USAGE_TURN_CACHE_LIMIT = 512
20
+ DEFAULT_INTERRUPT_TIMEOUT_SECONDS = 30.0
21
+ DEFAULT_WORKSPACE_STATE_ROOT = "~/.codex-autorunner/workspaces"
22
+ APP_SERVER_START_BACKOFF_INITIAL_SECONDS = 1.0
23
+ APP_SERVER_START_BACKOFF_MAX_SECONDS = 30.0
24
+ CACHE_CLEANUP_INTERVAL_SECONDS = 300.0
25
+ COALESCE_BUFFER_TTL_SECONDS = 60.0
26
+ MODEL_PENDING_TTL_SECONDS = 1800.0
27
+ PENDING_APPROVAL_TTL_SECONDS = 600.0
28
+ REASONING_BUFFER_TTL_SECONDS = 900.0
29
+ SELECTION_STATE_TTL_SECONDS = 1800.0
30
+ TURN_PREVIEW_TTL_SECONDS = 900.0
31
+ OVERSIZE_WARNING_TTL_SECONDS = 3600.0
32
+ UPDATE_ID_PERSIST_INTERVAL_SECONDS = 60.0
33
+ OUTBOX_RETRY_INTERVAL_SECONDS = 10.0
34
+ OUTBOX_IMMEDIATE_RETRY_DELAYS = (0.5, 2.0, 5.0)
35
+ OUTBOX_MAX_ATTEMPTS = 8
36
+ VOICE_RETRY_INTERVAL_SECONDS = 5.0
37
+ VOICE_RETRY_INITIAL_SECONDS = 2.0
38
+ VOICE_RETRY_MAX_SECONDS = 300.0
39
+ VOICE_RETRY_JITTER_RATIO = 0.2
40
+ VOICE_MAX_ATTEMPTS = 20
41
+ VOICE_RETRY_AFTER_BUFFER_SECONDS = 1.0
42
+ WHISPER_TRANSCRIPT_DISCLAIMER = (
43
+ "Note: transcribed from user voice. If confusing or possibly inaccurate and you "
44
+ "cannot infer the intention please clarify before proceeding."
45
+ )
46
+ DEFAULT_UPDATE_REPO_URL = "https://github.com/Git-on-my-level/codex-autorunner.git"
47
+ DEFAULT_UPDATE_REPO_REF = "main"
48
+ RESUME_PICKER_PROMPT = (
49
+ "Select a thread to resume (buttons below or reply with number/id)."
50
+ )
51
+ BIND_PICKER_PROMPT = "Select a repo to bind (buttons below or reply with number/id)."
52
+ MODEL_PICKER_PROMPT = "Select a model (buttons below)."
53
+ EFFORT_PICKER_PROMPT = "Select a reasoning effort for {model}."
54
+ UPDATE_PICKER_PROMPT = "Select update target (buttons below)."
55
+ REVIEW_COMMIT_PICKER_PROMPT = (
56
+ "Select a commit to review (buttons below or reply with number)."
57
+ )
58
+ REVIEW_COMMIT_BUTTON_LABEL_LIMIT = 80
59
+ UPDATE_TARGET_OPTIONS = (
60
+ ("both", "Both (web + Telegram)"),
61
+ ("web", "Web only"),
62
+ ("telegram", "Telegram only"),
63
+ )
64
+ TRACE_MESSAGE_TOKENS = (
65
+ "failed",
66
+ "error",
67
+ "denied",
68
+ "unknown",
69
+ "not bound",
70
+ "not found",
71
+ "invalid",
72
+ "unsupported",
73
+ "disabled",
74
+ "missing",
75
+ "mismatch",
76
+ "different workspace",
77
+ "no previous",
78
+ "no resumable",
79
+ "no workspace-tagged",
80
+ "not applicable",
81
+ "selection expired",
82
+ "timed out",
83
+ "timeout",
84
+ "aborted",
85
+ "canceled",
86
+ "cancelled",
87
+ )
88
+ PLACEHOLDER_TEXT = "Working..."
89
+ STREAM_PREVIEW_PREFIX = ""
90
+ THINKING_PREVIEW_MAX_LEN = 80
91
+ THINKING_PREVIEW_MIN_EDIT_INTERVAL_SECONDS = 1.0
92
+ COMMAND_DISABLED_TEMPLATE = "'/{name}' is disabled while a task is in progress."
93
+ MAX_MENTION_BYTES = 200_000
94
+ VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
95
+ CONTEXT_BASELINE_TOKENS = 12000
96
+ APPROVAL_POLICY_VALUES = {"untrusted", "on-failure", "on-request", "never"}
97
+ APPROVAL_PRESETS = {
98
+ "read-only": ("on-request", "readOnly"),
99
+ "auto": ("on-request", "workspaceWrite"),
100
+ "full-access": ("never", "dangerFullAccess"),
101
+ }
102
+ SHELL_OUTPUT_TRUNCATION_SUFFIX = "\n...(truncated)"
103
+ SHELL_MESSAGE_BUFFER_CHARS = 200
104
+ COMPACT_SUMMARY_PROMPT = (
105
+ "Summarize the conversation so far into a concise context block I can paste into "
106
+ "a new thread. Include goals, constraints, decisions, and current state."
107
+ )
108
+ INIT_PROMPT = "\n".join(
109
+ [
110
+ "Generate a file named AGENTS.md that serves as a contributor guide for this repository.",
111
+ "Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.",
112
+ "Follow the outline below, but adapt as needed - add sections if relevant, and omit those that do not apply to this project.",
113
+ "",
114
+ "Document Requirements",
115
+ "",
116
+ '- Title the document "Repository Guidelines".',
117
+ "- Use Markdown headings (#, ##, etc.) for structure.",
118
+ "- Keep the document concise. 200-400 words is optimal.",
119
+ "- Keep explanations short, direct, and specific to this repository.",
120
+ "- Provide examples where helpful (commands, directory paths, naming patterns).",
121
+ "- Maintain a professional, instructional tone.",
122
+ "",
123
+ "Recommended Sections",
124
+ "",
125
+ "Project Structure & Module Organization",
126
+ "",
127
+ "- Outline the project structure, including where the source code, tests, and assets are located.",
128
+ "",
129
+ "Build, Test, and Development Commands",
130
+ "",
131
+ "- List key commands for building, testing, and running locally (e.g., npm test, make build).",
132
+ "- Briefly explain what each command does.",
133
+ "",
134
+ "Coding Style & Naming Conventions",
135
+ "",
136
+ "- Specify indentation rules, language-specific style preferences, and naming patterns.",
137
+ "- Include any formatting or linting tools used.",
138
+ "",
139
+ "Testing Guidelines",
140
+ "",
141
+ "- Identify testing frameworks and coverage requirements.",
142
+ "- State test naming conventions and how to run tests.",
143
+ "",
144
+ "Commit & Pull Request Guidelines",
145
+ "",
146
+ "- Summarize commit message conventions found in the project's Git history.",
147
+ "- Outline pull request requirements (descriptions, linked issues, screenshots, etc.).",
148
+ "",
149
+ "(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.",
150
+ ]
151
+ )
152
+
153
+
154
+ TurnKey = tuple[str, str]
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Awaitable, Callable, Optional
6
+
7
+ from ...core.logging_utils import log_event
8
+ from .adapter import TelegramUpdate, allowlist_allows
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class DispatchContext:
13
+ chat_id: Optional[int]
14
+ user_id: Optional[int]
15
+ thread_id: Optional[int]
16
+ message_id: Optional[int]
17
+ is_topic: Optional[bool]
18
+ is_edited: Optional[bool]
19
+ topic_key: Optional[str]
20
+
21
+
22
+ DispatchRoute = Callable[[Any, TelegramUpdate, DispatchContext], Awaitable[None]]
23
+
24
+
25
+ def _build_context(handlers: Any, update: TelegramUpdate) -> DispatchContext:
26
+ chat_id = None
27
+ user_id = None
28
+ thread_id = None
29
+ message_id = None
30
+ is_topic = None
31
+ is_edited = None
32
+ key = None
33
+ if update.message:
34
+ chat_id = update.message.chat_id
35
+ user_id = update.message.from_user_id
36
+ thread_id = update.message.thread_id
37
+ message_id = update.message.message_id
38
+ is_topic = update.message.is_topic_message
39
+ is_edited = update.message.is_edited
40
+ key = handlers._resolve_topic_key(chat_id, thread_id)
41
+ elif update.callback:
42
+ chat_id = update.callback.chat_id
43
+ user_id = update.callback.from_user_id
44
+ thread_id = update.callback.thread_id
45
+ message_id = update.callback.message_id
46
+ if chat_id is not None:
47
+ key = handlers._resolve_topic_key(chat_id, thread_id)
48
+ return DispatchContext(
49
+ chat_id=chat_id,
50
+ user_id=user_id,
51
+ thread_id=thread_id,
52
+ message_id=message_id,
53
+ is_topic=is_topic,
54
+ is_edited=is_edited,
55
+ topic_key=key,
56
+ )
57
+
58
+
59
+ def _log_denied(handlers: Any, update: TelegramUpdate) -> None:
60
+ chat_id = None
61
+ user_id = None
62
+ thread_id = None
63
+ if update.message:
64
+ chat_id = update.message.chat_id
65
+ user_id = update.message.from_user_id
66
+ thread_id = update.message.thread_id
67
+ elif update.callback:
68
+ chat_id = update.callback.chat_id
69
+ user_id = update.callback.from_user_id
70
+ thread_id = update.callback.thread_id
71
+ log_event(
72
+ handlers._logger,
73
+ logging.INFO,
74
+ "telegram.allowlist.denied",
75
+ chat_id=chat_id,
76
+ user_id=user_id,
77
+ thread_id=thread_id,
78
+ )
79
+
80
+
81
+ async def _dispatch_callback(
82
+ handlers: Any, update: TelegramUpdate, context: DispatchContext
83
+ ) -> None:
84
+ callback = update.callback
85
+ if callback is None:
86
+ return
87
+ if context.topic_key:
88
+ handlers._enqueue_topic_work(
89
+ context.topic_key,
90
+ lambda: handlers._handle_callback(callback),
91
+ force_queue=True,
92
+ )
93
+ return
94
+ await handlers._handle_callback(callback)
95
+
96
+
97
+ async def _dispatch_message(
98
+ handlers: Any, update: TelegramUpdate, context: DispatchContext
99
+ ) -> None:
100
+ message = update.message
101
+ if message is None:
102
+ return
103
+ if context.topic_key:
104
+ if handlers._should_bypass_topic_queue(message):
105
+ await handlers._handle_message(message)
106
+ return
107
+ handlers._enqueue_topic_work(
108
+ context.topic_key,
109
+ lambda: handlers._handle_message(message),
110
+ force_queue=True,
111
+ )
112
+ return
113
+ await handlers._handle_message(message)
114
+
115
+
116
+ _ROUTES: tuple[tuple[str, DispatchRoute], ...] = (
117
+ ("callback", _dispatch_callback),
118
+ ("message", _dispatch_message),
119
+ )
120
+
121
+
122
+ async def dispatch_update(handlers: Any, update: TelegramUpdate) -> None:
123
+ context = _build_context(handlers, update)
124
+ log_event(
125
+ handlers._logger,
126
+ logging.INFO,
127
+ "telegram.update.received",
128
+ update_id=update.update_id,
129
+ chat_id=context.chat_id,
130
+ user_id=context.user_id,
131
+ thread_id=context.thread_id,
132
+ message_id=context.message_id,
133
+ is_topic=context.is_topic,
134
+ is_edited=context.is_edited,
135
+ has_message=bool(update.message),
136
+ has_callback=bool(update.callback),
137
+ )
138
+ if (
139
+ update.update_id is not None
140
+ and context.topic_key
141
+ and not handlers._should_process_update(context.topic_key, update.update_id)
142
+ ):
143
+ log_event(
144
+ handlers._logger,
145
+ logging.INFO,
146
+ "telegram.update.duplicate",
147
+ update_id=update.update_id,
148
+ chat_id=context.chat_id,
149
+ thread_id=context.thread_id,
150
+ message_id=context.message_id,
151
+ )
152
+ return
153
+ if not allowlist_allows(update, handlers._allowlist):
154
+ _log_denied(handlers, update)
155
+ return
156
+ for name, route in _ROUTES:
157
+ if name == "callback" and update.callback:
158
+ await route(handlers, update, context)
159
+ return
160
+ if name == "message" and update.message:
161
+ await route(handlers, update, context)
162
+ return
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from ....core.logging_utils import log_event
8
+ from ....core.state import now_iso
9
+ from ...app_server.client import ApprovalDecision
10
+ from ..adapter import ApprovalCallback, TelegramCallbackQuery, build_approval_keyboard
11
+ from ..config import DEFAULT_APPROVAL_TIMEOUT_SECONDS
12
+ from ..helpers import (
13
+ _approval_age_seconds,
14
+ _coerce_id,
15
+ _extract_turn_thread_id,
16
+ _format_approval_decision,
17
+ _format_approval_prompt,
18
+ )
19
+ from ..state import PendingApprovalRecord
20
+ from ..types import PendingApproval
21
+
22
+
23
+ class TelegramApprovalHandlers:
24
+ async def _restore_pending_approvals(self) -> None:
25
+ state = self._store.load()
26
+ if not state.pending_approvals:
27
+ return
28
+ grouped: dict[tuple[int, int | None], list[PendingApprovalRecord]] = {}
29
+ for record in state.pending_approvals.values():
30
+ key = (record.chat_id, record.thread_id)
31
+ grouped.setdefault(key, []).append(record)
32
+ for (chat_id, thread_id), records in grouped.items():
33
+ items = []
34
+ for record in records:
35
+ age = _approval_age_seconds(record.created_at)
36
+ age_label = f"{age}s" if isinstance(age, int) else "unknown age"
37
+ items.append(f"{record.request_id} ({age_label})")
38
+ self._store.clear_pending_approval(record.request_id)
39
+ message = (
40
+ "Cleared stale approval requests from a previous session. "
41
+ "Re-run the request or use /interrupt if the turn is still active.\n"
42
+ f"Requests: {', '.join(items)}"
43
+ )
44
+ try:
45
+ await self._send_message(
46
+ chat_id,
47
+ message,
48
+ thread_id=thread_id,
49
+ )
50
+ except Exception:
51
+ log_event(
52
+ self._logger,
53
+ logging.WARNING,
54
+ "telegram.approval.restore_failed",
55
+ chat_id=chat_id,
56
+ thread_id=thread_id,
57
+ )
58
+
59
+ async def _handle_approval_request(
60
+ self, message: dict[str, Any]
61
+ ) -> ApprovalDecision:
62
+ req_id = message.get("id")
63
+ params = (
64
+ message.get("params") if isinstance(message.get("params"), dict) else {}
65
+ )
66
+ turn_id = _coerce_id(params.get("turnId")) if isinstance(params, dict) else None
67
+ if not req_id or not turn_id:
68
+ return "cancel"
69
+ codex_thread_id = _extract_turn_thread_id(params)
70
+ ctx = self._resolve_turn_context(turn_id, thread_id=codex_thread_id)
71
+ if ctx is None:
72
+ return "cancel"
73
+ request_id = str(req_id)
74
+ prompt = _format_approval_prompt(message)
75
+ created_at = now_iso()
76
+ approval_record = PendingApprovalRecord(
77
+ request_id=request_id,
78
+ turn_id=str(turn_id),
79
+ chat_id=ctx.chat_id,
80
+ thread_id=ctx.thread_id,
81
+ message_id=None,
82
+ prompt=prompt,
83
+ created_at=created_at,
84
+ topic_key=ctx.topic_key,
85
+ )
86
+ self._store.upsert_pending_approval(approval_record)
87
+ log_event(
88
+ self._logger,
89
+ logging.INFO,
90
+ "telegram.approval.requested",
91
+ request_id=request_id,
92
+ turn_id=turn_id,
93
+ chat_id=ctx.chat_id,
94
+ thread_id=ctx.thread_id,
95
+ )
96
+ try:
97
+ keyboard = build_approval_keyboard(request_id, include_session=False)
98
+ except ValueError:
99
+ log_event(
100
+ self._logger,
101
+ logging.WARNING,
102
+ "telegram.approval.callback_too_long",
103
+ request_id=request_id,
104
+ )
105
+ self._store.clear_pending_approval(request_id)
106
+ return "cancel"
107
+ payload_text, parse_mode = self._prepare_outgoing_text(
108
+ prompt,
109
+ chat_id=ctx.chat_id,
110
+ thread_id=ctx.thread_id,
111
+ reply_to=ctx.reply_to_message_id,
112
+ topic_key=ctx.topic_key,
113
+ codex_thread_id=codex_thread_id,
114
+ )
115
+ try:
116
+ response = await self._bot.send_message(
117
+ ctx.chat_id,
118
+ payload_text,
119
+ message_thread_id=ctx.thread_id,
120
+ reply_to_message_id=ctx.reply_to_message_id,
121
+ reply_markup=keyboard,
122
+ parse_mode=parse_mode,
123
+ )
124
+ except Exception as exc:
125
+ log_event(
126
+ self._logger,
127
+ logging.WARNING,
128
+ "telegram.approval.send_failed",
129
+ request_id=request_id,
130
+ turn_id=turn_id,
131
+ chat_id=ctx.chat_id,
132
+ thread_id=ctx.thread_id,
133
+ exc=exc,
134
+ )
135
+ self._store.clear_pending_approval(request_id)
136
+ try:
137
+ await self._send_message(
138
+ ctx.chat_id,
139
+ "Approval prompt failed to send; canceling approval. "
140
+ "Please retry or use /interrupt.",
141
+ thread_id=ctx.thread_id,
142
+ reply_to=ctx.reply_to_message_id,
143
+ )
144
+ except Exception:
145
+ pass
146
+ return "cancel"
147
+ message_id = response.get("message_id") if isinstance(response, dict) else None
148
+ if isinstance(message_id, int):
149
+ approval_record.message_id = message_id
150
+ self._store.upsert_pending_approval(approval_record)
151
+ loop = asyncio.get_running_loop()
152
+ future: asyncio.Future[ApprovalDecision] = loop.create_future()
153
+ pending = PendingApproval(
154
+ request_id=request_id,
155
+ turn_id=str(turn_id),
156
+ codex_thread_id=codex_thread_id,
157
+ chat_id=ctx.chat_id,
158
+ thread_id=ctx.thread_id,
159
+ topic_key=ctx.topic_key,
160
+ message_id=message_id if isinstance(message_id, int) else None,
161
+ created_at=created_at,
162
+ future=future,
163
+ )
164
+ self._pending_approvals[request_id] = pending
165
+ self._touch_cache_timestamp("pending_approvals", request_id)
166
+ runtime = self._router.runtime_for(ctx.topic_key)
167
+ runtime.pending_request_id = request_id
168
+ try:
169
+ return await asyncio.wait_for(
170
+ future, timeout=DEFAULT_APPROVAL_TIMEOUT_SECONDS
171
+ )
172
+ except asyncio.TimeoutError:
173
+ self._pending_approvals.pop(request_id, None)
174
+ self._store.clear_pending_approval(request_id)
175
+ runtime.pending_request_id = None
176
+ log_event(
177
+ self._logger,
178
+ logging.WARNING,
179
+ "telegram.approval.timeout",
180
+ request_id=request_id,
181
+ turn_id=turn_id,
182
+ chat_id=ctx.chat_id,
183
+ thread_id=ctx.thread_id,
184
+ timeout_seconds=DEFAULT_APPROVAL_TIMEOUT_SECONDS,
185
+ )
186
+ if pending.message_id is not None:
187
+ await self._edit_message_text(
188
+ pending.chat_id,
189
+ pending.message_id,
190
+ "Approval timed out.",
191
+ reply_markup={"inline_keyboard": []},
192
+ )
193
+ return "cancel"
194
+ except asyncio.CancelledError:
195
+ self._pending_approvals.pop(request_id, None)
196
+ self._store.clear_pending_approval(request_id)
197
+ runtime.pending_request_id = None
198
+ raise
199
+
200
+ async def _handle_approval_callback(
201
+ self, callback: TelegramCallbackQuery, parsed: ApprovalCallback
202
+ ) -> None:
203
+ self._store.clear_pending_approval(parsed.request_id)
204
+ pending = self._pending_approvals.pop(parsed.request_id, None)
205
+ if pending is None:
206
+ await self._answer_callback(callback, "Approval already handled")
207
+ return
208
+ if not pending.future.done():
209
+ pending.future.set_result(parsed.decision)
210
+ ctx = self._resolve_turn_context(
211
+ pending.turn_id, thread_id=pending.codex_thread_id
212
+ )
213
+ if ctx:
214
+ runtime_key = ctx.topic_key
215
+ elif pending.topic_key:
216
+ runtime_key = pending.topic_key
217
+ else:
218
+ runtime_key = self._resolve_topic_key(pending.chat_id, pending.thread_id)
219
+ runtime = self._router.runtime_for(runtime_key)
220
+ runtime.pending_request_id = None
221
+ log_event(
222
+ self._logger,
223
+ logging.INFO,
224
+ "telegram.approval.decision",
225
+ request_id=parsed.request_id,
226
+ decision=parsed.decision,
227
+ chat_id=callback.chat_id,
228
+ thread_id=callback.thread_id,
229
+ message_id=callback.message_id,
230
+ )
231
+ await self._answer_callback(callback, f"Decision: {parsed.decision}")
232
+ if pending.message_id is not None:
233
+ try:
234
+ await self._edit_message_text(
235
+ pending.chat_id,
236
+ pending.message_id,
237
+ _format_approval_decision(parsed.decision),
238
+ reply_markup={"inline_keyboard": []},
239
+ )
240
+ except Exception:
241
+ return
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from ..adapter import (
6
+ ApprovalCallback,
7
+ BindCallback,
8
+ CancelCallback,
9
+ CompactCallback,
10
+ EffortCallback,
11
+ ModelCallback,
12
+ PageCallback,
13
+ ResumeCallback,
14
+ ReviewCommitCallback,
15
+ TelegramCallbackQuery,
16
+ UpdateCallback,
17
+ UpdateConfirmCallback,
18
+ parse_callback_data,
19
+ )
20
+
21
+
22
+ def _selection_contains(items: Sequence[tuple[str, str]], value: str) -> bool:
23
+ return any(item_id == value for item_id, _ in items)
24
+
25
+
26
+ async def handle_callback(handlers: Any, callback: TelegramCallbackQuery) -> None:
27
+ parsed = parse_callback_data(callback.data)
28
+ if parsed is None:
29
+ return
30
+ key = None
31
+ if callback.chat_id is not None:
32
+ key = handlers._resolve_topic_key(callback.chat_id, callback.thread_id)
33
+ if isinstance(parsed, ApprovalCallback):
34
+ await handlers._handle_approval_callback(callback, parsed)
35
+ elif isinstance(parsed, ResumeCallback):
36
+ if key:
37
+ state = handlers._resume_options.get(key)
38
+ if not state or not _selection_contains(state.items, parsed.thread_id):
39
+ await handlers._answer_callback(callback, "Selection expired")
40
+ return
41
+ await handlers._resume_thread_by_id(key, parsed.thread_id, callback)
42
+ elif isinstance(parsed, BindCallback):
43
+ if key:
44
+ state = handlers._bind_options.get(key)
45
+ if not state or not _selection_contains(state.items, parsed.repo_id):
46
+ await handlers._answer_callback(callback, "Selection expired")
47
+ return
48
+ await handlers._bind_topic_by_repo_id(key, parsed.repo_id, callback)
49
+ elif isinstance(parsed, ModelCallback):
50
+ if key:
51
+ await handlers._handle_model_callback(key, callback, parsed)
52
+ elif isinstance(parsed, EffortCallback):
53
+ if key:
54
+ await handlers._handle_effort_callback(key, callback, parsed)
55
+ elif isinstance(parsed, UpdateCallback):
56
+ if key:
57
+ await handlers._handle_update_callback(key, callback, parsed)
58
+ elif isinstance(parsed, UpdateConfirmCallback):
59
+ if key:
60
+ await handlers._handle_update_confirm_callback(key, callback, parsed)
61
+ elif isinstance(parsed, ReviewCommitCallback):
62
+ if key:
63
+ await handlers._handle_review_commit_callback(key, callback, parsed)
64
+ elif isinstance(parsed, CancelCallback):
65
+ if key:
66
+ await handlers._handle_selection_cancel(key, parsed, callback)
67
+ elif isinstance(parsed, CompactCallback):
68
+ if key:
69
+ await handlers._handle_compact_callback(key, callback, parsed)
70
+ elif isinstance(parsed, PageCallback):
71
+ if key:
72
+ await handlers._handle_selection_page(key, parsed, callback)