From 2f9787ac271c4c8471517c40f865c52daba69c89 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:59:25 +0400 Subject: [PATCH] feat(telegram): optional resume (#104) --- docs/transports/telegram.md | 10 ++--- docs/user-guide.md | 11 +++-- src/takopi/settings.py | 2 +- src/takopi/telegram/backend.py | 1 + src/takopi/telegram/bridge.py | 1 + src/takopi/telegram/commands.py | 35 ++++++++++++--- src/takopi/telegram/loop.py | 10 ++++- tests/test_telegram_bridge.py | 75 ++++++++++++++++++++++++++++++++- 8 files changed, 123 insertions(+), 22 deletions(-) diff --git a/docs/transports/telegram.md b/docs/transports/telegram.md index 173525d..c2517d9 100644 --- a/docs/transports/telegram.md +++ b/docs/transports/telegram.md @@ -48,6 +48,7 @@ line. If you want auto-resume without replies, enable chat sessions. Configuration (under `[transports.telegram]`): ```toml +show_resume_line = true # set false to hide resume lines session_mode = "chat" # or "stateless" ``` @@ -59,6 +60,10 @@ Behavior: State is stored in `telegram_chat_sessions_state.json` alongside the config file. +Set `show_resume_line = false` to hide resume lines when takopi can auto-resume +(topics or chat sessions) and a project context is resolved. Otherwise the resume +line stays visible so reply-to-continue still works. + ## Message overflow By default, takopi trims long final responses to ~3500 characters to stay under @@ -84,7 +89,6 @@ Configuration (under `[transports.telegram]`): [transports.telegram.topics] enabled = true scope = "auto" # auto | main | projects | all -show_resume_line = true ``` Requirements: @@ -96,10 +100,6 @@ 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 79318dd..d0de121 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -39,6 +39,8 @@ To continue a session: - **Chat sessions** (optional) store one resume token per chat (per sender in groups) so new messages auto-resume without replying. Enable with `session_mode = "chat"` and reset with `/new`. State is stored in `telegram_chat_sessions_state.json`. + You can hide resume lines by setting `[transports.telegram].show_resume_line = false` + when auto-resume is available and a project context is resolved. Reply-to-continue always works, even if chat sessions or topics are enabled. @@ -262,7 +264,6 @@ 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. @@ -270,8 +271,6 @@ 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,10 +320,10 @@ In project chats, omit the project: `/topic @branch` or `/ctx set @branch`. ```toml [transports.telegram] chat_id = -1001234567890 +# show_resume_line = false [transports.telegram.topics] enabled = true -# show_resume_line = false ``` **Project chats:** @@ -332,10 +331,10 @@ enabled = true ```toml [transports.telegram] chat_id = 123456789 # main chat (private, for non-project messages) +# show_resume_line = false [transports.telegram.topics] enabled = true -# show_resume_line = false [projects.takopi] path = "~/dev/takopi" @@ -448,6 +447,7 @@ chat_id = 123456789 voice_transcription = true voice_transcription_model = "gpt-4o-mini-transcribe" session_mode = "stateless" # or "chat" for auto-resume per chat +show_resume_line = true [transports.telegram.files] enabled = true @@ -460,7 +460,6 @@ 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 aec577c..4d8cb4a 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -57,7 +57,6 @@ class TelegramTopicsSettings(BaseModel): enabled: bool = False scope: Literal["auto", "main", "projects", "all"] = "auto" - show_resume_line: bool = True class TelegramFilesSettings(BaseModel): @@ -104,6 +103,7 @@ class TelegramTransportSettings(BaseModel): voice_max_bytes: StrictInt = 10 * 1024 * 1024 voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe" session_mode: Literal["stateless", "chat"] = "stateless" + show_resume_line: bool = True topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings) files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings) diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py index e56fb91..de15b7f 100644 --- a/src/takopi/telegram/backend.py +++ b/src/takopi/telegram/backend.py @@ -114,6 +114,7 @@ class TelegramBackend(TransportBackend): startup_msg=startup_msg, exec_cfg=exec_cfg, session_mode=settings.session_mode, + show_resume_line=settings.show_resume_line, voice_transcription=settings.voice_transcription, voice_max_bytes=int(settings.voice_max_bytes), voice_transcription_model=settings.voice_transcription_model, diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 738357d..d3095c1 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -119,6 +119,7 @@ class TelegramBridgeConfig: startup_msg: str exec_cfg: ExecBridgeConfig session_mode: Literal["stateless", "chat"] = "stateless" + show_resume_line: bool = True voice_transcription: bool = False voice_max_bytes: int = 10 * 1024 * 1024 voice_transcription_model: str = "gpt-4o-mini-transcribe" diff --git a/src/takopi/telegram/commands.py b/src/takopi/telegram/commands.py index 3deeaf2..ad125d0 100644 --- a/src/takopi/telegram/commands.py +++ b/src/takopi/telegram/commands.py @@ -68,7 +68,6 @@ from .topics import ( _maybe_update_topic_context, _topic_key, _topic_title, - _topics_chat_allowed, _topics_chat_project, _topics_command_error, ) @@ -280,6 +279,21 @@ class _ResumeLineProxy: return self.runner.run(prompt, resume) +def _should_show_resume_line( + *, + show_resume_line: bool, + stateful_mode: bool, + context: RunContext | None, +) -> bool: + if show_resume_line: + return True + if not stateful_mode: + return True + if context is None or context.project is None: + return True + return False + + def resolve_file_put_paths( plan: _FilePutPlan, *, @@ -1283,7 +1297,7 @@ async def _run_engine( await reply(text=f"error:\n{exc}") return runner: Runner = entry.runner - if thread_id is not None and not show_resume_line: + if not show_resume_line: runner = cast(Runner, _ResumeLineProxy(runner)) if not entry.available: reason = entry.issue or "engine unavailable" @@ -1396,6 +1410,7 @@ class _TelegramCommandExecutor(CommandExecutor): user_msg_id: int, thread_id: int | None, show_resume_line: bool, + stateful_mode: bool, ) -> None: self._exec_cfg = exec_cfg self._runtime = runtime @@ -1405,6 +1420,7 @@ class _TelegramCommandExecutor(CommandExecutor): self._user_msg_id = user_msg_id self._thread_id = thread_id self._show_resume_line = show_resume_line + self._stateful_mode = stateful_mode self._reply_ref = MessageRef( channel_id=chat_id, message_id=user_msg_id, @@ -1450,6 +1466,11 @@ class _TelegramCommandExecutor(CommandExecutor): self, request: RunRequest, *, mode: RunMode = "emit" ) -> RunResult: request = self._apply_default_context(request) + effective_show_resume_line = _should_show_resume_line( + show_resume_line=self._show_resume_line, + stateful_mode=self._stateful_mode, + context=request.context, + ) engine = self._runtime.resolve_engine( engine_override=request.engine, context=request.context, @@ -1474,7 +1495,7 @@ class _TelegramCommandExecutor(CommandExecutor): on_thread_known=None, engine_override=engine, thread_id=self._thread_id, - show_resume_line=self._show_resume_line, + show_resume_line=effective_show_resume_line, ) return RunResult(engine=engine, message=capture.last_message) await _run_engine( @@ -1490,7 +1511,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, + show_resume_line=effective_show_resume_line, ) return RunResult(engine=engine, message=None) @@ -1524,6 +1545,7 @@ async def _dispatch_command( args_text: str, running_tasks: RunningTasks, scheduler: ThreadScheduler, + stateful_mode: bool, ) -> None: allowlist = cfg.runtime.allowlist chat_id = msg.chat_id @@ -1537,8 +1559,6 @@ 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, @@ -1547,7 +1567,8 @@ 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, + show_resume_line=cfg.show_resume_line, + stateful_mode=stateful_mode, ) 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 c65089d..1c5194b 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -32,6 +32,7 @@ from .commands import ( _parse_slash_command, _reserved_commands, _save_file_put, + _should_show_resume_line, _run_engine, _set_command_menu, handle_callback_cancel, @@ -444,8 +445,11 @@ async def run_main_loop( ) else None ) - show_resume_line = ( - cfg.topics.show_resume_line if topic_key is not None else True + stateful_mode = topic_key is not None or chat_session_key is not None + show_resume_line = _should_show_resume_line( + show_resume_line=cfg.show_resume_line, + stateful_mode=stateful_mode, + context=context, ) await _run_engine( exec_cfg=cfg.exec_cfg, @@ -590,6 +594,7 @@ async def run_main_loop( else None ) chat_session_key = _chat_session_key(msg, store=chat_session_store) + stateful_mode = topic_key is not None or chat_session_key is not None chat_project = ( _topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None ) @@ -706,6 +711,7 @@ async def run_main_loop( args_text, running_tasks, scheduler, + stateful_mode, ) continue diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 84971d2..c3e65e4 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -1462,9 +1462,19 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None: presenter=MarkdownPresenter(), final_notify=True, ) + projects = ProjectsConfig( + projects={ + "proj": ProjectConfig( + alias="proj", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project="proj", + ) runtime = TransportRuntime( router=_make_router(runner), - projects=_empty_projects(), + projects=projects, config_path=state_path, ) cfg = TelegramBridgeConfig( @@ -1526,6 +1536,69 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None: assert runner2.calls[0][1] == ResumeToken(engine=CODEX_ENGINE, value=resume_value) +@pytest.mark.anyio +async def test_run_main_loop_hides_resume_line_when_disabled( + tmp_path: Path, +) -> None: + resume_value = "resume-123" + state_path = tmp_path / "takopi.toml" + + transport = _FakeTransport() + bot = _FakeBot() + runner = ScriptRunner( + [Return(answer="ok")], + engine=CODEX_ENGINE, + resume_value=resume_value, + ) + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + projects = ProjectsConfig( + projects={ + "proj": ProjectConfig( + alias="proj", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project="proj", + ) + runtime = TransportRuntime( + router=_make_router(runner), + projects=projects, + config_path=state_path, + ) + cfg = TelegramBridgeConfig( + bot=bot, + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + session_mode="chat", + show_resume_line=False, + ) + + async def poller(_cfg: TelegramBridgeConfig): + yield TelegramIncomingMessage( + transport="telegram", + chat_id=123, + message_id=1, + text="hello", + reply_to_message_id=None, + reply_to_text=None, + sender_id=123, + chat_type="private", + ) + + await run_main_loop(cfg, poller) + + assert transport.send_calls + final_text = transport.send_calls[-1]["message"].text + assert resume_value not in final_text + + @pytest.mark.anyio async def test_run_main_loop_chat_sessions_isolate_group_senders( tmp_path: Path,