feat(telegram): make /ctx work everywhere (#159)

This commit is contained in:
banteg
2026-01-17 01:17:50 +04:00
committed by GitHub
parent b215279a3c
commit 419ec5078b
13 changed files with 390 additions and 20 deletions
+2
View File
@@ -70,6 +70,8 @@ Takopi will bind the topic and rename it to match the context.
- `/ctx set <project> @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.
+9 -3
View File
@@ -42,11 +42,17 @@ This line is parsed from replies and takes precedence over new directives.
| `/file put <path>` | Upload a document into the repo/worktree (requires file transfer enabled). |
| `/file get <path>` | Fetch a file or directory back into Telegram. |
| `/topic <project> @branch` | Create/bind a topic (topics enabled). |
| `/ctx` | Show topic context binding (topics enabled). |
| `/ctx set <project> @branch` | Update topic context binding. |
| `/ctx clear` | Remove topic context binding. |
| `/ctx` | Show context binding (chat or topic). |
| `/ctx set <project> @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
Takopis CLI is an auto-router by default; engine subcommands override the default engine.
+3
View File
@@ -119,6 +119,9 @@ When a message arrives in a chat whose `chat_id` matches `projects.<alias>.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:
+2 -2
View File
@@ -204,8 +204,8 @@ Commands:
- `projects`: `/topic @branch` creates a topic in the project chat and binds it.
- `all`: use `/topic <project> @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.
+8 -1
View File
@@ -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 <project> [@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:
+40
View File
@@ -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)
)
+2 -2
View File
@@ -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(
+2
View File
@@ -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",
+2 -4
View File
@@ -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})
+102
View File
@@ -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,
+34 -6
View File
@@ -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,
+125 -1
View File
@@ -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,
+59 -1
View File
@@ -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()