diff --git a/src/takopi/telegram/commands/topics.py b/src/takopi/telegram/commands/topics.py index 3d2f399..7249a47 100644 --- a/src/takopi/telegram/commands/topics.py +++ b/src/takopi/telegram/commands/topics.py @@ -191,19 +191,29 @@ async def _handle_topic_command( text = f"error:\n{error}\n{usage}" if error else usage await reply(text=text) return - existing = await store.find_thread_for_context(msg.chat_id, context) - if existing is not None: - await reply( - text=f"topic already exists for {_format_context(cfg.runtime, context)} " - "in this chat.", - ) - return title = _topic_title(runtime=cfg.runtime, context=context) + existing = await store.find_thread_for_context(msg.chat_id, context) + stale_thread_id: int | None = None + if existing is not None: + updated = await cfg.bot.edit_forum_topic( + chat_id=msg.chat_id, + message_thread_id=existing, + name=title, + ) + if updated: + await reply( + text=f"topic already exists for {_format_context(cfg.runtime, context)} " + "in this chat.", + ) + return + stale_thread_id = existing created = await cfg.bot.create_forum_topic(msg.chat_id, title) if created is None: await reply(text="failed to create topic.") return thread_id = created.message_thread_id + if stale_thread_id is not None: + await store.delete_thread(msg.chat_id, stale_thread_id) await store.set_context( msg.chat_id, thread_id, diff --git a/src/takopi/telegram/topic_state.py b/src/takopi/telegram/topic_state.py index 4e7da76..f8e5744 100644 --- a/src/takopi/telegram/topic_state.py +++ b/src/takopi/telegram/topic_state.py @@ -192,6 +192,15 @@ class TopicStateStore(JsonStateStore[_TopicState]): thread.sessions = {} self._save_locked() + async def delete_thread(self, chat_id: int, thread_id: int) -> None: + async with self._lock: + self._reload_locked_if_needed() + key = _thread_key(chat_id, thread_id) + if key not in self._state.threads: + return + self._state.threads.pop(key, None) + self._save_locked() + async def find_thread_for_context( self, chat_id: int, context: RunContext ) -> int | None: diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 0968141..6b720d1 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -8,6 +8,7 @@ import pytest from takopi import commands, plugins from takopi.telegram.commands.executor import _CaptureTransport, _run_engine from takopi.telegram.commands.file_transfer import _handle_file_get, _handle_file_put +from takopi.telegram.commands.topics import _handle_topic_command import takopi.telegram.loop as telegram_loop import takopi.telegram.topics as telegram_topics from takopi.directives import parse_directives @@ -1149,6 +1150,92 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No assert bot.edit_topic_calls == [] +@pytest.mark.anyio +async def test_topic_command_recreates_stale_topic(tmp_path: Path) -> None: + class _StaleTopicBot(_FakeBot): + def __init__(self) -> None: + super().__init__() + self.create_topic_calls: list[dict[str, Any]] = [] + + async def create_forum_topic( + self, chat_id: int, name: str + ) -> ForumTopic | None: + self.create_topic_calls.append({"chat_id": chat_id, "name": name}) + return ForumTopic(message_thread_id=55) + + async def edit_forum_topic( + self, chat_id: int, message_thread_id: int, name: str + ) -> bool: + self.edit_topic_calls.append( + { + "chat_id": chat_id, + "message_thread_id": message_thread_id, + "name": name, + } + ) + return False + + transport = _FakeTransport() + bot = _StaleTopicBot() + runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) + projects = ProjectsConfig( + projects={ + "takopi": ProjectConfig( + alias="takopi", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project=None, + ) + runtime = TransportRuntime(router=_make_router(runner), projects=projects) + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + cfg = TelegramBridgeConfig( + bot=bot, + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + topics=TelegramTopicsSettings(enabled=True, scope="main"), + ) + store = TopicStateStore(tmp_path / "telegram_topics_state.json") + await store.set_context( + 123, + 77, + RunContext(project="takopi", branch="master"), + topic_title="takopi @master", + ) + msg = TelegramIncomingMessage( + transport="telegram", + chat_id=123, + message_id=10, + text="/topic takopi @master", + reply_to_message_id=None, + reply_to_text=None, + sender_id=123, + ) + + await _handle_topic_command( + cfg, + msg, + "takopi @master", + store, + resolved_scope="main", + scope_chat_ids=frozenset({123}), + ) + + assert bot.edit_topic_calls + assert bot.create_topic_calls + assert await store.get_thread(123, 77) is None + snapshot = await store.get_thread(123, 55) + assert snapshot is not None + assert snapshot.context == RunContext(project="takopi", branch="master") + + @pytest.mark.anyio async def test_send_with_resume_waits_for_token() -> None: transport = _FakeTransport() diff --git a/tests/test_telegram_topic_state.py b/tests/test_telegram_topic_state.py index b16cb8b..204b923 100644 --- a/tests/test_telegram_topic_state.py +++ b/tests/test_telegram_topic_state.py @@ -54,3 +54,17 @@ async def test_topic_state_store_clear_and_find(tmp_path) -> None: snapshot = await store.get_thread(2, 20) assert snapshot is not None assert snapshot.default_engine is None + + +@pytest.mark.anyio +async def test_topic_state_store_delete_thread(tmp_path) -> None: + path = tmp_path / "telegram_topics_state.json" + store = TopicStateStore(path) + context = RunContext(project="proj", branch="main") + await store.set_context(1, 10, context) + await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123")) + + await store.delete_thread(1, 10) + + assert await store.get_thread(1, 10) is None + assert await store.find_thread_for_context(1, context) is None