codex-telegram 0.4.16__tar.gz → 0.5.1__tar.gz
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.
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/PKG-INFO +1 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/app_runtime/bootstrap.py +2 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/callbacks.py +28 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/handlers.py +17 -3
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/projects.py +9 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/threads.py +18 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/event_forwarding.py +67 -4
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram/__init__.py +1 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/PKG-INFO +1 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/SOURCES.txt +9 -4
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/models/state.py +1 -0
- codex-telegram-0.5.1/models/telegram_bridge.py +77 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/models/user.py +66 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_callbacks_results.py +91 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_handlers_results.py +50 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_main_approval_flow.py +205 -21
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_web_runtime.py +6 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_web_server_local_command.py +413 -1
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/routes.py +141 -4
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/runtime.py +12 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/server.py +1 -1
- codex-telegram-0.5.1/web/static/dist/assets/codex-telegram-icon-black.svg +37 -0
- codex-telegram-0.5.1/web/static/dist/assets/codex-telegram-icon-ivory.svg +37 -0
- codex-telegram-0.5.1/web/static/dist/assets/codex-telegram-login-image.png +0 -0
- codex-telegram-0.5.1/web/static/dist/assets/index-DIOICBNe.js +251 -0
- codex-telegram-0.5.1/web/static/dist/assets/index-DW1AcoLg.css +1 -0
- codex-telegram-0.5.1/web/static/dist/favicon.ico +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/static/dist/index.html +4 -3
- codex-telegram-0.5.1/web/telegram_sync.py +79 -0
- codex-telegram-0.5.1/web/telegram_thread_selection.py +182 -0
- codex-telegram-0.4.16/web/static/dist/assets/index-C8IolvBK.css +0 -1
- codex-telegram-0.4.16/web/static/dist/assets/index-ERTv2jbE.js +0 -53
- codex-telegram-0.4.16/web/static/dist/icons-tab-add.svg +0 -3
- codex-telegram-0.4.16/web/static/dist/icons-tab-close.svg +0 -3
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/LICENSE +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/MANIFEST.in +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/README.md +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/TELEGRAM.md +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/WEB.md +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/app_runtime/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/app_runtime/telegram_app.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/app_runtime/web_server.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/features_ui.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/keyboard.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/projects_ui.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/skills_ui.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/bot/thread_ui.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/approval_flow.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/approval_guardian.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/client.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/client_pool.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/collaboration_mode.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/common.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/context.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/contracts.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/core.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/review.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/command_router/system.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/commands.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/events.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex/protocol.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram/cli.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/dependency_links.txt +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/entry_points.txt +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/requires.txt +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/codex_telegram.egg-info/top_level.txt +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/conf.toml.example +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/main.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/models/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/models/thread.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/pyproject.toml +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/setup.cfg +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_approval_guardian.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_approval_policy.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_bootstrap_update_check.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_cli_entrypoint.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_codex_client.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_codex_client_pool.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_command_router.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_guardian_config.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_keyboard.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_normalize.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_pypi_check.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_single_instance.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_telegram_bot_config.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/tests/test_thread_history.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/approval_policy.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/config.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/local_command.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/logger.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/normalize.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/pypi_check.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/single_instance.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/utils/web_config.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/__init__.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/dependencies.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/thread_history.py +0 -0
- {codex-telegram-0.4.16 → codex-telegram-0.5.1}/web/workspace.py +0 -0
|
@@ -56,6 +56,7 @@ async def post_init(
|
|
|
56
56
|
match_approval_policy: Callable[..., Any],
|
|
57
57
|
to_thread: Callable[..., Awaitable[Any]],
|
|
58
58
|
) -> None:
|
|
59
|
+
state.telegram_app = app
|
|
59
60
|
state.codex_client = await setup_codex_fn()
|
|
60
61
|
state.command_router = command_router_factory(state.codex_client)
|
|
61
62
|
state.approval_guardian = approval_guardian_factory()
|
|
@@ -95,6 +96,7 @@ async def post_shutdown(
|
|
|
95
96
|
*,
|
|
96
97
|
state_module=state,
|
|
97
98
|
) -> None:
|
|
99
|
+
state_module.telegram_app = None
|
|
98
100
|
if state_module.codex_client:
|
|
99
101
|
await state_module.codex_client.stop()
|
|
100
102
|
state_module.codex_client = None
|
|
@@ -14,6 +14,10 @@ from models import state
|
|
|
14
14
|
from models.user import user_manager
|
|
15
15
|
from codex.commands import CommandResult
|
|
16
16
|
from web.runtime import event_hub
|
|
17
|
+
from web.telegram_thread_selection import (
|
|
18
|
+
format_telegram_active_thread_changed_message,
|
|
19
|
+
preview_from_read_text,
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
logger = logging.getLogger("codex-telegram.bot")
|
|
19
23
|
GUARDIAN_WEB_ONLY_TEXT = "Guardian settings and rules can be edited in Web UI only."
|
|
@@ -227,6 +231,12 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
227
231
|
if command == "start":
|
|
228
232
|
logger.info("Executing callback action user_id=%s data=%s", user_id, data)
|
|
229
233
|
result = await run_callback_command("/start", user_id)
|
|
234
|
+
logger.info(
|
|
235
|
+
"Telegram callback /start result user_id=%s kind=%s thread_id=%s",
|
|
236
|
+
user_id,
|
|
237
|
+
result.kind,
|
|
238
|
+
result.meta.get("thread_id"),
|
|
239
|
+
)
|
|
230
240
|
await context.bot.send_message(chat_id=chat_id, text=result.text, reply_markup=_main_menu_markup(user_id))
|
|
231
241
|
elif command == "menu":
|
|
232
242
|
logger.info("Executing callback action user_id=%s data=%s", user_id, data)
|
|
@@ -400,7 +410,24 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
400
410
|
thread_id = data[7:]
|
|
401
411
|
logger.info("Executing callback action user_id=%s data=%s", user_id, data)
|
|
402
412
|
result = await state.command_router.route("/resume", [thread_id], user_id)
|
|
403
|
-
|
|
413
|
+
if result.kind == "error":
|
|
414
|
+
await edit_with_log(
|
|
415
|
+
query,
|
|
416
|
+
context,
|
|
417
|
+
result.text,
|
|
418
|
+
user_id,
|
|
419
|
+
reply_markup=_main_menu_markup(user_id),
|
|
420
|
+
)
|
|
421
|
+
return
|
|
422
|
+
resolved_thread_id = result.meta.get("thread_id") or thread_id
|
|
423
|
+
read_result = await state.command_router.route("/read", [resolved_thread_id], user_id)
|
|
424
|
+
preview = preview_from_read_text(resolved_thread_id, read_result.text)
|
|
425
|
+
message = format_telegram_active_thread_changed_message(
|
|
426
|
+
preview,
|
|
427
|
+
project_key=user_manager.get_thread_project(resolved_thread_id),
|
|
428
|
+
resume_status="ok",
|
|
429
|
+
)
|
|
430
|
+
await edit_with_log(query, context, message, user_id, reply_markup=_main_menu_markup(user_id))
|
|
404
431
|
|
|
405
432
|
elif data.startswith("fork:"):
|
|
406
433
|
thread_id = data[5:]
|
|
@@ -21,6 +21,7 @@ from bot.features_ui import features_keyboard, features_panel_text
|
|
|
21
21
|
from models import state
|
|
22
22
|
from utils.single_instance import find_local_conflict_candidates
|
|
23
23
|
from utils.local_command import run_bang_command
|
|
24
|
+
from web.telegram_sync import bind_telegram_thread_to_active_web
|
|
24
25
|
|
|
25
26
|
logger = logging.getLogger("codex-telegram.bot")
|
|
26
27
|
_last_conflict_log_at = 0.0
|
|
@@ -182,12 +183,15 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
182
183
|
)
|
|
183
184
|
return
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
active_thread_running_turn_id = state_user.get_turn_for_thread(state_user.active_thread_id)
|
|
187
|
+
if not active_thread_running_turn_id and state_user.active_turn_id and not state_user.active_turn_id_by_thread:
|
|
188
|
+
active_thread_running_turn_id = state_user.active_turn_id
|
|
189
|
+
if active_thread_running_turn_id:
|
|
186
190
|
await send_reply(
|
|
187
191
|
update,
|
|
188
192
|
(
|
|
189
193
|
"A turn is already running.\n"
|
|
190
|
-
f"turnId: {
|
|
194
|
+
f"turnId: {active_thread_running_turn_id}\n"
|
|
191
195
|
"Use /interrupt (or the Interrupt button) to stop it first."
|
|
192
196
|
),
|
|
193
197
|
user_id,
|
|
@@ -205,6 +209,12 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
205
209
|
"threadId": state_user.active_thread_id,
|
|
206
210
|
"input": [{"type": "text", "text": text}],
|
|
207
211
|
}
|
|
212
|
+
bound_user_ids = await bind_telegram_thread_to_active_web(
|
|
213
|
+
user_id,
|
|
214
|
+
state_user.active_thread_id,
|
|
215
|
+
project_key=user_manager.get_thread_project(state_user.active_thread_id),
|
|
216
|
+
notify_web=False,
|
|
217
|
+
)
|
|
208
218
|
collaboration_mode = await _require_turn_collaboration_mode(state_user)
|
|
209
219
|
params["collaborationMode"] = collaboration_mode
|
|
210
220
|
logger.info(
|
|
@@ -220,7 +230,11 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
220
230
|
turn = result.get("turn", {})
|
|
221
231
|
turn_id = turn.get("id", "unknown")
|
|
222
232
|
if isinstance(turn_id, str) and turn_id and turn_id != "unknown":
|
|
223
|
-
state_user.set_turn(turn_id)
|
|
233
|
+
state_user.set_turn(turn_id, state_user.active_thread_id)
|
|
234
|
+
user_manager.bind_turn(user_id, turn_id, state_user.active_thread_id)
|
|
235
|
+
for bound_user_id in bound_user_ids:
|
|
236
|
+
if bound_user_id != user_id:
|
|
237
|
+
user_manager.bind_turn_subscriber(bound_user_id, turn_id, state_user.active_thread_id)
|
|
224
238
|
|
|
225
239
|
if state_user.selected_project_path:
|
|
226
240
|
await send_reply(update, f"Turn started: {turn_id}\nWorkspace: {state_user.selected_project_path}", user_id)
|
|
@@ -210,6 +210,15 @@ class ProjectCommands:
|
|
|
210
210
|
if isinstance(new_thread_id, str) and new_thread_id:
|
|
211
211
|
user_manager.set_active_thread(user_id, new_thread_id, project_key=selected["key"])
|
|
212
212
|
state.clear_turn()
|
|
213
|
+
if user_id > 0:
|
|
214
|
+
from web.telegram_sync import bind_telegram_thread_to_active_web
|
|
215
|
+
|
|
216
|
+
await bind_telegram_thread_to_active_web(
|
|
217
|
+
user_id,
|
|
218
|
+
new_thread_id,
|
|
219
|
+
project_key=selected["key"],
|
|
220
|
+
notify_web=True,
|
|
221
|
+
)
|
|
213
222
|
else:
|
|
214
223
|
user_manager.clear_active_thread(user_id)
|
|
215
224
|
thread_start_note = "\nProject switched, but failed to create a new thread. Run /start."
|
|
@@ -126,6 +126,15 @@ class ThreadCommands:
|
|
|
126
126
|
|
|
127
127
|
if thread_id:
|
|
128
128
|
user_manager.set_active_thread(user_id, thread_id, project_key=(project or {}).get("key"))
|
|
129
|
+
if user_id > 0:
|
|
130
|
+
from web.telegram_sync import bind_telegram_thread_to_active_web
|
|
131
|
+
|
|
132
|
+
await bind_telegram_thread_to_active_web(
|
|
133
|
+
user_id,
|
|
134
|
+
thread_id,
|
|
135
|
+
project_key=(project or {}).get("key"),
|
|
136
|
+
notify_web=True,
|
|
137
|
+
)
|
|
129
138
|
|
|
130
139
|
if project:
|
|
131
140
|
return text_result(
|
|
@@ -149,6 +158,15 @@ class ThreadCommands:
|
|
|
149
158
|
|
|
150
159
|
await self.ctx.codex.call("thread/resume", {"threadId": thread_id})
|
|
151
160
|
user_manager.set_active_thread(user_id, thread_id)
|
|
161
|
+
if user_id > 0:
|
|
162
|
+
from web.telegram_sync import bind_telegram_thread_to_active_web
|
|
163
|
+
|
|
164
|
+
await bind_telegram_thread_to_active_web(
|
|
165
|
+
user_id,
|
|
166
|
+
thread_id,
|
|
167
|
+
project_key=user_manager.get_thread_project(thread_id),
|
|
168
|
+
notify_web=True,
|
|
169
|
+
)
|
|
152
170
|
return text_result(f"Thread resumed: {thread_id}", thread_id=thread_id)
|
|
153
171
|
|
|
154
172
|
async def fork(self, args: list[str], user_id: int) -> CommandResult:
|
|
@@ -977,10 +977,16 @@ def truncate_telegram_text(text: str, footer: str) -> str:
|
|
|
977
977
|
return text[:head_len] + suffix + footer
|
|
978
978
|
|
|
979
979
|
|
|
980
|
+
def telegram_turn_footer(turn_id: str | None) -> str:
|
|
981
|
+
if isinstance(turn_id, str) and turn_id:
|
|
982
|
+
return f"\n\nturnId: {turn_id}"
|
|
983
|
+
return ""
|
|
984
|
+
|
|
985
|
+
|
|
980
986
|
async def send_telegram_message(app, user_id: int, text: str, turn_id: str | None) -> None:
|
|
981
987
|
if user_id <= 0 or app is None or not text.strip():
|
|
982
988
|
return
|
|
983
|
-
footer =
|
|
989
|
+
footer = telegram_turn_footer(turn_id)
|
|
984
990
|
try:
|
|
985
991
|
await app.bot.send_message(
|
|
986
992
|
chat_id=user_id,
|
|
@@ -1020,7 +1026,7 @@ async def send_telegram_file_change(app, user_id: int, payload: dict[str, Any])
|
|
|
1020
1026
|
try:
|
|
1021
1027
|
last_index = len(lines) - 1
|
|
1022
1028
|
for index, line in enumerate(lines):
|
|
1023
|
-
footer =
|
|
1029
|
+
footer = telegram_turn_footer(turn_id) if index == last_index else ""
|
|
1024
1030
|
await app.bot.send_message(chat_id=user_id, text=truncate_telegram_text(line, footer))
|
|
1025
1031
|
if index < last_index:
|
|
1026
1032
|
await asyncio.sleep(FILE_CHANGE_LINE_DELAY_SECONDS)
|
|
@@ -1034,12 +1040,60 @@ async def send_telegram_plan(app, user_id: int, payload: dict[str, Any]) -> None
|
|
|
1034
1040
|
await send_telegram_message(app, user_id, f"Plan proposal\n\n{plan_text}", payload.get("turn_id"))
|
|
1035
1041
|
|
|
1036
1042
|
|
|
1043
|
+
def filter_telegram_targets_to_active_thread(
|
|
1044
|
+
method: str,
|
|
1045
|
+
target_user_ids: set[int],
|
|
1046
|
+
thread_id: str | None,
|
|
1047
|
+
turn_id: str | None,
|
|
1048
|
+
params: dict | None,
|
|
1049
|
+
) -> set[int]:
|
|
1050
|
+
if not target_user_ids:
|
|
1051
|
+
return set()
|
|
1052
|
+
payload_thread = (params or {}).get("thread")
|
|
1053
|
+
parent_thread_id = (
|
|
1054
|
+
payload_thread.get("forkedFromId")
|
|
1055
|
+
if isinstance(payload_thread, dict) and isinstance(payload_thread.get("forkedFromId"), str)
|
|
1056
|
+
else None
|
|
1057
|
+
)
|
|
1058
|
+
filtered: set[int] = set()
|
|
1059
|
+
for user_id in target_user_ids:
|
|
1060
|
+
if user_id <= 0:
|
|
1061
|
+
filtered.add(user_id)
|
|
1062
|
+
continue
|
|
1063
|
+
active_thread_id = user_manager.get(user_id).active_thread_id
|
|
1064
|
+
if (
|
|
1065
|
+
isinstance(thread_id, str)
|
|
1066
|
+
and thread_id
|
|
1067
|
+
and (active_thread_id == thread_id or active_thread_id == parent_thread_id)
|
|
1068
|
+
):
|
|
1069
|
+
filtered.add(user_id)
|
|
1070
|
+
continue
|
|
1071
|
+
if not thread_id and method in {"turn/completed", "turn/failed", "turn/cancelled"} and active_thread_id:
|
|
1072
|
+
filtered.add(user_id)
|
|
1073
|
+
continue
|
|
1074
|
+
logger.info(
|
|
1075
|
+
"Telegram forwarding skipped method=%s thread_id=%s turn_id=%s telegram_user_id=%s active_thread_id=%s reason=active_thread_mismatch",
|
|
1076
|
+
method,
|
|
1077
|
+
thread_id,
|
|
1078
|
+
turn_id,
|
|
1079
|
+
user_id,
|
|
1080
|
+
active_thread_id,
|
|
1081
|
+
)
|
|
1082
|
+
return filtered
|
|
1083
|
+
|
|
1084
|
+
|
|
1037
1085
|
def build_event_forwarder(app, config: ForwardingConfig):
|
|
1038
1086
|
async def forward_event(method: str, params: dict | None):
|
|
1039
1087
|
thread_id = extract_thread_id(method, params)
|
|
1040
1088
|
turn_id = extract_turn_id(method, params)
|
|
1041
1089
|
if thread_id is None and turn_id:
|
|
1042
1090
|
thread_id = user_manager.get_turn_thread(turn_id)
|
|
1091
|
+
if not turn_id and thread_id:
|
|
1092
|
+
for user_id in user_manager.find_user_ids_by_thread(thread_id):
|
|
1093
|
+
inferred_turn_id = user_manager.get(user_id).get_turn_for_thread(thread_id)
|
|
1094
|
+
if inferred_turn_id:
|
|
1095
|
+
turn_id = inferred_turn_id
|
|
1096
|
+
break
|
|
1043
1097
|
target_user_ids = set()
|
|
1044
1098
|
if turn_id:
|
|
1045
1099
|
target_user_ids.update(user_manager.find_user_ids_by_turn(turn_id))
|
|
@@ -1050,6 +1104,7 @@ def build_event_forwarder(app, config: ForwardingConfig):
|
|
|
1050
1104
|
fallback_owner = user_manager.find_single_active_turn_owner()
|
|
1051
1105
|
if fallback_owner is not None:
|
|
1052
1106
|
target_user_ids.add(fallback_owner)
|
|
1107
|
+
target_user_ids = filter_telegram_targets_to_active_thread(method, target_user_ids, thread_id, turn_id, params)
|
|
1053
1108
|
|
|
1054
1109
|
if method == "turn/started" and turn_id and target_user_ids:
|
|
1055
1110
|
actual_mode = normalize_mode_kind((params or {}).get("collaboration_mode_kind") or (params or {}).get("collaborationModeKind"))
|
|
@@ -1076,7 +1131,7 @@ def build_event_forwarder(app, config: ForwardingConfig):
|
|
|
1076
1131
|
try:
|
|
1077
1132
|
await app.bot.send_message(
|
|
1078
1133
|
chat_id=uid,
|
|
1079
|
-
text=truncate_telegram_text(message,
|
|
1134
|
+
text=truncate_telegram_text(message, telegram_turn_footer(turn_id)),
|
|
1080
1135
|
)
|
|
1081
1136
|
except Exception:
|
|
1082
1137
|
logger.exception("Failed to forward app-server error to Telegram")
|
|
@@ -1235,12 +1290,20 @@ def build_event_forwarder(app, config: ForwardingConfig):
|
|
|
1235
1290
|
if event_level(method, params) < config.threshold:
|
|
1236
1291
|
return
|
|
1237
1292
|
telegram_user_ids = [uid for uid in target_user_ids if uid > 0]
|
|
1293
|
+
logger.info(
|
|
1294
|
+
"Telegram forwarding targets method=%s thread_id=%s turn_id=%s target_user_ids=%s telegram_user_ids=%s",
|
|
1295
|
+
method,
|
|
1296
|
+
thread_id,
|
|
1297
|
+
turn_id,
|
|
1298
|
+
sorted(target_user_ids),
|
|
1299
|
+
sorted(telegram_user_ids),
|
|
1300
|
+
)
|
|
1238
1301
|
if not telegram_user_ids or app is None:
|
|
1239
1302
|
return
|
|
1240
1303
|
message = format_event(method, params, config.rules)
|
|
1241
1304
|
if message is None or not message.strip():
|
|
1242
1305
|
return
|
|
1243
|
-
footer =
|
|
1306
|
+
footer = telegram_turn_footer(turn_id)
|
|
1244
1307
|
for user_id in telegram_user_ids:
|
|
1245
1308
|
logger.info(
|
|
1246
1309
|
"Forwarding app-server event to Telegram user_id=%s method=%s message=%s",
|
|
@@ -47,6 +47,7 @@ codex_telegram.egg-info/requires.txt
|
|
|
47
47
|
codex_telegram.egg-info/top_level.txt
|
|
48
48
|
models/__init__.py
|
|
49
49
|
models/state.py
|
|
50
|
+
models/telegram_bridge.py
|
|
50
51
|
models/thread.py
|
|
51
52
|
models/user.py
|
|
52
53
|
tests/test_approval_guardian.py
|
|
@@ -82,10 +83,14 @@ web/dependencies.py
|
|
|
82
83
|
web/routes.py
|
|
83
84
|
web/runtime.py
|
|
84
85
|
web/server.py
|
|
86
|
+
web/telegram_sync.py
|
|
87
|
+
web/telegram_thread_selection.py
|
|
85
88
|
web/thread_history.py
|
|
86
89
|
web/workspace.py
|
|
87
|
-
web/static/dist/
|
|
88
|
-
web/static/dist/icons-tab-close.svg
|
|
90
|
+
web/static/dist/favicon.ico
|
|
89
91
|
web/static/dist/index.html
|
|
90
|
-
web/static/dist/assets/
|
|
91
|
-
web/static/dist/assets/
|
|
92
|
+
web/static/dist/assets/codex-telegram-icon-black.svg
|
|
93
|
+
web/static/dist/assets/codex-telegram-icon-ivory.svg
|
|
94
|
+
web/static/dist/assets/codex-telegram-login-image.png
|
|
95
|
+
web/static/dist/assets/index-DIOICBNe.js
|
|
96
|
+
web/static/dist/assets/index-DW1AcoLg.css
|
|
@@ -7,5 +7,6 @@ from codex.approval_guardian import ApprovalGuardianService
|
|
|
7
7
|
codex_client: Any | None = None
|
|
8
8
|
command_router: CommandRouter | None = None
|
|
9
9
|
approval_guardian: ApprovalGuardianService | None = None
|
|
10
|
+
telegram_app: Any | None = None
|
|
10
11
|
codex_ready = asyncio.Event()
|
|
11
12
|
update_notified: bool = False
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from utils.config import get
|
|
2
|
+
|
|
3
|
+
from .user import user_manager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TelegramBridgeConfigError(ValueError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def single_allowed_telegram_user_id() -> int | None:
|
|
11
|
+
allowed_ids = get("users.allowed_ids", [])
|
|
12
|
+
if not isinstance(allowed_ids, list) or len(allowed_ids) != 1:
|
|
13
|
+
return None
|
|
14
|
+
try:
|
|
15
|
+
user_id = int(allowed_ids[0])
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
return None
|
|
18
|
+
if user_id <= 0:
|
|
19
|
+
return None
|
|
20
|
+
return user_id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def require_single_allowed_telegram_user_id() -> int:
|
|
24
|
+
allowed_ids = get("users.allowed_ids", [])
|
|
25
|
+
if not isinstance(allowed_ids, list) or len(allowed_ids) != 1:
|
|
26
|
+
raise TelegramBridgeConfigError(
|
|
27
|
+
"Open in Telegram requires exactly one Telegram user in users.allowed_ids."
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
user_id = int(allowed_ids[0])
|
|
31
|
+
except (TypeError, ValueError) as exc:
|
|
32
|
+
raise TelegramBridgeConfigError(
|
|
33
|
+
"Open in Telegram requires users.allowed_ids to contain one numeric Telegram user id."
|
|
34
|
+
) from exc
|
|
35
|
+
if user_id <= 0:
|
|
36
|
+
raise TelegramBridgeConfigError(
|
|
37
|
+
"Open in Telegram requires a positive Telegram user id in users.allowed_ids."
|
|
38
|
+
)
|
|
39
|
+
return user_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bind_thread_counterparts(
|
|
43
|
+
user_id: int,
|
|
44
|
+
thread_id: str | None,
|
|
45
|
+
*,
|
|
46
|
+
project_key: str | None = None,
|
|
47
|
+
web_user_ids: list[int] | tuple[int, ...] | set[int] | None = None,
|
|
48
|
+
) -> set[int]:
|
|
49
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
50
|
+
return set()
|
|
51
|
+
|
|
52
|
+
user_manager.bind_thread_subscriber(user_id, thread_id)
|
|
53
|
+
if isinstance(project_key, str) and project_key:
|
|
54
|
+
user_manager.bind_thread_project(thread_id, project_key)
|
|
55
|
+
|
|
56
|
+
bound_user_ids = {user_id}
|
|
57
|
+
telegram_user_id = single_allowed_telegram_user_id()
|
|
58
|
+
if telegram_user_id is None:
|
|
59
|
+
return bound_user_ids
|
|
60
|
+
|
|
61
|
+
if user_id > 0:
|
|
62
|
+
if user_id != telegram_user_id:
|
|
63
|
+
return bound_user_ids
|
|
64
|
+
user_manager.keep_user_thread_subscription_only(user_id, thread_id)
|
|
65
|
+
for web_user_id in web_user_ids or ():
|
|
66
|
+
if isinstance(web_user_id, int) and web_user_id < 0:
|
|
67
|
+
user_manager.bind_thread_subscriber(web_user_id, thread_id)
|
|
68
|
+
bound_user_ids.add(web_user_id)
|
|
69
|
+
return bound_user_ids
|
|
70
|
+
|
|
71
|
+
if user_id < 0:
|
|
72
|
+
telegram_state = user_manager.get(telegram_user_id)
|
|
73
|
+
if telegram_state.active_thread_id == thread_id:
|
|
74
|
+
user_manager.keep_user_thread_subscription_only(telegram_user_id, thread_id)
|
|
75
|
+
bound_user_ids.add(telegram_user_id)
|
|
76
|
+
|
|
77
|
+
return bound_user_ids
|
|
@@ -152,6 +152,17 @@ class UserManager:
|
|
|
152
152
|
if isinstance(thread_id, str) and thread_id:
|
|
153
153
|
self._thread_subscribers.setdefault(thread_id, set()).add(user_id)
|
|
154
154
|
|
|
155
|
+
def remove_thread_subscriber(self, user_id: int, thread_id: str | None):
|
|
156
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
157
|
+
return
|
|
158
|
+
subscribers = self._thread_subscribers.get(thread_id)
|
|
159
|
+
if subscribers is not None:
|
|
160
|
+
subscribers.discard(user_id)
|
|
161
|
+
if not subscribers:
|
|
162
|
+
self._thread_subscribers.pop(thread_id, None)
|
|
163
|
+
if self._thread_owners.get(thread_id) == user_id:
|
|
164
|
+
self._thread_owners.pop(thread_id, None)
|
|
165
|
+
|
|
155
166
|
def bind_thread_project(self, thread_id: str | None, project_key: str | None):
|
|
156
167
|
if isinstance(thread_id, str) and thread_id and isinstance(project_key, str) and project_key:
|
|
157
168
|
self._thread_projects[thread_id] = project_key
|
|
@@ -196,6 +207,7 @@ class UserManager:
|
|
|
196
207
|
if isinstance(turn_id, str) and turn_id:
|
|
197
208
|
self._turn_subscribers.setdefault(turn_id, set()).add(user_id)
|
|
198
209
|
if isinstance(thread_id, str) and thread_id:
|
|
210
|
+
self.bind_turn_thread(turn_id, thread_id)
|
|
199
211
|
self.bind_thread_subscriber(user_id, thread_id)
|
|
200
212
|
|
|
201
213
|
def bind_turn(self, user_id: int, turn_id: str | None, thread_id: str | None = None):
|
|
@@ -238,6 +250,60 @@ class UserManager:
|
|
|
238
250
|
user_ids.add(uid)
|
|
239
251
|
return user_ids
|
|
240
252
|
|
|
253
|
+
def remove_turn_subscriber(self, user_id: int, turn_id: str | None):
|
|
254
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
255
|
+
return
|
|
256
|
+
subscribers = self._turn_subscribers.get(turn_id)
|
|
257
|
+
if subscribers is not None:
|
|
258
|
+
subscribers.discard(user_id)
|
|
259
|
+
if not subscribers:
|
|
260
|
+
self._turn_subscribers.pop(turn_id, None)
|
|
261
|
+
if self._turn_owners.get(turn_id) == user_id:
|
|
262
|
+
self._turn_owners.pop(turn_id, None)
|
|
263
|
+
self.get(user_id).clear_turn(turn_id=turn_id)
|
|
264
|
+
|
|
265
|
+
def remove_user_turn_bindings_except_thread(self, user_id: int, thread_id: str | None):
|
|
266
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
267
|
+
return
|
|
268
|
+
turn_ids = set(self._turn_threads.keys())
|
|
269
|
+
turn_ids.update(
|
|
270
|
+
turn_id
|
|
271
|
+
for turn_id, subscribers in self._turn_subscribers.items()
|
|
272
|
+
if user_id in subscribers
|
|
273
|
+
)
|
|
274
|
+
turn_ids.update(
|
|
275
|
+
turn_id
|
|
276
|
+
for turn_id, owner_id in self._turn_owners.items()
|
|
277
|
+
if owner_id == user_id
|
|
278
|
+
)
|
|
279
|
+
user = self.get(user_id)
|
|
280
|
+
turn_ids.update(
|
|
281
|
+
turn_id
|
|
282
|
+
for mapped_thread_id, turn_id in user.active_turn_id_by_thread.items()
|
|
283
|
+
if mapped_thread_id != thread_id
|
|
284
|
+
)
|
|
285
|
+
for turn_id in list(turn_ids):
|
|
286
|
+
bound_thread_id = self._turn_threads.get(turn_id)
|
|
287
|
+
if bound_thread_id != thread_id:
|
|
288
|
+
self.remove_turn_subscriber(user_id, turn_id)
|
|
289
|
+
self.remove_thread_subscriber(user_id, bound_thread_id)
|
|
290
|
+
|
|
291
|
+
def keep_user_thread_subscription_only(self, user_id: int, thread_id: str | None):
|
|
292
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
293
|
+
return
|
|
294
|
+
for subscribed_thread_id in list(self._thread_subscribers.keys()):
|
|
295
|
+
if subscribed_thread_id != thread_id:
|
|
296
|
+
self.remove_thread_subscriber(user_id, subscribed_thread_id)
|
|
297
|
+
for owned_thread_id, owner_id in list(self._thread_owners.items()):
|
|
298
|
+
if owner_id == user_id and owned_thread_id != thread_id:
|
|
299
|
+
self._thread_owners.pop(owned_thread_id, None)
|
|
300
|
+
user = self.get(user_id)
|
|
301
|
+
for mapped_thread_id in list(user.active_turn_id_by_thread.keys()):
|
|
302
|
+
if mapped_thread_id != thread_id:
|
|
303
|
+
user.clear_turn(thread_id=mapped_thread_id)
|
|
304
|
+
self.remove_user_turn_bindings_except_thread(user_id, thread_id)
|
|
305
|
+
self.bind_thread_subscriber(user_id, thread_id)
|
|
306
|
+
|
|
241
307
|
def clear_turn_bindings(self, turn_id: str | None):
|
|
242
308
|
if not isinstance(turn_id, str) or not turn_id:
|
|
243
309
|
return
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
from types import SimpleNamespace
|
|
3
|
-
from unittest.mock import AsyncMock
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
4
|
|
|
5
5
|
from codex.commands import CommandResult
|
|
6
6
|
from bot import callbacks
|
|
@@ -127,6 +127,38 @@ class CallbackResultTests(unittest.IsolatedAsyncioTestCase):
|
|
|
127
127
|
self.assertIn("Collaboration mode set", kwargs["text"])
|
|
128
128
|
self.assertIn("reply_markup", kwargs)
|
|
129
129
|
|
|
130
|
+
async def test_callback_start_routes_new_thread_and_logs_thread_id(self):
|
|
131
|
+
self.mock_router.route.return_value = CommandResult(
|
|
132
|
+
kind="text",
|
|
133
|
+
text="Thread started: thread-callback-1",
|
|
134
|
+
meta={"thread_id": "thread-callback-1"},
|
|
135
|
+
)
|
|
136
|
+
query = SimpleNamespace(
|
|
137
|
+
data="cmd:start",
|
|
138
|
+
answer=AsyncMock(),
|
|
139
|
+
edit_message_text=AsyncMock(),
|
|
140
|
+
)
|
|
141
|
+
update = SimpleNamespace(
|
|
142
|
+
callback_query=query,
|
|
143
|
+
effective_user=SimpleNamespace(id=1),
|
|
144
|
+
effective_chat=SimpleNamespace(id=100),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
with patch("bot.callbacks._wait_for_codex", new=AsyncMock()), \
|
|
148
|
+
patch("bot.callbacks.logger") as mock_logger:
|
|
149
|
+
await callbacks.callback_handler(update, self.context)
|
|
150
|
+
|
|
151
|
+
self.mock_router.route.assert_awaited_once_with("/start", [], 1)
|
|
152
|
+
kwargs = self.context.bot.send_message.await_args.kwargs
|
|
153
|
+
self.assertEqual("Thread started: thread-callback-1", kwargs["text"])
|
|
154
|
+
self.assertIn("reply_markup", kwargs)
|
|
155
|
+
self.assertTrue(
|
|
156
|
+
any(
|
|
157
|
+
len(call.args) >= 4 and call.args[0] == "Telegram callback /start result user_id=%s kind=%s thread_id=%s"
|
|
158
|
+
for call in mock_logger.info.call_args_list
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
130
162
|
async def test_callback_menu_shows_current_mode(self):
|
|
131
163
|
user_manager.get(1).set_collaboration_mode("plan")
|
|
132
164
|
query = SimpleNamespace(
|
|
@@ -207,5 +239,63 @@ class CallbackResultTests(unittest.IsolatedAsyncioTestCase):
|
|
|
207
239
|
self.assertIn("Collaboration mode set", kwargs["text"])
|
|
208
240
|
self.assertIn("reply_markup", kwargs)
|
|
209
241
|
|
|
242
|
+
async def test_callback_resume_shows_thread_changed_preview(self):
|
|
243
|
+
self.mock_router.route.side_effect = [
|
|
244
|
+
CommandResult(kind="text", text="Thread resumed: thread-1", meta={"thread_id": "thread-1"}),
|
|
245
|
+
CommandResult(
|
|
246
|
+
kind="read",
|
|
247
|
+
text=(
|
|
248
|
+
"Thread: Existing discussion\n"
|
|
249
|
+
"Status: active\n"
|
|
250
|
+
"ID: thread-1\n"
|
|
251
|
+
"Turns: 1\n"
|
|
252
|
+
"Preview: What changed? It is synced."
|
|
253
|
+
),
|
|
254
|
+
meta={"thread_id": "thread-1"},
|
|
255
|
+
),
|
|
256
|
+
]
|
|
257
|
+
query = SimpleNamespace(
|
|
258
|
+
data="resume:thread-1",
|
|
259
|
+
answer=AsyncMock(),
|
|
260
|
+
edit_message_text=AsyncMock(),
|
|
261
|
+
)
|
|
262
|
+
update = SimpleNamespace(
|
|
263
|
+
callback_query=query,
|
|
264
|
+
effective_user=SimpleNamespace(id=1),
|
|
265
|
+
effective_chat=SimpleNamespace(id=100),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
await callbacks.callback_handler(update, self.context)
|
|
269
|
+
|
|
270
|
+
sent_text = query.edit_message_text.await_args.args[0]
|
|
271
|
+
self.assertIn("Telegram active thread changed", sent_text)
|
|
272
|
+
self.assertIn("Existing discussion", sent_text)
|
|
273
|
+
self.assertIn("What changed? It is synced.", sent_text)
|
|
274
|
+
self.mock_router.route.assert_any_await("/resume", ["thread-1"], 1)
|
|
275
|
+
self.mock_router.route.assert_any_await("/read", ["thread-1"], 1)
|
|
276
|
+
|
|
277
|
+
async def test_callback_resume_failure_stops_before_read(self):
|
|
278
|
+
self.mock_router.route.return_value = CommandResult(
|
|
279
|
+
kind="error",
|
|
280
|
+
text="Error: resume failed",
|
|
281
|
+
)
|
|
282
|
+
query = SimpleNamespace(
|
|
283
|
+
data="resume:thread-1",
|
|
284
|
+
answer=AsyncMock(),
|
|
285
|
+
edit_message_text=AsyncMock(),
|
|
286
|
+
)
|
|
287
|
+
update = SimpleNamespace(
|
|
288
|
+
callback_query=query,
|
|
289
|
+
effective_user=SimpleNamespace(id=1),
|
|
290
|
+
effective_chat=SimpleNamespace(id=100),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
await callbacks.callback_handler(update, self.context)
|
|
294
|
+
|
|
295
|
+
sent_text = query.edit_message_text.await_args.args[0]
|
|
296
|
+
self.assertEqual("Error: resume failed", sent_text)
|
|
297
|
+
self.assertNotIn("Telegram active thread changed", sent_text)
|
|
298
|
+
self.mock_router.route.assert_awaited_once_with("/resume", ["thread-1"], 1)
|
|
299
|
+
|
|
210
300
|
if __name__ == "__main__":
|
|
211
301
|
unittest.main()
|