diff --git a/docs/transports/telegram.md b/docs/transports/telegram.md index cc9b02c..173525d 100644 --- a/docs/transports/telegram.md +++ b/docs/transports/telegram.md @@ -84,6 +84,7 @@ Configuration (under `[transports.telegram]`): [transports.telegram.topics] enabled = true scope = "auto" # auto | main | projects | all +show_resume_line = true ``` Requirements: @@ -95,6 +96,10 @@ Requirements: - `auto`: if any project chats are configured, uses `projects`; otherwise `main`. - The bot needs the **Manage Topics** permission in the relevant chat(s). +Optional: + +- `show_resume_line`: set `false` to hide the resume command line in topic threads. + Commands: - `main`: `/topic @branch` creates a topic in the main chat and binds it. diff --git a/docs/user-guide.md b/docs/user-guide.md index 0678126..79318dd 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -262,6 +262,7 @@ Topics bind Telegram forum threads to specific project/branch contexts. They als ```toml [transports.telegram.topics] enabled = true +# show_resume_line = false ``` Your bot needs **Manage Topics** permission in the group. @@ -269,6 +270,8 @@ Your bot needs **Manage Topics** permission in the group. If any `projects..chat_id` are configured, topics are managed in those project chats; otherwise topics are managed in the main chat. +Set `show_resume_line = false` to hide the resume command line in topic threads. + ### Topic behavior ``` @@ -321,6 +324,7 @@ chat_id = -1001234567890 [transports.telegram.topics] enabled = true +# show_resume_line = false ``` **Project chats:** @@ -331,6 +335,7 @@ chat_id = 123456789 # main chat (private, for non-project messages) [transports.telegram.topics] enabled = true +# show_resume_line = false [projects.takopi] path = "~/dev/takopi" @@ -455,6 +460,7 @@ deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"] [transports.telegram.topics] enabled = true scope = "auto" +show_resume_line = true # Project definitions [projects.takopi] diff --git a/src/takopi/settings.py b/src/takopi/settings.py index d77a6ad..aec577c 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -57,6 +57,7 @@ class TelegramTopicsSettings(BaseModel): enabled: bool = False scope: Literal["auto", "main", "projects", "all"] = "auto" + show_resume_line: bool = True class TelegramFilesSettings(BaseModel): diff --git a/src/takopi/telegram/commands.py b/src/takopi/telegram/commands.py index 634f900..3deeaf2 100644 --- a/src/takopi/telegram/commands.py +++ b/src/takopi/telegram/commands.py @@ -1,9 +1,10 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from dataclasses import dataclass from functools import partial from pathlib import Path +from typing import cast import anyio @@ -21,7 +22,7 @@ from ..directives import DirectiveError from ..ids import RESERVED_COMMAND_IDS, is_valid_id from ..logging import bind_run_context, clear_context, get_logger from ..markdown import MarkdownParts -from ..model import EngineId, ResumeToken +from ..model import EngineId, ResumeToken, TakopiEvent from ..plugins import COMMAND_GROUP, list_entrypoints from ..progress import ProgressTracker from ..router import RunnerUnavailableError @@ -67,6 +68,7 @@ from .topics import ( _maybe_update_topic_context, _topic_key, _topic_title, + _topics_chat_allowed, _topics_chat_project, _topics_command_error, ) @@ -255,6 +257,29 @@ class _SavedFilePutGroup: failed: list[_FilePutResult] +@dataclass(slots=True) +class _ResumeLineProxy: + runner: Runner + + @property + def engine(self) -> str: + return self.runner.engine + + def is_resume_line(self, line: str) -> bool: + return self.runner.is_resume_line(line) + + def format_resume(self, _: ResumeToken) -> str: + return "" + + def extract_resume(self, text: str | None) -> ResumeToken | None: + return self.runner.extract_resume(text) + + def run( + self, prompt: str, resume: ResumeToken | None + ) -> AsyncIterator[TakopiEvent]: + return self.runner.run(prompt, resume) + + def resolve_file_put_paths( plan: _FilePutPlan, *, @@ -1239,6 +1264,7 @@ async def _run_engine( | None = None, engine_override: EngineId | None = None, thread_id: int | None = None, + show_resume_line: bool = True, ) -> None: reply = partial( send_plain, @@ -1256,6 +1282,9 @@ async def _run_engine( except RunnerUnavailableError as exc: await reply(text=f"error:\n{exc}") return + runner: Runner = entry.runner + if thread_id is not None and not show_resume_line: + runner = cast(Runner, _ResumeLineProxy(runner)) if not entry.available: reason = entry.issue or "engine unavailable" await _send_runner_unavailable( @@ -1263,7 +1292,7 @@ async def _run_engine( chat_id=chat_id, user_msg_id=user_msg_id, resume_token=resume_token, - runner=entry.runner, + runner=runner, reason=reason, thread_id=thread_id, ) @@ -1278,7 +1307,7 @@ async def _run_engine( run_fields = { "chat_id": chat_id, "user_msg_id": user_msg_id, - "engine": entry.runner.engine, + "engine": runner.engine, "resume": resume_token.value if resume_token else None, } if context is not None: @@ -1297,7 +1326,7 @@ async def _run_engine( ) await handle_message( exec_cfg, - runner=entry.runner, + runner=runner, incoming=incoming, resume_token=resume_token, context=context, @@ -1366,6 +1395,7 @@ class _TelegramCommandExecutor(CommandExecutor): chat_id: int, user_msg_id: int, thread_id: int | None, + show_resume_line: bool, ) -> None: self._exec_cfg = exec_cfg self._runtime = runtime @@ -1374,6 +1404,7 @@ class _TelegramCommandExecutor(CommandExecutor): self._chat_id = chat_id self._user_msg_id = user_msg_id self._thread_id = thread_id + self._show_resume_line = show_resume_line self._reply_ref = MessageRef( channel_id=chat_id, message_id=user_msg_id, @@ -1443,6 +1474,7 @@ class _TelegramCommandExecutor(CommandExecutor): on_thread_known=None, engine_override=engine, thread_id=self._thread_id, + show_resume_line=self._show_resume_line, ) return RunResult(engine=engine, message=capture.last_message) await _run_engine( @@ -1458,6 +1490,7 @@ class _TelegramCommandExecutor(CommandExecutor): on_thread_known=self._scheduler.note_thread_known, engine_override=engine, thread_id=self._thread_id, + show_resume_line=self._show_resume_line, ) return RunResult(engine=engine, message=None) @@ -1504,6 +1537,8 @@ async def _dispatch_command( if msg.reply_to_message_id is not None else None ) + topic_thread = msg.thread_id is not None and _topics_chat_allowed(cfg, msg.chat_id) + show_resume_line = cfg.topics.show_resume_line if topic_thread else True executor = _TelegramCommandExecutor( exec_cfg=cfg.exec_cfg, runtime=cfg.runtime, @@ -1512,6 +1547,7 @@ async def _dispatch_command( chat_id=chat_id, user_msg_id=user_msg_id, thread_id=msg.thread_id, + show_resume_line=show_resume_line, ) message_ref = MessageRef( channel_id=chat_id, message_id=user_msg_id, thread_id=msg.thread_id diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index df700c9..c65089d 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -444,6 +444,9 @@ async def run_main_loop( ) else None ) + show_resume_line = ( + cfg.topics.show_resume_line if topic_key is not None else True + ) await _run_engine( exec_cfg=cfg.exec_cfg, runtime=cfg.runtime, @@ -459,6 +462,7 @@ async def run_main_loop( ), engine_override=engine_override, thread_id=thread_id, + show_resume_line=show_resume_line, ) async def run_thread_job(job: ThreadJob) -> None: diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 31c15a8..84971d2 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -1261,6 +1261,44 @@ async def test_send_with_resume_reports_when_missing() -> None: assert "resume token" in transport.send_calls[-1]["message"].text.lower() +@pytest.mark.anyio +async def test_run_engine_hides_resume_line_in_topics() -> None: + transport = telegram_commands._CaptureTransport() + runner = ScriptRunner( + [Return(answer="ok")], + engine=CODEX_ENGINE, + resume_value="resume-123", + ) + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + runtime = TransportRuntime( + router=_make_router(runner), + projects=_empty_projects(), + ) + + await telegram_commands._run_engine( + exec_cfg=exec_cfg, + runtime=runtime, + running_tasks={}, + chat_id=123, + user_msg_id=1, + text="hello", + resume_token=None, + context=None, + reply_ref=None, + on_thread_known=None, + engine_override=None, + thread_id=77, + show_resume_line=False, + ) + + assert transport.last_message is not None + assert "resume-123" not in transport.last_message.text + + @pytest.mark.anyio async def test_run_main_loop_routes_reply_to_running_resume() -> None: progress_ready = anyio.Event()