feat(telegram): optional resume (#104)

This commit is contained in:
banteg
2026-01-12 19:59:25 +04:00
committed by GitHub
parent 98ba41f8c7
commit 2f9787ac27
8 changed files with 123 additions and 22 deletions
+5 -5
View File
@@ -48,6 +48,7 @@ line. If you want auto-resume without replies, enable chat sessions.
Configuration (under `[transports.telegram]`): Configuration (under `[transports.telegram]`):
```toml ```toml
show_resume_line = true # set false to hide resume lines
session_mode = "chat" # or "stateless" session_mode = "chat" # or "stateless"
``` ```
@@ -59,6 +60,10 @@ Behavior:
State is stored in `telegram_chat_sessions_state.json` alongside the config file. 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 ## Message overflow
By default, takopi trims long final responses to ~3500 characters to stay under By default, takopi trims long final responses to ~3500 characters to stay under
@@ -84,7 +89,6 @@ Configuration (under `[transports.telegram]`):
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
scope = "auto" # auto | main | projects | all scope = "auto" # auto | main | projects | all
show_resume_line = true
``` ```
Requirements: Requirements:
@@ -96,10 +100,6 @@ Requirements:
- `auto`: if any project chats are configured, uses `projects`; otherwise `main`. - `auto`: if any project chats are configured, uses `projects`; otherwise `main`.
- The bot needs the **Manage Topics** permission in the relevant chat(s). - 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: Commands:
- `main`: `/topic <project> @branch` creates a topic in the main chat and binds it. - `main`: `/topic <project> @branch` creates a topic in the main chat and binds it.
+5 -6
View File
@@ -39,6 +39,8 @@ To continue a session:
- **Chat sessions** (optional) store one resume token per chat (per sender in groups) so new messages - **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`. auto-resume without replying. Enable with `session_mode = "chat"` and reset with `/new`.
State is stored in `telegram_chat_sessions_state.json`. 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. 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 ```toml
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
# show_resume_line = false
``` ```
Your bot needs **Manage Topics** permission in the group. 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.<alias>.chat_id` are configured, topics are managed in those If any `projects.<alias>.chat_id` are configured, topics are managed in those
project chats; otherwise topics are managed in the main chat. 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 ### Topic behavior
``` ```
@@ -321,10 +320,10 @@ In project chats, omit the project: `/topic @branch` or `/ctx set @branch`.
```toml ```toml
[transports.telegram] [transports.telegram]
chat_id = -1001234567890 chat_id = -1001234567890
# show_resume_line = false
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
# show_resume_line = false
``` ```
**Project chats:** **Project chats:**
@@ -332,10 +331,10 @@ enabled = true
```toml ```toml
[transports.telegram] [transports.telegram]
chat_id = 123456789 # main chat (private, for non-project messages) chat_id = 123456789 # main chat (private, for non-project messages)
# show_resume_line = false
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
# show_resume_line = false
[projects.takopi] [projects.takopi]
path = "~/dev/takopi" path = "~/dev/takopi"
@@ -448,6 +447,7 @@ chat_id = 123456789
voice_transcription = true voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" voice_transcription_model = "gpt-4o-mini-transcribe"
session_mode = "stateless" # or "chat" for auto-resume per chat session_mode = "stateless" # or "chat" for auto-resume per chat
show_resume_line = true
[transports.telegram.files] [transports.telegram.files]
enabled = true enabled = true
@@ -460,7 +460,6 @@ deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
scope = "auto" scope = "auto"
show_resume_line = true
# Project definitions # Project definitions
[projects.takopi] [projects.takopi]
+1 -1
View File
@@ -57,7 +57,6 @@ class TelegramTopicsSettings(BaseModel):
enabled: bool = False enabled: bool = False
scope: Literal["auto", "main", "projects", "all"] = "auto" scope: Literal["auto", "main", "projects", "all"] = "auto"
show_resume_line: bool = True
class TelegramFilesSettings(BaseModel): class TelegramFilesSettings(BaseModel):
@@ -104,6 +103,7 @@ class TelegramTransportSettings(BaseModel):
voice_max_bytes: StrictInt = 10 * 1024 * 1024 voice_max_bytes: StrictInt = 10 * 1024 * 1024
voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe" voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe"
session_mode: Literal["stateless", "chat"] = "stateless" session_mode: Literal["stateless", "chat"] = "stateless"
show_resume_line: bool = True
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings) topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings) files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
+1
View File
@@ -114,6 +114,7 @@ class TelegramBackend(TransportBackend):
startup_msg=startup_msg, startup_msg=startup_msg,
exec_cfg=exec_cfg, exec_cfg=exec_cfg,
session_mode=settings.session_mode, session_mode=settings.session_mode,
show_resume_line=settings.show_resume_line,
voice_transcription=settings.voice_transcription, voice_transcription=settings.voice_transcription,
voice_max_bytes=int(settings.voice_max_bytes), voice_max_bytes=int(settings.voice_max_bytes),
voice_transcription_model=settings.voice_transcription_model, voice_transcription_model=settings.voice_transcription_model,
+1
View File
@@ -119,6 +119,7 @@ class TelegramBridgeConfig:
startup_msg: str startup_msg: str
exec_cfg: ExecBridgeConfig exec_cfg: ExecBridgeConfig
session_mode: Literal["stateless", "chat"] = "stateless" session_mode: Literal["stateless", "chat"] = "stateless"
show_resume_line: bool = True
voice_transcription: bool = False voice_transcription: bool = False
voice_max_bytes: int = 10 * 1024 * 1024 voice_max_bytes: int = 10 * 1024 * 1024
voice_transcription_model: str = "gpt-4o-mini-transcribe" voice_transcription_model: str = "gpt-4o-mini-transcribe"
+28 -7
View File
@@ -68,7 +68,6 @@ from .topics import (
_maybe_update_topic_context, _maybe_update_topic_context,
_topic_key, _topic_key,
_topic_title, _topic_title,
_topics_chat_allowed,
_topics_chat_project, _topics_chat_project,
_topics_command_error, _topics_command_error,
) )
@@ -280,6 +279,21 @@ class _ResumeLineProxy:
return self.runner.run(prompt, resume) 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( def resolve_file_put_paths(
plan: _FilePutPlan, plan: _FilePutPlan,
*, *,
@@ -1283,7 +1297,7 @@ async def _run_engine(
await reply(text=f"error:\n{exc}") await reply(text=f"error:\n{exc}")
return return
runner: Runner = entry.runner 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)) runner = cast(Runner, _ResumeLineProxy(runner))
if not entry.available: if not entry.available:
reason = entry.issue or "engine unavailable" reason = entry.issue or "engine unavailable"
@@ -1396,6 +1410,7 @@ class _TelegramCommandExecutor(CommandExecutor):
user_msg_id: int, user_msg_id: int,
thread_id: int | None, thread_id: int | None,
show_resume_line: bool, show_resume_line: bool,
stateful_mode: bool,
) -> None: ) -> None:
self._exec_cfg = exec_cfg self._exec_cfg = exec_cfg
self._runtime = runtime self._runtime = runtime
@@ -1405,6 +1420,7 @@ class _TelegramCommandExecutor(CommandExecutor):
self._user_msg_id = user_msg_id self._user_msg_id = user_msg_id
self._thread_id = thread_id self._thread_id = thread_id
self._show_resume_line = show_resume_line self._show_resume_line = show_resume_line
self._stateful_mode = stateful_mode
self._reply_ref = MessageRef( self._reply_ref = MessageRef(
channel_id=chat_id, channel_id=chat_id,
message_id=user_msg_id, message_id=user_msg_id,
@@ -1450,6 +1466,11 @@ class _TelegramCommandExecutor(CommandExecutor):
self, request: RunRequest, *, mode: RunMode = "emit" self, request: RunRequest, *, mode: RunMode = "emit"
) -> RunResult: ) -> RunResult:
request = self._apply_default_context(request) 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 = self._runtime.resolve_engine(
engine_override=request.engine, engine_override=request.engine,
context=request.context, context=request.context,
@@ -1474,7 +1495,7 @@ class _TelegramCommandExecutor(CommandExecutor):
on_thread_known=None, on_thread_known=None,
engine_override=engine, engine_override=engine,
thread_id=self._thread_id, 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) return RunResult(engine=engine, message=capture.last_message)
await _run_engine( await _run_engine(
@@ -1490,7 +1511,7 @@ class _TelegramCommandExecutor(CommandExecutor):
on_thread_known=self._scheduler.note_thread_known, on_thread_known=self._scheduler.note_thread_known,
engine_override=engine, engine_override=engine,
thread_id=self._thread_id, 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) return RunResult(engine=engine, message=None)
@@ -1524,6 +1545,7 @@ async def _dispatch_command(
args_text: str, args_text: str,
running_tasks: RunningTasks, running_tasks: RunningTasks,
scheduler: ThreadScheduler, scheduler: ThreadScheduler,
stateful_mode: bool,
) -> None: ) -> None:
allowlist = cfg.runtime.allowlist allowlist = cfg.runtime.allowlist
chat_id = msg.chat_id chat_id = msg.chat_id
@@ -1537,8 +1559,6 @@ async def _dispatch_command(
if msg.reply_to_message_id is not None if msg.reply_to_message_id is not None
else 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( executor = _TelegramCommandExecutor(
exec_cfg=cfg.exec_cfg, exec_cfg=cfg.exec_cfg,
runtime=cfg.runtime, runtime=cfg.runtime,
@@ -1547,7 +1567,8 @@ async def _dispatch_command(
chat_id=chat_id, chat_id=chat_id,
user_msg_id=user_msg_id, user_msg_id=user_msg_id,
thread_id=msg.thread_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( message_ref = MessageRef(
channel_id=chat_id, message_id=user_msg_id, thread_id=msg.thread_id channel_id=chat_id, message_id=user_msg_id, thread_id=msg.thread_id
+8 -2
View File
@@ -32,6 +32,7 @@ from .commands import (
_parse_slash_command, _parse_slash_command,
_reserved_commands, _reserved_commands,
_save_file_put, _save_file_put,
_should_show_resume_line,
_run_engine, _run_engine,
_set_command_menu, _set_command_menu,
handle_callback_cancel, handle_callback_cancel,
@@ -444,8 +445,11 @@ async def run_main_loop(
) )
else None else None
) )
show_resume_line = ( stateful_mode = topic_key is not None or chat_session_key is not None
cfg.topics.show_resume_line if topic_key is not None else True show_resume_line = _should_show_resume_line(
show_resume_line=cfg.show_resume_line,
stateful_mode=stateful_mode,
context=context,
) )
await _run_engine( await _run_engine(
exec_cfg=cfg.exec_cfg, exec_cfg=cfg.exec_cfg,
@@ -590,6 +594,7 @@ async def run_main_loop(
else None else None
) )
chat_session_key = _chat_session_key(msg, store=chat_session_store) 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 = ( chat_project = (
_topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None _topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None
) )
@@ -706,6 +711,7 @@ async def run_main_loop(
args_text, args_text,
running_tasks, running_tasks,
scheduler, scheduler,
stateful_mode,
) )
continue continue
+74 -1
View File
@@ -1462,9 +1462,19 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None:
presenter=MarkdownPresenter(), presenter=MarkdownPresenter(),
final_notify=True, final_notify=True,
) )
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
)
},
default_project="proj",
)
runtime = TransportRuntime( runtime = TransportRuntime(
router=_make_router(runner), router=_make_router(runner),
projects=_empty_projects(), projects=projects,
config_path=state_path, config_path=state_path,
) )
cfg = TelegramBridgeConfig( 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) 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 @pytest.mark.anyio
async def test_run_main_loop_chat_sessions_isolate_group_senders( async def test_run_main_loop_chat_sessions_isolate_group_senders(
tmp_path: Path, tmp_path: Path,