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]`):
```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 <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
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.<alias>.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]
+1 -1
View File
@@ -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)
+1
View File
@@ -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,
+1
View File
@@ -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"
+28 -7
View File
@@ -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
+8 -2
View File
@@ -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
+74 -1
View File
@@ -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,