fix(telegram): recreate stale topics (#127)

This commit is contained in:
banteg
2026-01-13 22:55:26 +04:00
committed by GitHub
parent 43fd594061
commit b2834da232
4 changed files with 127 additions and 7 deletions
+11 -1
View File
@@ -191,19 +191,29 @@ async def _handle_topic_command(
text = f"error:\n{error}\n{usage}" if error else usage text = f"error:\n{error}\n{usage}" if error else usage
await reply(text=text) await reply(text=text)
return return
title = _topic_title(runtime=cfg.runtime, context=context)
existing = await store.find_thread_for_context(msg.chat_id, context) existing = await store.find_thread_for_context(msg.chat_id, context)
stale_thread_id: int | None = None
if existing is not 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( await reply(
text=f"topic already exists for {_format_context(cfg.runtime, context)} " text=f"topic already exists for {_format_context(cfg.runtime, context)} "
"in this chat.", "in this chat.",
) )
return return
title = _topic_title(runtime=cfg.runtime, context=context) stale_thread_id = existing
created = await cfg.bot.create_forum_topic(msg.chat_id, title) created = await cfg.bot.create_forum_topic(msg.chat_id, title)
if created is None: if created is None:
await reply(text="failed to create topic.") await reply(text="failed to create topic.")
return return
thread_id = created.message_thread_id 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( await store.set_context(
msg.chat_id, msg.chat_id,
thread_id, thread_id,
+9
View File
@@ -192,6 +192,15 @@ class TopicStateStore(JsonStateStore[_TopicState]):
thread.sessions = {} thread.sessions = {}
self._save_locked() 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( async def find_thread_for_context(
self, chat_id: int, context: RunContext self, chat_id: int, context: RunContext
) -> int | None: ) -> int | None:
+87
View File
@@ -8,6 +8,7 @@ import pytest
from takopi import commands, plugins from takopi import commands, plugins
from takopi.telegram.commands.executor import _CaptureTransport, _run_engine 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.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.loop as telegram_loop
import takopi.telegram.topics as telegram_topics import takopi.telegram.topics as telegram_topics
from takopi.directives import parse_directives 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 == [] 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 @pytest.mark.anyio
async def test_send_with_resume_waits_for_token() -> None: async def test_send_with_resume_waits_for_token() -> None:
transport = _FakeTransport() transport = _FakeTransport()
+14
View File
@@ -54,3 +54,17 @@ async def test_topic_state_store_clear_and_find(tmp_path) -> None:
snapshot = await store.get_thread(2, 20) snapshot = await store.get_thread(2, 20)
assert snapshot is not None assert snapshot is not None
assert snapshot.default_engine is 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