codex-telegram 0.5.0__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.
Files changed (100) hide show
  1. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/PKG-INFO +2 -6
  2. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/app_runtime/bootstrap.py +2 -0
  3. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/callbacks.py +28 -1
  4. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/handlers.py +17 -3
  5. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/projects.py +9 -0
  6. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/threads.py +18 -0
  7. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/event_forwarding.py +67 -4
  8. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram/__init__.py +1 -1
  9. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/PKG-INFO +2 -6
  10. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/SOURCES.txt +6 -2
  11. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/models/state.py +1 -0
  12. codex-telegram-0.5.1/models/telegram_bridge.py +77 -0
  13. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/models/user.py +66 -0
  14. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_callbacks_results.py +91 -1
  15. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_handlers_results.py +50 -0
  16. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_main_approval_flow.py +205 -21
  17. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_web_runtime.py +6 -0
  18. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_web_server_local_command.py +362 -0
  19. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/routes.py +118 -4
  20. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/runtime.py +12 -0
  21. codex-telegram-0.5.1/web/static/dist/assets/index-DIOICBNe.js +251 -0
  22. codex-telegram-0.5.1/web/static/dist/assets/index-DW1AcoLg.css +1 -0
  23. codex-telegram-0.5.1/web/static/dist/favicon.ico +0 -0
  24. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/static/dist/index.html +3 -2
  25. codex-telegram-0.5.1/web/telegram_sync.py +79 -0
  26. codex-telegram-0.5.1/web/telegram_thread_selection.py +182 -0
  27. codex_telegram-0.5.0/web/static/dist/assets/index-CAl6dA4Q.js +0 -53
  28. codex_telegram-0.5.0/web/static/dist/assets/index-C_g51sA5.css +0 -1
  29. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/LICENSE +0 -0
  30. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/MANIFEST.in +0 -0
  31. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/README.md +0 -0
  32. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/TELEGRAM.md +0 -0
  33. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/WEB.md +0 -0
  34. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/app_runtime/__init__.py +0 -0
  35. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/app_runtime/telegram_app.py +0 -0
  36. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/app_runtime/web_server.py +0 -0
  37. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/__init__.py +0 -0
  38. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/features_ui.py +0 -0
  39. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/keyboard.py +0 -0
  40. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/projects_ui.py +0 -0
  41. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/skills_ui.py +0 -0
  42. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/bot/thread_ui.py +0 -0
  43. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/__init__.py +0 -0
  44. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/approval_flow.py +0 -0
  45. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/approval_guardian.py +0 -0
  46. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/client.py +0 -0
  47. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/client_pool.py +0 -0
  48. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/collaboration_mode.py +0 -0
  49. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/__init__.py +0 -0
  50. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/common.py +0 -0
  51. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/context.py +0 -0
  52. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/contracts.py +0 -0
  53. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/core.py +0 -0
  54. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/review.py +0 -0
  55. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/command_router/system.py +0 -0
  56. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/commands.py +0 -0
  57. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/events.py +0 -0
  58. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex/protocol.py +0 -0
  59. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram/cli.py +0 -0
  60. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/dependency_links.txt +0 -0
  61. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/entry_points.txt +0 -0
  62. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/requires.txt +1 -1
  63. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/codex_telegram.egg-info/top_level.txt +0 -0
  64. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/conf.toml.example +0 -0
  65. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/main.py +0 -0
  66. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/models/__init__.py +0 -0
  67. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/models/thread.py +0 -0
  68. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/pyproject.toml +0 -0
  69. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/setup.cfg +0 -0
  70. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_approval_guardian.py +0 -0
  71. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_approval_policy.py +0 -0
  72. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_bootstrap_update_check.py +0 -0
  73. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_cli_entrypoint.py +0 -0
  74. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_codex_client.py +0 -0
  75. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_codex_client_pool.py +0 -0
  76. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_command_router.py +0 -0
  77. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_guardian_config.py +0 -0
  78. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_keyboard.py +0 -0
  79. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_normalize.py +0 -0
  80. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_pypi_check.py +0 -0
  81. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_single_instance.py +0 -0
  82. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_telegram_bot_config.py +0 -0
  83. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/tests/test_thread_history.py +0 -0
  84. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/__init__.py +0 -0
  85. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/approval_policy.py +0 -0
  86. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/config.py +0 -0
  87. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/local_command.py +0 -0
  88. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/logger.py +0 -0
  89. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/normalize.py +0 -0
  90. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/pypi_check.py +0 -0
  91. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/single_instance.py +0 -0
  92. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/utils/web_config.py +0 -0
  93. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/__init__.py +0 -0
  94. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/dependencies.py +0 -0
  95. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/server.py +0 -0
  96. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/static/dist/assets/codex-telegram-icon-black.svg +0 -0
  97. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/static/dist/assets/codex-telegram-icon-ivory.svg +0 -0
  98. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/static/dist/assets/codex-telegram-login-image.png +0 -0
  99. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/thread_history.py +0 -0
  100. {codex_telegram-0.5.0 → codex-telegram-0.5.1}/web/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: codex-telegram
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -218,10 +218,6 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
218
218
  Requires-Python: >=3.11
219
219
  Description-Content-Type: text/markdown
220
220
  License-File: LICENSE
221
- Requires-Dist: python-telegram-bot>=20.0
222
- Requires-Dist: fastapi>=0.111.0
223
- Requires-Dist: uvicorn>=0.30.0
224
- Dynamic: license-file
225
221
 
226
222
  # Codex Telegram + Web
227
223
 
@@ -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
- await edit_with_log(query, context, result.text, user_id, reply_markup=_main_menu_markup(user_id))
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
- if state_user.active_turn_id:
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: {state_user.active_turn_id}\n"
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 = f"\n\nturnId: {turn_id or 'unknown'}"
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 = f"\n\nturnId: {turn_id or 'unknown'}" if index == last_index else ""
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, f"\n\nturnId: {turn_id or 'unknown'}"),
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 = f"\n\nturnId: {turn_id or 'unknown'}"
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",
@@ -1,3 +1,3 @@
1
1
  """codex-telegram package."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.5.1"
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: codex-telegram
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -218,10 +218,6 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
218
218
  Requires-Python: >=3.11
219
219
  Description-Content-Type: text/markdown
220
220
  License-File: LICENSE
221
- Requires-Dist: python-telegram-bot>=20.0
222
- Requires-Dist: fastapi>=0.111.0
223
- Requires-Dist: uvicorn>=0.30.0
224
- Dynamic: license-file
225
221
 
226
222
  # Codex Telegram + Web
227
223
 
@@ -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,11 +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
90
+ web/static/dist/favicon.ico
87
91
  web/static/dist/index.html
88
92
  web/static/dist/assets/codex-telegram-icon-black.svg
89
93
  web/static/dist/assets/codex-telegram-icon-ivory.svg
90
94
  web/static/dist/assets/codex-telegram-login-image.png
91
- web/static/dist/assets/index-CAl6dA4Q.js
92
- web/static/dist/assets/index-C_g51sA5.css
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()