codex-telegram 0.5.0__tar.gz → 0.5.2__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.2}/PKG-INFO +29 -1
  2. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/README.md +28 -0
  3. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/app_runtime/bootstrap.py +2 -0
  4. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/callbacks.py +28 -1
  5. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/handlers.py +17 -3
  6. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/projects.py +9 -0
  7. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/threads.py +18 -0
  8. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/event_forwarding.py +67 -4
  9. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram/__init__.py +1 -1
  10. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram/cli.py +50 -0
  11. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/PKG-INFO +29 -1
  12. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/SOURCES.txt +6 -2
  13. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/models/state.py +1 -0
  14. codex_telegram-0.5.2/models/telegram_bridge.py +77 -0
  15. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/models/user.py +66 -0
  16. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_callbacks_results.py +91 -1
  17. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_handlers_results.py +50 -0
  18. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_main_approval_flow.py +205 -21
  19. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_web_runtime.py +6 -0
  20. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_web_server_local_command.py +362 -0
  21. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/routes.py +118 -4
  22. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/runtime.py +12 -0
  23. codex_telegram-0.5.2/web/static/dist/assets/index-DIOICBNe.js +251 -0
  24. codex_telegram-0.5.2/web/static/dist/assets/index-DW1AcoLg.css +1 -0
  25. codex_telegram-0.5.2/web/static/dist/favicon.ico +0 -0
  26. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/static/dist/index.html +3 -2
  27. codex_telegram-0.5.2/web/telegram_sync.py +79 -0
  28. codex_telegram-0.5.2/web/telegram_thread_selection.py +182 -0
  29. codex_telegram-0.5.0/web/static/dist/assets/index-CAl6dA4Q.js +0 -53
  30. codex_telegram-0.5.0/web/static/dist/assets/index-C_g51sA5.css +0 -1
  31. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/LICENSE +0 -0
  32. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/MANIFEST.in +0 -0
  33. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/TELEGRAM.md +0 -0
  34. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/WEB.md +0 -0
  35. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/app_runtime/__init__.py +0 -0
  36. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/app_runtime/telegram_app.py +0 -0
  37. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/app_runtime/web_server.py +0 -0
  38. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/__init__.py +0 -0
  39. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/features_ui.py +0 -0
  40. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/keyboard.py +0 -0
  41. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/projects_ui.py +0 -0
  42. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/skills_ui.py +0 -0
  43. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/bot/thread_ui.py +0 -0
  44. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/__init__.py +0 -0
  45. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/approval_flow.py +0 -0
  46. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/approval_guardian.py +0 -0
  47. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/client.py +0 -0
  48. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/client_pool.py +0 -0
  49. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/collaboration_mode.py +0 -0
  50. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/__init__.py +0 -0
  51. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/common.py +0 -0
  52. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/context.py +0 -0
  53. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/contracts.py +0 -0
  54. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/core.py +0 -0
  55. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/review.py +0 -0
  56. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/command_router/system.py +0 -0
  57. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/commands.py +0 -0
  58. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/events.py +0 -0
  59. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex/protocol.py +0 -0
  60. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/dependency_links.txt +0 -0
  61. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/entry_points.txt +0 -0
  62. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/requires.txt +0 -0
  63. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/codex_telegram.egg-info/top_level.txt +0 -0
  64. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/conf.toml.example +0 -0
  65. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/main.py +0 -0
  66. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/models/__init__.py +0 -0
  67. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/models/thread.py +0 -0
  68. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/pyproject.toml +0 -0
  69. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/setup.cfg +0 -0
  70. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_approval_guardian.py +0 -0
  71. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_approval_policy.py +0 -0
  72. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_bootstrap_update_check.py +0 -0
  73. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_cli_entrypoint.py +0 -0
  74. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_codex_client.py +0 -0
  75. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_codex_client_pool.py +0 -0
  76. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_command_router.py +0 -0
  77. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_guardian_config.py +0 -0
  78. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_keyboard.py +0 -0
  79. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_normalize.py +0 -0
  80. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_pypi_check.py +0 -0
  81. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_single_instance.py +0 -0
  82. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_telegram_bot_config.py +0 -0
  83. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/tests/test_thread_history.py +0 -0
  84. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/__init__.py +0 -0
  85. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/approval_policy.py +0 -0
  86. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/config.py +0 -0
  87. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/local_command.py +0 -0
  88. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/logger.py +0 -0
  89. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/normalize.py +0 -0
  90. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/pypi_check.py +0 -0
  91. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/single_instance.py +0 -0
  92. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/utils/web_config.py +0 -0
  93. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/__init__.py +0 -0
  94. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/dependencies.py +0 -0
  95. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/server.py +0 -0
  96. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/static/dist/assets/codex-telegram-icon-black.svg +0 -0
  97. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/static/dist/assets/codex-telegram-icon-ivory.svg +0 -0
  98. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/static/dist/assets/codex-telegram-login-image.png +0 -0
  99. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/thread_history.py +0 -0
  100. {codex_telegram-0.5.0 → codex_telegram-0.5.2}/web/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -292,6 +292,34 @@ python -m venv .venv && source .venv/bin/activate
292
292
  pip install codex-telegram
293
293
  ```
294
294
 
295
+ ### Update
296
+
297
+ The CLI checks for updates automatically on startup. When a newer version is available on PyPI, you'll see:
298
+
299
+ ```text
300
+ ✨ Update available! 0.5.1 -> 0.5.2
301
+
302
+ Release notes: https://github.com/heungtae/codex-telegram/releases/latest
303
+
304
+ 1. Update now and exit (runs `pip install -U codex-telegram`)
305
+ 2. Skip this run
306
+ 3. Skip this version
307
+
308
+ Choose [1/2/3] (default: 2):
309
+ ```
310
+
311
+ You can also update manually:
312
+
313
+ ```bash
314
+ pip install -U codex-telegram
315
+ ```
316
+
317
+ Or with system packages flag:
318
+
319
+ ```bash
320
+ pip install -U codex-telegram --break-system-packages
321
+ ```
322
+
295
323
  ### 2. Configure
296
324
 
297
325
  Create `~/.config/codex-telegram/conf.toml`:
@@ -67,6 +67,34 @@ python -m venv .venv && source .venv/bin/activate
67
67
  pip install codex-telegram
68
68
  ```
69
69
 
70
+ ### Update
71
+
72
+ The CLI checks for updates automatically on startup. When a newer version is available on PyPI, you'll see:
73
+
74
+ ```text
75
+ ✨ Update available! 0.5.1 -> 0.5.2
76
+
77
+ Release notes: https://github.com/heungtae/codex-telegram/releases/latest
78
+
79
+ 1. Update now and exit (runs `pip install -U codex-telegram`)
80
+ 2. Skip this run
81
+ 3. Skip this version
82
+
83
+ Choose [1/2/3] (default: 2):
84
+ ```
85
+
86
+ You can also update manually:
87
+
88
+ ```bash
89
+ pip install -U codex-telegram
90
+ ```
91
+
92
+ Or with system packages flag:
93
+
94
+ ```bash
95
+ pip install -U codex-telegram --break-system-packages
96
+ ```
97
+
70
98
  ### 2. Configure
71
99
 
72
100
  Create `~/.config/codex-telegram/conf.toml`:
@@ -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.2"
@@ -1,7 +1,10 @@
1
1
  import argparse
2
+ import json
2
3
  import logging
3
4
  import os
4
5
  import sys
6
+ import urllib.error
7
+ import urllib.request
5
8
  from importlib.util import module_from_spec, spec_from_file_location
6
9
  from pathlib import Path
7
10
  from types import ModuleType
@@ -13,6 +16,44 @@ def _get_version() -> str:
13
16
  return __version__
14
17
 
15
18
 
19
+ def _check_for_update(current_version: str) -> str | None:
20
+ """Check PyPI for the latest version. Returns latest version if newer, else None."""
21
+ url = "https://pypi.org/pypi/codex-telegram/json"
22
+ try:
23
+ req = urllib.request.Request(url, headers={"User-Agent": "codex-telegram"})
24
+ with urllib.request.urlopen(req, timeout=3) as resp:
25
+ data = json.load(resp)
26
+ latest = data["info"]["version"]
27
+ if _version_gt(latest, current_version):
28
+ return latest
29
+ except (urllib.error.URLError, json.JSONDecodeError, KeyError, TimeoutError):
30
+ pass
31
+ return None
32
+
33
+
34
+ def _version_gt(v1: str, v2: str) -> bool:
35
+ """Compare version strings (simple tuple comparison)."""
36
+ def parse(v: str):
37
+ return tuple(int(x) for x in v.split(".") if x.isdigit())
38
+ return parse(v1) > parse(v2)
39
+
40
+
41
+ def _prompt_update(latest: str) -> bool:
42
+ """Prompt user to update. Returns True if user chooses to update."""
43
+ print(f"\n✨ Update available! {_get_version()} -> {latest}")
44
+ print(" Release notes: https://github.com/heungtae/codex-telegram/releases/latest")
45
+ print()
46
+ print(" 1. Update now and exit (runs `pip install -U codex-telegram`)")
47
+ print(" 2. Skip this run")
48
+ print(" 3. Skip this version")
49
+ print()
50
+ try:
51
+ choice = input(" Choose [1/2/3] (default: 2): ").strip() or "2"
52
+ except (EOFError, KeyboardInterrupt):
53
+ return False
54
+ return choice == "1"
55
+
56
+
16
57
  def _load_root_main_module() -> ModuleType:
17
58
  root_main = Path(__file__).resolve().parent.parent / "main.py"
18
59
  spec = spec_from_file_location("codex_telegram_root_main", root_main)
@@ -24,6 +65,15 @@ def _load_root_main_module() -> ModuleType:
24
65
 
25
66
 
26
67
  def main() -> int:
68
+ # Check for updates (skip if --version or --validate-config or --debug)
69
+ if "--version" not in sys.argv and "--validate-config" not in sys.argv and "--debug" not in sys.argv:
70
+ current = _get_version()
71
+ latest = _check_for_update(current)
72
+ if latest and _prompt_update(latest):
73
+ print("Running: pip install -U codex-telegram")
74
+ os.execvpe(sys.executable, [sys.executable, "-m", "pip", "install", "-U", "codex-telegram"], os.environ)
75
+ return 0
76
+
27
77
  parser = argparse.ArgumentParser(
28
78
  prog="codex-telegram",
29
79
  description="Codex Telegram Bot - Bridges Codex App Server with Telegram and Web UI.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -292,6 +292,34 @@ python -m venv .venv && source .venv/bin/activate
292
292
  pip install codex-telegram
293
293
  ```
294
294
 
295
+ ### Update
296
+
297
+ The CLI checks for updates automatically on startup. When a newer version is available on PyPI, you'll see:
298
+
299
+ ```text
300
+ ✨ Update available! 0.5.1 -> 0.5.2
301
+
302
+ Release notes: https://github.com/heungtae/codex-telegram/releases/latest
303
+
304
+ 1. Update now and exit (runs `pip install -U codex-telegram`)
305
+ 2. Skip this run
306
+ 3. Skip this version
307
+
308
+ Choose [1/2/3] (default: 2):
309
+ ```
310
+
311
+ You can also update manually:
312
+
313
+ ```bash
314
+ pip install -U codex-telegram
315
+ ```
316
+
317
+ Or with system packages flag:
318
+
319
+ ```bash
320
+ pip install -U codex-telegram --break-system-packages
321
+ ```
322
+
295
323
  ### 2. Configure
296
324
 
297
325
  Create `~/.config/codex-telegram/conf.toml`:
@@ -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