feat(telegram): optional resume (#104)
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user