From 419ec5078b6e97811171d011405b69657b8509e8 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:17:50 +0400 Subject: [PATCH] feat(telegram): make /ctx work everywhere (#159) --- docs/how-to/topics.md | 2 + docs/reference/commands-and-directives.md | 12 ++- docs/reference/context-resolution.md | 3 + docs/reference/transports/telegram.md | 4 +- docs/tutorials/conversation-modes.md | 9 +- src/takopi/telegram/chat_prefs.py | 40 +++++++ src/takopi/telegram/commands/executor.py | 4 +- src/takopi/telegram/commands/handlers.py | 2 + src/takopi/telegram/commands/menu.py | 6 +- src/takopi/telegram/commands/topics.py | 102 ++++++++++++++++++ src/takopi/telegram/loop.py | 40 +++++-- tests/test_telegram_bridge.py | 126 +++++++++++++++++++++- tests/test_telegram_topics_command.py | 60 ++++++++++- 13 files changed, 390 insertions(+), 20 deletions(-) diff --git a/docs/how-to/topics.md b/docs/how-to/topics.md index 6a230ab..85ee424 100644 --- a/docs/how-to/topics.md +++ b/docs/how-to/topics.md @@ -70,6 +70,8 @@ Takopi will bind the topic and rename it to match the context. - `/ctx set @branch` updates it - `/ctx clear` removes it +Note: Outside topics (private chats or main group chats), `/ctx` binds the chat context instead of a topic. + ## Reset a topic session Use `/new` inside the topic to clear stored sessions for that thread. diff --git a/docs/reference/commands-and-directives.md b/docs/reference/commands-and-directives.md index e96da66..99a7b76 100644 --- a/docs/reference/commands-and-directives.md +++ b/docs/reference/commands-and-directives.md @@ -42,11 +42,17 @@ This line is parsed from replies and takes precedence over new directives. | `/file put ` | Upload a document into the repo/worktree (requires file transfer enabled). | | `/file get ` | Fetch a file or directory back into Telegram. | | `/topic @branch` | Create/bind a topic (topics enabled). | -| `/ctx` | Show topic context binding (topics enabled). | -| `/ctx set @branch` | Update topic context binding. | -| `/ctx clear` | Remove topic context binding. | +| `/ctx` | Show context binding (chat or topic). | +| `/ctx set @branch` | Update context binding. | +| `/ctx clear` | Remove context binding. | | `/new` | Clear stored sessions for the current scope (topic/chat). | +Notes: + +- Outside topics, `/ctx` binds the chat context. +- In topics, `/ctx` binds the topic context. +- `/new` clears sessions but does **not** clear a bound context. + ## CLI Takopi’s CLI is an auto-router by default; engine subcommands override the default engine. diff --git a/docs/reference/context-resolution.md b/docs/reference/context-resolution.md index ddbec6d..a43002a 100644 --- a/docs/reference/context-resolution.md +++ b/docs/reference/context-resolution.md @@ -119,6 +119,9 @@ When a message arrives in a chat whose `chat_id` matches `projects..chat_ Takopi defaults the project context to that alias unless a reply `ctx:` or explicit `/project` directive is present. +In non-topic chats, `/ctx` can bind a chat context. That bound context is treated as +ambient and takes precedence over the default project mapping until cleared. + ## Worktree resolution When `@branch` is present: diff --git a/docs/reference/transports/telegram.md b/docs/reference/transports/telegram.md index 9178e7b..eb99d66 100644 --- a/docs/reference/transports/telegram.md +++ b/docs/reference/transports/telegram.md @@ -204,8 +204,8 @@ Commands: - `projects`: `/topic @branch` creates a topic in the project chat and binds it. - `all`: use `/topic @branch` in the main chat, or `/topic @branch` in project chats. -- `/ctx` inside a topic shows the bound context and stored session engines. - `/ctx set ...` and `/ctx clear` update the binding. +- `/ctx` shows the bound context and stored session engines inside topics. + Outside topics, `/ctx set ...` and `/ctx clear` bind the chat context. - `/new` inside a topic clears stored resume tokens for that topic. State is stored in `telegram_topics_state.json` alongside the config file. diff --git a/docs/tutorials/conversation-modes.md b/docs/tutorials/conversation-modes.md index ce3c53c..3cd3f84 100644 --- a/docs/tutorials/conversation-modes.md +++ b/docs/tutorials/conversation-modes.md @@ -31,6 +31,13 @@ Takopi treats the second message as a continuation. If you want a clean slate, u !!! user "You" /new +To pin a project or branch for the chat, use: + +!!! user "You" + /ctx set [@branch] + +`/new` clears the session but keeps the bound context. + Tip: set a default agent for this chat with `/agent set claude`. ## Stateless (reply-to-continue) @@ -81,7 +88,7 @@ takopi --onboard ## Resume lines in chat mode If you enable chat mode (or topics), Takopi can auto-resume, so you can hide resume lines for a cleaner chat. -Resume lines are still shown when no project context is set, so replies can branch there. +Disable them if you want a fully clean footer, or enable `show_resume_line` to keep reply-branching visible. If you prefer always-visible resume lines, set: diff --git a/src/takopi/telegram/chat_prefs.py b/src/takopi/telegram/chat_prefs.py index 63d688b..b140866 100644 --- a/src/takopi/telegram/chat_prefs.py +++ b/src/takopi/telegram/chat_prefs.py @@ -4,6 +4,7 @@ from pathlib import Path import msgspec +from ..context import RunContext from ..logging import get_logger from .engine_overrides import EngineOverrides, normalize_overrides from .state_store import JsonStateStore @@ -17,6 +18,8 @@ STATE_FILENAME = "telegram_chat_prefs_state.json" class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False): default_engine: str | None = None trigger_mode: str | None = None + context_project: str | None = None + context_branch: str | None = None engine_overrides: dict[str, EngineOverrides] = msgspec.field(default_factory=dict) @@ -129,6 +132,41 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]): async def clear_trigger_mode(self, chat_id: int) -> None: await self.set_trigger_mode(chat_id, None) + async def get_context(self, chat_id: int) -> RunContext | None: + async with self._lock: + self._reload_locked_if_needed() + chat = self._get_chat_locked(chat_id) + if chat is None: + return None + project = _normalize_text(chat.context_project) + if project is None: + return None + branch = _normalize_text(chat.context_branch) + return RunContext(project=project, branch=branch) + + async def set_context(self, chat_id: int, context: RunContext | None) -> None: + project = _normalize_text(context.project) if context is not None else None + branch = _normalize_text(context.branch) if context is not None else None + async with self._lock: + self._reload_locked_if_needed() + chat = self._get_chat_locked(chat_id) + if project is None: + if chat is None: + return + chat.context_project = None + chat.context_branch = None + if self._chat_is_empty(chat): + self._remove_chat_locked(chat_id) + self._save_locked() + return + chat = self._ensure_chat_locked(chat_id) + chat.context_project = project + chat.context_branch = branch + self._save_locked() + + async def clear_context(self, chat_id: int) -> None: + await self.set_context(chat_id, None) + async def get_engine_override( self, chat_id: int, engine: str ) -> EngineOverrides | None: @@ -184,6 +222,8 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]): return ( _normalize_text(chat.default_engine) is None and _normalize_trigger_mode(chat.trigger_mode) is None + and _normalize_text(chat.context_project) is None + and _normalize_text(chat.context_branch) is None and not self._has_engine_overrides(chat.engine_overrides) ) diff --git a/src/takopi/telegram/commands/executor.py b/src/takopi/telegram/commands/executor.py index 65a6ab0..8212cb2 100644 --- a/src/takopi/telegram/commands/executor.py +++ b/src/takopi/telegram/commands/executor.py @@ -109,9 +109,9 @@ def _should_show_resume_line( stateful_mode: bool, context: RunContext | None, ) -> bool: - if show_resume_line or not stateful_mode: + if show_resume_line: return True - return context is None or context.project is None + return not stateful_mode async def _send_runner_unavailable( diff --git a/src/takopi/telegram/commands/handlers.py b/src/takopi/telegram/commands/handlers.py index d03be2a..7530650 100644 --- a/src/takopi/telegram/commands/handlers.py +++ b/src/takopi/telegram/commands/handlers.py @@ -16,6 +16,7 @@ from .model import _handle_model_command as handle_model_command from .parse import _parse_slash_command as parse_slash_command from .reasoning import _handle_reasoning_command as handle_reasoning_command from .topics import _handle_chat_new_command as handle_chat_new_command +from .topics import _handle_chat_ctx_command as handle_chat_ctx_command from .topics import _handle_ctx_command as handle_ctx_command from .topics import _handle_new_command as handle_new_command from .topics import _handle_topic_command as handle_topic_command @@ -25,6 +26,7 @@ __all__ = [ "dispatch_command", "get_reserved_commands", "handle_agent_command", + "handle_chat_ctx_command", "handle_chat_new_command", "handle_ctx_command", "handle_file_command", diff --git a/src/takopi/telegram/commands/menu.py b/src/takopi/telegram/commands/menu.py index ea39c7f..1341b47 100644 --- a/src/takopi/telegram/commands/menu.py +++ b/src/takopi/telegram/commands/menu.py @@ -72,6 +72,7 @@ def build_bot_commands( seen.add(cmd) for cmd, description in [ ("new", "start a new thread"), + ("ctx", "show or update context"), ("agent", "set default agent"), ("model", "set model override"), ("reasoning", "set reasoning override"), @@ -82,10 +83,7 @@ def build_bot_commands( commands.append({"command": cmd, "description": description}) seen.add(cmd) if include_topics: - for cmd, description in [ - ("topic", "create or bind a topic"), - ("ctx", "show or update topic context"), - ]: + for cmd, description in [("topic", "create or bind a topic")]: if cmd in seen: continue commands.append({"command": cmd, "description": description}) diff --git a/src/takopi/telegram/commands/topics.py b/src/takopi/telegram/commands/topics.py index 7249a47..817da09 100644 --- a/src/takopi/telegram/commands/topics.py +++ b/src/takopi/telegram/commands/topics.py @@ -2,8 +2,11 @@ from __future__ import annotations from typing import TYPE_CHECKING +from ...context import RunContext from ...markdown import MarkdownParts +from ...transport_runtime import TransportRuntime from ...transport import RenderedMessage, SendOptions +from ..chat_prefs import ChatPrefsStore from ..chat_sessions import ChatSessionStore from ..context import ( _format_context, @@ -116,6 +119,105 @@ async def _handle_ctx_command( ) +def _parse_chat_ctx_args( + args_text: str, + *, + runtime: TransportRuntime, + default_project: str | None, +) -> tuple[RunContext | None, str | None]: + tokens = split_command_args(args_text) + if not tokens: + return None, _usage_ctx_set(chat_project=None) + if len(tokens) > 2: + return None, "too many arguments" + project_token: str | None = None + branch: str | None = None + first = tokens[0] + if first.startswith("@"): + branch = first[1:] or None + else: + project_token = first + if len(tokens) == 2: + second = tokens[1] + if not second.startswith("@"): + return None, "branch must be prefixed with @" + branch = second[1:] or None + project_key: str | None = None + if project_token is None: + if default_project is None: + return None, "project is required" + project_key = default_project + else: + project_key = runtime.normalize_project_key(project_token) + if project_key is None: + return None, f"unknown project {project_token!r}" + return RunContext(project=project_key, branch=branch), None + + +async def _handle_chat_ctx_command( + cfg: TelegramBridgeConfig, + msg: TelegramIncomingMessage, + args_text: str, + chat_prefs: ChatPrefsStore | None, +) -> None: + reply = make_reply(cfg, msg) + if chat_prefs is None: + await reply(text="chat context unavailable; config path is not set.") + return + + tokens = split_command_args(args_text) + action = tokens[0].lower() if tokens else "show" + if action in {"show", ""}: + bound = await chat_prefs.get_context(msg.chat_id) + resolved = cfg.runtime.resolve_message( + text="", + reply_text=msg.reply_to_text, + chat_id=msg.chat_id, + ambient_context=bound, + ) + source = resolved.context_source + if bound is not None and resolved.context_source == "ambient": + source = "bound" + lines = [ + f"bound ctx: {_format_context(cfg.runtime, bound)}", + f"resolved ctx: {_format_context(cfg.runtime, resolved.context)} (source: {source})", + ] + if bound is None: + ctx_usage = ( + _usage_ctx_set(chat_project=None).removeprefix("usage: ").strip() + ) + lines.append(f"note: no bound context — bind with {ctx_usage}") + await reply(text="\n".join(lines)) + return + if action == "set": + rest = " ".join(tokens[1:]) + context, error = _parse_chat_ctx_args( + rest, + runtime=cfg.runtime, + default_project=cfg.runtime.default_project, + ) + if error is not None: + await reply( + text=f"error:\n{error}\n{_usage_ctx_set(chat_project=None)}", + ) + return + if context is None: + await reply(text=f"error:\n{_usage_ctx_set(chat_project=None)}") + return + await chat_prefs.set_context(msg.chat_id, context) + await reply( + text=f"chat bound to `{_format_context(cfg.runtime, context)}`", + ) + return + if action == "clear": + await chat_prefs.clear_context(msg.chat_id) + await reply(text="chat context cleared.") + return + await reply( + text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.", + ) + + async def _handle_new_command( cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage, diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index 46dc7d4..cb6458f 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -28,6 +28,7 @@ from .commands.file_transfer import FILE_PUT_USAGE from .commands.handlers import ( dispatch_command, handle_agent_command, + handle_chat_ctx_command, handle_chat_new_command, handle_ctx_command, handle_file_command, @@ -169,8 +170,13 @@ def _dispatch_builtin_command( task_group.start_soon(handler) return True - if cfg.topics.enabled and topic_store is not None: - if command_id == "ctx": + if command_id == "ctx": + topic_key = ( + _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) + if cfg.topics.enabled and topic_store is not None + else None + ) + if topic_key is not None: handler = partial( handle_ctx_command, cfg, @@ -180,7 +186,19 @@ def _dispatch_builtin_command( resolved_scope=resolved_scope, scope_chat_ids=scope_chat_ids, ) - elif command_id == "new": + else: + handler = partial( + handle_chat_ctx_command, + cfg, + msg, + args_text, + chat_prefs, + ) + task_group.start_soon(handler) + return True + + if cfg.topics.enabled and topic_store is not None: + if command_id == "new": handler = partial( handle_new_command, cfg, @@ -1507,9 +1525,19 @@ async def run_main_loop( if state.topic_store is not None and topic_key is not None else None ) - ambient_context = _merge_topic_context( - chat_project=chat_project, bound=bound_context - ) + chat_bound_context = None + if state.chat_prefs is not None: + chat_bound_context = await state.chat_prefs.get_context(chat_id) + if bound_context is not None: + ambient_context = _merge_topic_context( + chat_project=chat_project, bound=bound_context + ) + elif chat_bound_context is not None: + ambient_context = chat_bound_context + else: + ambient_context = _merge_topic_context( + chat_project=chat_project, bound=None + ) return TelegramMsgContext( chat_id=chat_id, thread_id=msg.thread_id, diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 81a30e7..e83c358 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -135,6 +135,7 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None: assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "file", "description": "upload or fetch files"} in commands assert {"command": "new", "description": "start a new thread"} in commands + assert {"command": "ctx", "description": "show or update context"} in commands assert {"command": "agent", "description": "set default agent"} in commands assert any(cmd["command"] == "codex" for cmd in commands) @@ -179,7 +180,7 @@ def test_build_bot_commands_includes_topics_when_enabled() -> None: commands = build_bot_commands(runtime, include_topics=True) assert {"command": "topic", "description": "create or bind a topic"} in commands - assert {"command": "ctx", "description": "show or update topic context"} in commands + assert {"command": "ctx", "description": "show or update context"} in commands def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None: @@ -2515,6 +2516,129 @@ async def test_run_main_loop_hides_resume_line_when_disabled( assert resume_value not in final_text +@pytest.mark.anyio +async def test_run_main_loop_hides_resume_line_without_context( + tmp_path: Path, +) -> None: + resume_value = "resume-ctxless" + 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, + ) + runtime = TransportRuntime( + router=_make_router(runner), + projects=_empty_projects(), + config_path=state_path, + ) + cfg = TelegramBridgeConfig( + bot=bot, + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + forward_coalesce_s=FAST_FORWARD_COALESCE_S, + media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S, + 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_applies_chat_bound_context( + tmp_path: Path, +) -> None: + state_path = tmp_path / "takopi.toml" + + transport = FakeTransport() + bot = FakeBot() + runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + projects = ProjectsConfig( + projects={ + "alpha": ProjectConfig( + alias="Alpha", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ), + "beta": ProjectConfig( + alias="Beta", + path=tmp_path / "beta", + worktrees_dir=Path(".worktrees"), + ), + }, + default_project="alpha", + ) + (tmp_path / "beta").mkdir() + runtime = TransportRuntime( + router=_make_router(runner), + projects=projects, + config_path=state_path, + ) + prefs = ChatPrefsStore(resolve_prefs_path(state_path)) + await prefs.set_context(123, RunContext(project="beta")) + cfg = TelegramBridgeConfig( + bot=bot, + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + forward_coalesce_s=FAST_FORWARD_COALESCE_S, + media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S, + 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 "`ctx: Beta`" in final_text + + @pytest.mark.anyio async def test_run_main_loop_chat_sessions_isolate_group_senders( tmp_path: Path, diff --git a/tests/test_telegram_topics_command.py b/tests/test_telegram_topics_command.py index 2c67bbf..6d18038 100644 --- a/tests/test_telegram_topics_command.py +++ b/tests/test_telegram_topics_command.py @@ -4,8 +4,12 @@ from pathlib import Path import pytest from takopi.settings import TelegramTopicsSettings +from takopi.config import ProjectConfig, ProjectsConfig +from takopi.runners.mock import Return, ScriptRunner from takopi.telegram.chat_sessions import ChatSessionStore +from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path from takopi.telegram.commands.topics import ( + _handle_chat_ctx_command, _handle_chat_new_command, _handle_ctx_command, _handle_new_command, @@ -13,7 +17,13 @@ from takopi.telegram.commands.topics import ( ) from takopi.telegram.topic_state import TopicStateStore from takopi.telegram.types import TelegramIncomingMessage -from tests.telegram_fakes import FakeTransport, make_cfg +from tests.telegram_fakes import ( + DEFAULT_ENGINE_ID, + FakeTransport, + _make_router, + make_cfg, +) +from takopi.transport_runtime import TransportRuntime def _msg( @@ -37,6 +47,27 @@ def _msg( ) +def _runtime(tmp_path: Path) -> tuple[TransportRuntime, Path]: + runner = ScriptRunner([Return(answer="ok")], engine=DEFAULT_ENGINE_ID) + projects = ProjectsConfig( + projects={ + "alpha": ProjectConfig( + alias="Alpha", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project="alpha", + ) + state_path = tmp_path / "takopi.toml" + runtime = TransportRuntime( + router=_make_router(runner), + projects=projects, + config_path=state_path, + ) + return runtime, state_path + + @pytest.mark.anyio async def test_ctx_command_requires_topic(tmp_path: Path) -> None: transport = FakeTransport() @@ -60,6 +91,33 @@ async def test_ctx_command_requires_topic(tmp_path: Path) -> None: assert "only works inside a topic" in text +@pytest.mark.anyio +async def test_chat_ctx_command_sets_binding(tmp_path: Path) -> None: + transport = FakeTransport() + runtime, state_path = _runtime(tmp_path) + cfg = replace(make_cfg(transport), runtime=runtime, session_mode="chat") + store = ChatPrefsStore(resolve_prefs_path(state_path)) + + msg = _msg("/ctx set alpha @dev", chat_type="private") + await _handle_chat_ctx_command( + cfg, + msg, + args_text="set alpha @dev", + chat_prefs=store, + ) + + msg_show = _msg("/ctx", chat_type="private") + await _handle_chat_ctx_command( + cfg, + msg_show, + args_text="", + chat_prefs=store, + ) + + text = transport.send_calls[-1]["message"].text + assert "bound ctx: Alpha @dev" in text + + @pytest.mark.anyio async def test_new_command_requires_topic(tmp_path: Path) -> None: transport = FakeTransport()