feat(telegram): add mentions-only trigger mode (#142)

This commit is contained in:
banteg
2026-01-15 21:56:31 +04:00
committed by GitHub
parent 6c5763b014
commit cabb796b19
15 changed files with 644 additions and 31 deletions
+32
View File
@@ -20,6 +20,8 @@ This document captures current behavior so transport changes stay intentional.
`parse_incoming_update` accepts text messages and voice notes.
### Voice transcription
If voice transcription is enabled, takopi downloads the voice payload from Telegram,
transcribes it with OpenAI, and routes the transcript through the same command and
directive pipeline as typed text.
@@ -40,6 +42,36 @@ example, `http://localhost:8000/v1`) and a dummy `OPENAI_API_KEY` if your server
ignores it. If your server requires a specific model name, set
`voice_transcription_model` (for example, `whisper-1`).
### Trigger mode (mentions-only)
Telegrams bot privacy mode stops bots from seeing every message by default, but
**admins always receive all messages** in groups. If you promote takopi to admin,
Telegram will deliver every update even when privacy mode is enabled.
To restore “only respond when invoked” behavior, use trigger mode:
- `all` (default): any message can start a run (subject to ignore rules).
- `mentions`: only start when explicitly invoked.
Explicit invocation includes any of:
- `@botname` mention in the message.
- `/engine` or `/project_alias` as the first token.
- Replying to a bot message.
- Built-in or plugin slash commands (for example `/agent`, `/file`, `/trigger`).
Commands:
- `/trigger` shows the current mode and defaults.
- `/trigger mentions` restricts runs to explicit invocations.
- `/trigger all` restores the default behavior.
- `/trigger clear` clears a topic override (topics only).
In group chats, changing trigger mode requires the sender to be an admin.
State is stored in `telegram_chat_prefs_state.json` (chat default) and
`telegram_topics_state.json` (topic overrides) alongside the config file.
## Chat sessions (optional)
If you chose the **handoff** workflow during onboarding, Takopi uses stateless mode
+3 -1
View File
@@ -6,7 +6,9 @@ ID_PATTERN = r"^[a-z0-9_]{1,32}$"
_ID_RE = re.compile(ID_PATTERN)
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"})
RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file", "new", "agent", "topic", "ctx"})
RESERVED_CHAT_COMMANDS = frozenset(
{"cancel", "file", "new", "agent", "trigger", "topic", "ctx"}
)
RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
+52 -1
View File
@@ -15,6 +15,7 @@ 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
class _ChatPrefsState(msgspec.Struct, forbid_unknown_fields=False):
@@ -37,6 +38,17 @@ def _normalize_text(value: str | None) -> str | None:
return value or None
def _normalize_trigger_mode(value: str | None) -> str | None:
if value is None:
return None
value = value.strip().lower()
if value == "mentions":
return "mentions"
if value == "all":
return None
return None
def _new_state() -> _ChatPrefsState:
return _ChatPrefsState(version=STATE_VERSION, chats={})
@@ -64,8 +76,13 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
normalized = _normalize_text(engine)
async with self._lock:
self._reload_locked_if_needed()
chat = self._get_chat_locked(chat_id)
if normalized is None:
if self._remove_chat_locked(chat_id):
if chat is None:
return
chat.default_engine = None
if self._chat_is_empty(chat):
self._remove_chat_locked(chat_id)
self._save_locked()
return
chat = self._ensure_chat_locked(chat_id)
@@ -75,6 +92,34 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
async def clear_default_engine(self, chat_id: int) -> None:
await self.set_default_engine(chat_id, None)
async def get_trigger_mode(self, chat_id: int) -> str | None:
async with self._lock:
self._reload_locked_if_needed()
chat = self._get_chat_locked(chat_id)
if chat is None:
return None
return _normalize_trigger_mode(chat.trigger_mode)
async def set_trigger_mode(self, chat_id: int, mode: str | None) -> None:
normalized = _normalize_trigger_mode(mode)
async with self._lock:
self._reload_locked_if_needed()
chat = self._get_chat_locked(chat_id)
if normalized is None:
if chat is None:
return
chat.trigger_mode = 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.trigger_mode = normalized
self._save_locked()
async def clear_trigger_mode(self, chat_id: int) -> None:
await self.set_trigger_mode(chat_id, None)
def _get_chat_locked(self, chat_id: int) -> _ChatPrefs | None:
return self._state.chats.get(_chat_key(chat_id))
@@ -87,6 +132,12 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
self._state.chats[key] = entry
return entry
def _chat_is_empty(self, chat: _ChatPrefs) -> bool:
return (
_normalize_text(chat.default_engine) is None
and _normalize_trigger_mode(chat.trigger_mode) is None
)
def _remove_chat_locked(self, chat_id: int) -> bool:
key = _chat_key(chat_id)
if key not in self._state.chats:
+1
View File
@@ -73,6 +73,7 @@ def build_bot_commands(
for cmd, description in [
("new", "start a new thread"),
("agent", "set default agent"),
("trigger", "set trigger mode"),
]:
if cmd in seen:
continue
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ..chat_prefs import ChatPrefsStore
from ..files import split_command_args
from ..topic_state import TopicStateStore
from ..topics import _topic_key
from ..trigger_mode import resolve_trigger_mode
from ..types import TelegramIncomingMessage
from .reply import make_reply
if TYPE_CHECKING:
from ..bridge import TelegramBridgeConfig
TRIGGER_USAGE = (
"usage: `/trigger`, `/trigger all`, `/trigger mentions`, or `/trigger clear`"
)
async def _check_trigger_permissions(
cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
) -> bool:
reply = make_reply(cfg, msg)
sender_id = msg.sender_id
if sender_id is None:
await reply(text="cannot verify sender for trigger settings.")
return False
is_private = msg.chat_type == "private"
if msg.chat_type is None:
is_private = msg.chat_id > 0
if is_private:
return True
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
if member is None:
await reply(text="failed to verify trigger permissions.")
return False
if member.status in {"creator", "administrator"}:
return True
await reply(text="changing trigger mode is restricted to group admins.")
return False
async def _handle_trigger_command(
cfg: TelegramBridgeConfig,
msg: TelegramIncomingMessage,
args_text: str,
_ambient_context,
topic_store: TopicStateStore | None,
chat_prefs: ChatPrefsStore | None,
*,
resolved_scope: str | None = None,
scope_chat_ids: frozenset[int] | None = None,
) -> None:
reply = make_reply(cfg, msg)
tkey = (
_topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
if topic_store is not None
else None
)
tokens = split_command_args(args_text)
action = tokens[0].lower() if tokens else "show"
if action in {"show", ""}:
resolved = await resolve_trigger_mode(
chat_id=msg.chat_id,
thread_id=msg.thread_id,
chat_prefs=chat_prefs,
topic_store=topic_store,
)
topic_mode = None
if tkey is not None and topic_store is not None:
topic_mode = await topic_store.get_trigger_mode(tkey[0], tkey[1])
chat_mode = None
if chat_prefs is not None:
chat_mode = await chat_prefs.get_trigger_mode(msg.chat_id)
if topic_mode is not None:
source = "topic override"
elif chat_mode is not None:
source = "chat default"
else:
source = "default"
trigger_line = f"trigger: {resolved} ({source})"
topic_label = topic_mode or "none"
if tkey is None:
topic_label = "none"
chat_label = "unavailable" if chat_prefs is None else chat_mode or "none"
defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}"
available_line = "available: all, mentions"
await reply(text="\n\n".join([trigger_line, defaults_line, available_line]))
return
if action in {"all", "mentions"}:
if not await _check_trigger_permissions(cfg, msg):
return
if tkey is not None:
if topic_store is None:
await reply(text="topic trigger settings are unavailable.")
return
await topic_store.set_trigger_mode(tkey[0], tkey[1], action)
await reply(text=f"topic trigger mode set to `{action}`")
return
if chat_prefs is None:
await reply(text="chat trigger settings are unavailable (no config path).")
return
await chat_prefs.set_trigger_mode(msg.chat_id, action)
await reply(text=f"chat trigger mode set to `{action}`")
return
if action == "clear":
if not await _check_trigger_permissions(cfg, msg):
return
if tkey is not None:
if topic_store is None:
await reply(text="topic trigger settings are unavailable.")
return
await topic_store.clear_trigger_mode(tkey[0], tkey[1])
await reply(text="topic trigger mode cleared (using chat default).")
return
if chat_prefs is None:
await reply(text="chat trigger settings are unavailable (no config path).")
return
await chat_prefs.clear_trigger_mode(msg.chat_id)
await reply(text="chat trigger mode reset to `all`.")
return
await reply(text=TRIGGER_USAGE)
+91 -26
View File
@@ -20,6 +20,7 @@ from ..settings import TelegramTransportSettings
from ..transport import MessageRef, SendOptions
from ..transport_runtime import ResolvedMessage
from ..context import RunContext
from ..ids import RESERVED_CHAT_COMMANDS
from .bridge import CANCEL_CALLBACK_DATA, TelegramBridgeConfig, send_plain
from .commands.agent import _handle_agent_command
from .commands.cancel import handle_callback_cancel, handle_cancel
@@ -41,6 +42,7 @@ from .commands.topics import (
_handle_new_command,
_handle_topic_command,
)
from .commands.trigger import _handle_trigger_command
from .context import _merge_topic_context, _usage_ctx_set, _usage_topic
from .topics import (
_maybe_rename_topic,
@@ -55,6 +57,7 @@ from .chat_prefs import ChatPrefsStore, resolve_prefs_path
from .chat_sessions import ChatSessionStore, resolve_sessions_path
from .engine_defaults import resolve_engine_for_message
from .topic_state import TopicStateStore, resolve_state_path
from .trigger_mode import resolve_trigger_mode, should_trigger_run
from .types import (
TelegramCallbackQuery,
TelegramIncomingMessage,
@@ -182,6 +185,19 @@ def _dispatch_builtin_command(
scope_chat_ids=scope_chat_ids,
)
if command_id == "trigger":
handlers["trigger"] = partial(
_handle_trigger_command,
cfg,
msg,
args_text,
ambient_context,
topic_store,
chat_prefs,
resolved_scope=resolved_scope,
scope_chat_ids=scope_chat_ids,
)
handler = handlers.get(command_id)
if handler is None:
return False
@@ -363,6 +379,7 @@ async def run_main_loop(
for command_id in list_command_ids(allowlist=cfg.runtime.allowlist)
}
reserved_commands = _reserved_commands(cfg.runtime)
reserved_chat_commands = set(RESERVED_CHAT_COMMANDS)
transport_snapshot = (
transport_config.model_dump() if transport_config is not None else None
)
@@ -372,6 +389,7 @@ async def run_main_loop(
media_groups: dict[tuple[int, str], _MediaGroupState] = {}
resolved_topics_scope: str | None = None
topics_chat_ids: frozenset[int] = frozenset()
bot_username: str | None = None
def refresh_topics_scope() -> None:
nonlocal resolved_topics_scope, topics_chat_ids
@@ -422,6 +440,19 @@ async def run_main_loop(
state_path=str(resolve_state_path(config_path)),
)
await _set_command_menu(cfg)
try:
me = await cfg.bot.get_me()
except Exception as exc: # noqa: BLE001
logger.info(
"trigger_mode.bot_username.failed",
error=str(exc),
error_type=exc.__class__.__name__,
)
me = None
if me is not None and me.username:
bot_username = me.username.lower()
else:
logger.info("trigger_mode.bot_username.unavailable")
async with anyio.create_task_group() as tg:
config_path = cfg.runtime.config_path
watch_enabled = bool(watch_config) and config_path is not None
@@ -777,6 +808,25 @@ async def run_main_loop(
continue
messages = list(state.messages)
del media_groups[key]
if not messages:
return
trigger_mode = await resolve_trigger_mode(
chat_id=messages[0].chat_id,
thread_id=messages[0].thread_id,
chat_prefs=chat_prefs,
topic_store=topic_store,
)
if trigger_mode == "mentions" and not any(
should_trigger_run(
msg,
bot_username=bot_username,
runtime=cfg.runtime,
command_ids=command_ids,
reserved_chat_commands=reserved_chat_commands,
)
for msg in messages
):
return
await _handle_media_group(
cfg,
messages,
@@ -809,18 +859,20 @@ async def run_main_loop(
reply = make_reply(cfg, msg)
text = msg.text
is_voice_transcribed = False
if msg.voice is not None:
text = await transcribe_voice(
bot=cfg.bot,
msg=msg,
enabled=cfg.voice_transcription,
model=cfg.voice_transcription_model,
max_bytes=cfg.voice_max_bytes,
reply=reply,
)
if text is None:
if (
cfg.files.enabled
and msg.document is not None
and msg.media_group_id is not None
):
key = (chat_id, msg.media_group_id)
state = media_groups.get(key)
if state is None:
state = _MediaGroupState(messages=[])
media_groups[key] = state
tg.start_soon(flush_media_group, key)
state.messages.append(msg)
state.token += 1
continue
is_voice_transcribed = True
topic_key = (
_topic_key(msg, cfg, scope_chat_ids=topics_chat_ids)
if topic_store is not None
@@ -840,21 +892,6 @@ async def run_main_loop(
chat_project=chat_project, bound=bound_context
)
if (
cfg.files.enabled
and msg.document is not None
and msg.media_group_id is not None
):
key = (chat_id, msg.media_group_id)
state = media_groups.get(key)
if state is None:
state = _MediaGroupState(messages=[])
media_groups[key] = state
tg.start_soon(flush_media_group, key)
state.messages.append(msg)
state.token += 1
continue
if is_cancel_command(text):
tg.start_soon(handle_cancel, cfg, msg, running_tasks, scheduler)
continue
@@ -908,6 +945,34 @@ async def run_main_loop(
task_group=tg,
):
continue
trigger_mode = await resolve_trigger_mode(
chat_id=chat_id,
thread_id=msg.thread_id,
chat_prefs=chat_prefs,
topic_store=topic_store,
)
if trigger_mode == "mentions" and not should_trigger_run(
msg,
bot_username=bot_username,
runtime=cfg.runtime,
command_ids=command_ids,
reserved_chat_commands=reserved_chat_commands,
):
continue
if msg.voice is not None:
text = await transcribe_voice(
bot=cfg.bot,
msg=msg,
enabled=cfg.voice_transcription,
model=cfg.voice_transcription_model,
max_bytes=cfg.voice_max_bytes,
reply=reply,
)
if text is None:
continue
is_voice_transcribed = True
if msg.document is not None:
if cfg.files.enabled and cfg.files.auto_put:
caption_text = text.strip()
+12
View File
@@ -167,6 +167,8 @@ def _parse_incoming_message(
reply = msg.get("reply_to_message")
reply_to_message_id = None
reply_to_text = None
reply_to_is_bot = None
reply_to_username = None
if isinstance(reply, dict):
reply_to_message_id = (
reply.get("message_id")
@@ -176,6 +178,14 @@ def _parse_incoming_message(
reply_to_text = (
reply.get("text") if isinstance(reply.get("text"), str) else None
)
reply_from = reply.get("from")
if isinstance(reply_from, dict):
is_bot = reply_from.get("is_bot")
if isinstance(is_bot, bool):
reply_to_is_bot = is_bot
username = reply_from.get("username")
if isinstance(username, str):
reply_to_username = username
sender = msg.get("from")
sender_id = (
sender.get("id")
@@ -198,6 +208,8 @@ def _parse_incoming_message(
text=text,
reply_to_message_id=reply_to_message_id,
reply_to_text=reply_to_text,
reply_to_is_bot=reply_to_is_bot,
reply_to_username=reply_to_username,
sender_id=sender_id,
media_group_id=media_group_id,
thread_id=thread_id,
+33
View File
@@ -40,6 +40,7 @@ class _ThreadState(msgspec.Struct, forbid_unknown_fields=False):
sessions: dict[str, _SessionState] = msgspec.field(default_factory=dict)
topic_title: str | None = None
default_engine: str | None = None
trigger_mode: str | None = None
class _TopicState(msgspec.Struct, forbid_unknown_fields=False):
@@ -62,6 +63,17 @@ def _normalize_text(value: str | None) -> str | None:
return value or None
def _normalize_trigger_mode(value: str | None) -> str | None:
if value is None:
return None
value = value.strip().lower()
if value == "mentions":
return "mentions"
if value == "all":
return None
return None
def _context_from_state(state: _ContextState | None) -> RunContext | None:
if state is None:
return None
@@ -161,6 +173,14 @@ class TopicStateStore(JsonStateStore[_TopicState]):
return None
return _normalize_text(thread.default_engine)
async def get_trigger_mode(self, chat_id: int, thread_id: int) -> str | None:
async with self._lock:
self._reload_locked_if_needed()
thread = self._get_thread_locked(chat_id, thread_id)
if thread is None:
return None
return _normalize_trigger_mode(thread.trigger_mode)
async def set_default_engine(
self, chat_id: int, thread_id: int, engine: str | None
) -> None:
@@ -174,6 +194,19 @@ class TopicStateStore(JsonStateStore[_TopicState]):
async def clear_default_engine(self, chat_id: int, thread_id: int) -> None:
await self.set_default_engine(chat_id, thread_id, None)
async def set_trigger_mode(
self, chat_id: int, thread_id: int, mode: str | None
) -> None:
normalized = _normalize_trigger_mode(mode)
async with self._lock:
self._reload_locked_if_needed()
thread = self._ensure_thread_locked(chat_id, thread_id)
thread.trigger_mode = normalized
self._save_locked()
async def clear_trigger_mode(self, chat_id: int, thread_id: int) -> None:
await self.set_trigger_mode(chat_id, thread_id, None)
async def set_session_resume(
self, chat_id: int, thread_id: int, token: ResumeToken
) -> None:
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Literal
from ..transport_runtime import TransportRuntime
from .chat_prefs import ChatPrefsStore
from .commands.parse import _parse_slash_command
from .topic_state import TopicStateStore
from .types import TelegramIncomingMessage
TriggerMode = Literal["all", "mentions"]
async def resolve_trigger_mode(
*,
chat_id: int,
thread_id: int | None,
chat_prefs: ChatPrefsStore | None,
topic_store: TopicStateStore | None,
) -> TriggerMode:
if topic_store is not None and thread_id is not None:
topic_mode = await topic_store.get_trigger_mode(chat_id, thread_id)
if topic_mode == "mentions":
return "mentions"
if chat_prefs is not None:
chat_mode = await chat_prefs.get_trigger_mode(chat_id)
if chat_mode == "mentions":
return "mentions"
return "all"
def should_trigger_run(
msg: TelegramIncomingMessage,
*,
bot_username: str | None,
runtime: TransportRuntime,
command_ids: set[str],
reserved_chat_commands: set[str],
) -> bool:
text = msg.text or ""
lowered = text.lower()
if bot_username:
needle = f"@{bot_username}"
if needle in lowered:
return True
if msg.reply_to_is_bot:
return True
if (
bot_username
and msg.reply_to_username
and msg.reply_to_username.lower() == bot_username
):
return True
command_id, _ = _parse_slash_command(text)
if not command_id:
return False
if command_id in reserved_chat_commands or command_id in command_ids:
return True
engine_ids = {engine.lower() for engine in runtime.available_engine_ids()}
if command_id in engine_ids:
return True
project_aliases = {alias.lower() for alias in runtime.project_aliases()}
return command_id in project_aliases
+2
View File
@@ -31,6 +31,8 @@ class TelegramIncomingMessage:
reply_to_message_id: int | None
reply_to_text: str | None
sender_id: int | None
reply_to_is_bot: bool | None = None
reply_to_username: str | None = None
media_group_id: str | None = None
thread_id: int | None = None
is_topic_message: bool | None = None
+95
View File
@@ -37,6 +37,7 @@ from takopi.telegram.client import BotClient
from takopi.telegram.render import MAX_BODY_CHARS
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.telegram.chat_sessions import ChatSessionStore, resolve_sessions_path
from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
@@ -2922,3 +2923,97 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
assert calls["count"] >= 2
assert transport.send_calls[-1]["message"].text == "late"
@pytest.mark.anyio
async def test_run_main_loop_mentions_only_skips_voice_and_files(
monkeypatch, tmp_path
) -> None:
calls = {"voice": 0, "file": 0}
async def fake_transcribe_voice(**kwargs):
_ = kwargs
calls["voice"] += 1
return "hello"
async def fake_handle_file_put_default(*args, **kwargs):
_ = args, kwargs
calls["file"] += 1
return None
monkeypatch.setattr(telegram_loop, "transcribe_voice", fake_transcribe_voice)
monkeypatch.setattr(
telegram_loop, "_handle_file_put_default", fake_handle_file_put_default
)
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
config_path = tmp_path / "takopi.toml"
runtime = TransportRuntime(
router=_make_router(runner),
projects=_empty_projects(),
config_path=config_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
voice_transcription=True,
files=TelegramFilesSettings(enabled=True, auto_put=True),
)
prefs = ChatPrefsStore(resolve_prefs_path(config_path))
await prefs.set_trigger_mode(123, "mentions")
voice = TelegramVoice(
file_id="voice-id",
mime_type="audio/ogg",
file_size=5,
duration=1,
raw={},
)
document = TelegramDocument(
file_id="doc-id",
file_name="doc.txt",
mime_type="text/plain",
file_size=5,
raw={},
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
voice=voice,
raw={},
)
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=2,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
document=document,
raw={},
)
await run_main_loop(cfg, poller)
assert calls["voice"] == 0
assert calls["file"] == 0
assert runner.calls == []
+7
View File
@@ -8,13 +8,20 @@ async def test_chat_prefs_store_roundtrip(tmp_path) -> None:
path = tmp_path / "telegram_chat_prefs_state.json"
store = ChatPrefsStore(path)
await store.set_default_engine(123, "codex")
await store.set_trigger_mode(123, "mentions")
await store.set_default_engine(123, "codex")
await store.clear_default_engine(456)
assert await store.get_default_engine(123) == "codex"
assert await store.get_trigger_mode(123) == "mentions"
store2 = ChatPrefsStore(path)
assert await store2.get_default_engine(123) == "codex"
assert await store2.get_trigger_mode(123) == "mentions"
await store2.clear_default_engine(123)
assert await store2.get_default_engine(123) is None
assert await store2.get_trigger_mode(123) == "mentions"
await store2.clear_trigger_mode(123)
assert await store2.get_trigger_mode(123) is None
+7 -1
View File
@@ -13,7 +13,11 @@ def test_parse_incoming_update_maps_fields() -> None:
"text": "hello",
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
"from": {"id": 99},
"reply_to_message": {"message_id": 5, "text": "prev"},
"reply_to_message": {
"message_id": 5,
"text": "prev",
"from": {"id": 77, "is_bot": True, "username": "ReplyBot"},
},
},
}
@@ -26,6 +30,8 @@ def test_parse_incoming_update_maps_fields() -> None:
assert msg.text == "hello"
assert msg.reply_to_message_id == 5
assert msg.reply_to_text == "prev"
assert msg.reply_to_is_bot is True
assert msg.reply_to_username == "ReplyBot"
assert msg.sender_id == 99
assert msg.thread_id is None
assert msg.is_topic_message is None
+5
View File
@@ -12,6 +12,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
context = RunContext(project="proj", branch="feat/topic")
await store.set_context(1, 10, context)
await store.set_default_engine(1, 10, "claude")
await store.set_trigger_mode(1, 10, "mentions")
await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123"))
snapshot = await store.get_thread(1, 10)
@@ -19,6 +20,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
assert snapshot.context == context
assert snapshot.sessions == {"codex": "abc123"}
assert snapshot.default_engine == "claude"
assert await store.get_trigger_mode(1, 10) == "mentions"
store2 = TopicStateStore(path)
snapshot2 = await store2.get_thread(1, 10)
@@ -26,6 +28,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
assert snapshot2.context == context
assert snapshot2.sessions == {"codex": "abc123"}
assert snapshot2.default_engine == "claude"
assert await store2.get_trigger_mode(1, 10) == "mentions"
@pytest.mark.anyio
@@ -54,6 +57,8 @@ async def test_topic_state_store_clear_and_find(tmp_path) -> None:
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.default_engine is None
await store.clear_trigger_mode(2, 20)
assert await store.get_trigger_mode(2, 20) is None
@pytest.mark.anyio
+112
View File
@@ -0,0 +1,112 @@
from pathlib import Path
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.ids import RESERVED_CHAT_COMMANDS
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner
from takopi.telegram.trigger_mode import should_trigger_run
from takopi.telegram.types import TelegramIncomingMessage
from takopi.transport_runtime import TransportRuntime
def _runtime() -> TransportRuntime:
runner = ScriptRunner([Return(answer="ok")], engine="codex")
router = AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
default_engine=runner.engine,
)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=Path("."),
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
return TransportRuntime(router=router, projects=projects)
def _msg(text: str, **kwargs) -> TelegramIncomingMessage:
return TelegramIncomingMessage(
transport="telegram",
chat_id=1,
message_id=1,
text=text,
reply_to_message_id=None,
reply_to_text=None,
sender_id=1,
**kwargs,
)
def test_should_trigger_run_mentions() -> None:
runtime = _runtime()
msg = _msg("hello @bot")
assert should_trigger_run(
msg,
bot_username="bot",
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_engine_and_project() -> None:
runtime = _runtime()
assert should_trigger_run(
_msg("/codex hello"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
assert should_trigger_run(
_msg("/proj hello"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_reply_to_bot() -> None:
runtime = _runtime()
msg = _msg("hello", reply_to_is_bot=True)
assert should_trigger_run(
msg,
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_known_commands() -> None:
runtime = _runtime()
assert should_trigger_run(
_msg("/agent"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
assert should_trigger_run(
_msg("/ping"),
bot_username=None,
runtime=runtime,
command_ids={"ping"},
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_ignores_unknown_commands() -> None:
runtime = _runtime()
assert not should_trigger_run(
_msg("/wat"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)