feat(telegram): make /ctx work everywhere (#159)
This commit is contained in:
@@ -70,6 +70,8 @@ Takopi will bind the topic and rename it to match the context.
|
|||||||
- `/ctx set <project> @branch` updates it
|
- `/ctx set <project> @branch` updates it
|
||||||
- `/ctx clear` removes 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
|
## Reset a topic session
|
||||||
|
|
||||||
Use `/new` inside the topic to clear stored sessions for that thread.
|
Use `/new` inside the topic to clear stored sessions for that thread.
|
||||||
|
|||||||
@@ -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 put <path>` | Upload a document into the repo/worktree (requires file transfer enabled). |
|
||||||
| `/file get <path>` | Fetch a file or directory back into Telegram. |
|
| `/file get <path>` | Fetch a file or directory back into Telegram. |
|
||||||
| `/topic <project> @branch` | Create/bind a topic (topics enabled). |
|
| `/topic <project> @branch` | Create/bind a topic (topics enabled). |
|
||||||
| `/ctx` | Show topic context binding (topics enabled). |
|
| `/ctx` | Show context binding (chat or topic). |
|
||||||
| `/ctx set <project> @branch` | Update topic context binding. |
|
| `/ctx set <project> @branch` | Update context binding. |
|
||||||
| `/ctx clear` | Remove topic context binding. |
|
| `/ctx clear` | Remove context binding. |
|
||||||
| `/new` | Clear stored sessions for the current scope (topic/chat). |
|
| `/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
|
## CLI
|
||||||
|
|
||||||
Takopi’s CLI is an auto-router by default; engine subcommands override the default engine.
|
Takopi’s CLI is an auto-router by default; engine subcommands override the default engine.
|
||||||
|
|||||||
@@ -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
|
Takopi defaults the project context to that alias unless a reply `ctx:` or explicit
|
||||||
`/project` directive is present.
|
`/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
|
## Worktree resolution
|
||||||
|
|
||||||
When `@branch` is present:
|
When `@branch` is present:
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ Commands:
|
|||||||
- `projects`: `/topic @branch` creates a topic in the project chat and binds it.
|
- `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
|
- `all`: use `/topic <project> @branch` in the main chat, or `/topic @branch` in
|
||||||
project chats.
|
project chats.
|
||||||
- `/ctx` inside a topic shows the bound context and stored session engines.
|
- `/ctx` shows the bound context and stored session engines inside topics.
|
||||||
`/ctx set ...` and `/ctx clear` update the binding.
|
Outside topics, `/ctx set ...` and `/ctx clear` bind the chat context.
|
||||||
- `/new` inside a topic clears stored resume tokens for that topic.
|
- `/new` inside a topic clears stored resume tokens for that topic.
|
||||||
|
|
||||||
State is stored in `telegram_topics_state.json` alongside the config file.
|
State is stored in `telegram_topics_state.json` alongside the config file.
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ Takopi treats the second message as a continuation. If you want a clean slate, u
|
|||||||
!!! user "You"
|
!!! user "You"
|
||||||
/new
|
/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`.
|
Tip: set a default agent for this chat with `/agent set claude`.
|
||||||
|
|
||||||
## Stateless (reply-to-continue)
|
## Stateless (reply-to-continue)
|
||||||
@@ -81,7 +88,7 @@ takopi --onboard
|
|||||||
## Resume lines in chat mode
|
## 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.
|
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:
|
If you prefer always-visible resume lines, set:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
|
from ..context import RunContext
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
from .engine_overrides import EngineOverrides, normalize_overrides
|
from .engine_overrides import EngineOverrides, normalize_overrides
|
||||||
from .state_store import JsonStateStore
|
from .state_store import JsonStateStore
|
||||||
@@ -17,6 +18,8 @@ STATE_FILENAME = "telegram_chat_prefs_state.json"
|
|||||||
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
|
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
|
||||||
default_engine: str | None = None
|
default_engine: str | None = None
|
||||||
trigger_mode: 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)
|
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:
|
async def clear_trigger_mode(self, chat_id: int) -> None:
|
||||||
await self.set_trigger_mode(chat_id, 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(
|
async def get_engine_override(
|
||||||
self, chat_id: int, engine: str
|
self, chat_id: int, engine: str
|
||||||
) -> EngineOverrides | None:
|
) -> EngineOverrides | None:
|
||||||
@@ -184,6 +222,8 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
|||||||
return (
|
return (
|
||||||
_normalize_text(chat.default_engine) is None
|
_normalize_text(chat.default_engine) is None
|
||||||
and _normalize_trigger_mode(chat.trigger_mode) 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)
|
and not self._has_engine_overrides(chat.engine_overrides)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ def _should_show_resume_line(
|
|||||||
stateful_mode: bool,
|
stateful_mode: bool,
|
||||||
context: RunContext | None,
|
context: RunContext | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if show_resume_line or not stateful_mode:
|
if show_resume_line:
|
||||||
return True
|
return True
|
||||||
return context is None or context.project is None
|
return not stateful_mode
|
||||||
|
|
||||||
|
|
||||||
async def _send_runner_unavailable(
|
async def _send_runner_unavailable(
|
||||||
|
|||||||
@@ -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 .parse import _parse_slash_command as parse_slash_command
|
||||||
from .reasoning import _handle_reasoning_command as handle_reasoning_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_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_ctx_command as handle_ctx_command
|
||||||
from .topics import _handle_new_command as handle_new_command
|
from .topics import _handle_new_command as handle_new_command
|
||||||
from .topics import _handle_topic_command as handle_topic_command
|
from .topics import _handle_topic_command as handle_topic_command
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"dispatch_command",
|
"dispatch_command",
|
||||||
"get_reserved_commands",
|
"get_reserved_commands",
|
||||||
"handle_agent_command",
|
"handle_agent_command",
|
||||||
|
"handle_chat_ctx_command",
|
||||||
"handle_chat_new_command",
|
"handle_chat_new_command",
|
||||||
"handle_ctx_command",
|
"handle_ctx_command",
|
||||||
"handle_file_command",
|
"handle_file_command",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ def build_bot_commands(
|
|||||||
seen.add(cmd)
|
seen.add(cmd)
|
||||||
for cmd, description in [
|
for cmd, description in [
|
||||||
("new", "start a new thread"),
|
("new", "start a new thread"),
|
||||||
|
("ctx", "show or update context"),
|
||||||
("agent", "set default agent"),
|
("agent", "set default agent"),
|
||||||
("model", "set model override"),
|
("model", "set model override"),
|
||||||
("reasoning", "set reasoning override"),
|
("reasoning", "set reasoning override"),
|
||||||
@@ -82,10 +83,7 @@ def build_bot_commands(
|
|||||||
commands.append({"command": cmd, "description": description})
|
commands.append({"command": cmd, "description": description})
|
||||||
seen.add(cmd)
|
seen.add(cmd)
|
||||||
if include_topics:
|
if include_topics:
|
||||||
for cmd, description in [
|
for cmd, description in [("topic", "create or bind a topic")]:
|
||||||
("topic", "create or bind a topic"),
|
|
||||||
("ctx", "show or update topic context"),
|
|
||||||
]:
|
|
||||||
if cmd in seen:
|
if cmd in seen:
|
||||||
continue
|
continue
|
||||||
commands.append({"command": cmd, "description": description})
|
commands.append({"command": cmd, "description": description})
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ...context import RunContext
|
||||||
from ...markdown import MarkdownParts
|
from ...markdown import MarkdownParts
|
||||||
|
from ...transport_runtime import TransportRuntime
|
||||||
from ...transport import RenderedMessage, SendOptions
|
from ...transport import RenderedMessage, SendOptions
|
||||||
|
from ..chat_prefs import ChatPrefsStore
|
||||||
from ..chat_sessions import ChatSessionStore
|
from ..chat_sessions import ChatSessionStore
|
||||||
from ..context import (
|
from ..context import (
|
||||||
_format_context,
|
_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(
|
async def _handle_new_command(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
msg: TelegramIncomingMessage,
|
msg: TelegramIncomingMessage,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .commands.file_transfer import FILE_PUT_USAGE
|
|||||||
from .commands.handlers import (
|
from .commands.handlers import (
|
||||||
dispatch_command,
|
dispatch_command,
|
||||||
handle_agent_command,
|
handle_agent_command,
|
||||||
|
handle_chat_ctx_command,
|
||||||
handle_chat_new_command,
|
handle_chat_new_command,
|
||||||
handle_ctx_command,
|
handle_ctx_command,
|
||||||
handle_file_command,
|
handle_file_command,
|
||||||
@@ -169,8 +170,13 @@ def _dispatch_builtin_command(
|
|||||||
task_group.start_soon(handler)
|
task_group.start_soon(handler)
|
||||||
return True
|
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(
|
handler = partial(
|
||||||
handle_ctx_command,
|
handle_ctx_command,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -180,7 +186,19 @@ def _dispatch_builtin_command(
|
|||||||
resolved_scope=resolved_scope,
|
resolved_scope=resolved_scope,
|
||||||
scope_chat_ids=scope_chat_ids,
|
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(
|
handler = partial(
|
||||||
handle_new_command,
|
handle_new_command,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -1507,9 +1525,19 @@ async def run_main_loop(
|
|||||||
if state.topic_store is not None and topic_key is not None
|
if state.topic_store is not None and topic_key is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
ambient_context = _merge_topic_context(
|
chat_bound_context = None
|
||||||
chat_project=chat_project, bound=bound_context
|
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(
|
return TelegramMsgContext(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
thread_id=msg.thread_id,
|
thread_id=msg.thread_id,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
|
|||||||
assert {"command": "cancel", "description": "cancel run"} in commands
|
assert {"command": "cancel", "description": "cancel run"} in commands
|
||||||
assert {"command": "file", "description": "upload or fetch files"} in commands
|
assert {"command": "file", "description": "upload or fetch files"} in commands
|
||||||
assert {"command": "new", "description": "start a new thread"} 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 {"command": "agent", "description": "set default agent"} in commands
|
||||||
assert any(cmd["command"] == "codex" for cmd 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)
|
commands = build_bot_commands(runtime, include_topics=True)
|
||||||
|
|
||||||
assert {"command": "topic", "description": "create or bind a topic"} in commands
|
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:
|
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
|
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
|
@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,
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from takopi.settings import TelegramTopicsSettings
|
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_sessions import ChatSessionStore
|
||||||
|
from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path
|
||||||
from takopi.telegram.commands.topics import (
|
from takopi.telegram.commands.topics import (
|
||||||
|
_handle_chat_ctx_command,
|
||||||
_handle_chat_new_command,
|
_handle_chat_new_command,
|
||||||
_handle_ctx_command,
|
_handle_ctx_command,
|
||||||
_handle_new_command,
|
_handle_new_command,
|
||||||
@@ -13,7 +17,13 @@ from takopi.telegram.commands.topics import (
|
|||||||
)
|
)
|
||||||
from takopi.telegram.topic_state import TopicStateStore
|
from takopi.telegram.topic_state import TopicStateStore
|
||||||
from takopi.telegram.types import TelegramIncomingMessage
|
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(
|
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
|
@pytest.mark.anyio
|
||||||
async def test_ctx_command_requires_topic(tmp_path: Path) -> None:
|
async def test_ctx_command_requires_topic(tmp_path: Path) -> None:
|
||||||
transport = FakeTransport()
|
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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_new_command_requires_topic(tmp_path: Path) -> None:
|
async def test_new_command_requires_topic(tmp_path: Path) -> None:
|
||||||
transport = FakeTransport()
|
transport = FakeTransport()
|
||||||
|
|||||||
Reference in New Issue
Block a user