diff --git a/docs/reference/transports/telegram.md b/docs/reference/transports/telegram.md index 43ab795..11dcfee 100644 --- a/docs/reference/transports/telegram.md +++ b/docs/reference/transports/telegram.md @@ -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) + +Telegram’s 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 diff --git a/src/takopi/ids.py b/src/takopi/ids.py index db6dcdc..bdfdd2c 100644 --- a/src/takopi/ids.py +++ b/src/takopi/ids.py @@ -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 diff --git a/src/takopi/telegram/chat_prefs.py b/src/takopi/telegram/chat_prefs.py index 64937e7..5fa1879 100644 --- a/src/takopi/telegram/chat_prefs.py +++ b/src/takopi/telegram/chat_prefs.py @@ -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,9 +76,14 @@ 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): - self._save_locked() + 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) chat.default_engine = normalized @@ -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: diff --git a/src/takopi/telegram/commands/menu.py b/src/takopi/telegram/commands/menu.py index 4deab6d..d518a46 100644 --- a/src/takopi/telegram/commands/menu.py +++ b/src/takopi/telegram/commands/menu.py @@ -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 diff --git a/src/takopi/telegram/commands/trigger.py b/src/takopi/telegram/commands/trigger.py new file mode 100644 index 0000000..e6d5ea6 --- /dev/null +++ b/src/takopi/telegram/commands/trigger.py @@ -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) diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index ceb0509..a712269 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -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: - continue - is_voice_transcribed = True + 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 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() diff --git a/src/takopi/telegram/parsing.py b/src/takopi/telegram/parsing.py index 821a634..c990810 100644 --- a/src/takopi/telegram/parsing.py +++ b/src/takopi/telegram/parsing.py @@ -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, diff --git a/src/takopi/telegram/topic_state.py b/src/takopi/telegram/topic_state.py index f8e5744..9bfa529 100644 --- a/src/takopi/telegram/topic_state.py +++ b/src/takopi/telegram/topic_state.py @@ -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: diff --git a/src/takopi/telegram/trigger_mode.py b/src/takopi/telegram/trigger_mode.py new file mode 100644 index 0000000..3f02fd0 --- /dev/null +++ b/src/takopi/telegram/trigger_mode.py @@ -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 diff --git a/src/takopi/telegram/types.py b/src/takopi/telegram/types.py index 733ea12..1034118 100644 --- a/src/takopi/telegram/types.py +++ b/src/takopi/telegram/types.py @@ -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 diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 02e6699..087b5c7 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -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 == [] diff --git a/tests/test_telegram_chat_prefs.py b/tests/test_telegram_chat_prefs.py index 328c5e1..5d088b8 100644 --- a/tests/test_telegram_chat_prefs.py +++ b/tests/test_telegram_chat_prefs.py @@ -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 diff --git a/tests/test_telegram_incoming.py b/tests/test_telegram_incoming.py index 3c201bb..dd244e1 100644 --- a/tests/test_telegram_incoming.py +++ b/tests/test_telegram_incoming.py @@ -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 diff --git a/tests/test_telegram_topic_state.py b/tests/test_telegram_topic_state.py index 204b923..a9ec8fc 100644 --- a/tests/test_telegram_topic_state.py +++ b/tests/test_telegram_topic_state.py @@ -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 diff --git a/tests/test_telegram_trigger_mode.py b/tests/test_telegram_trigger_mode.py new file mode 100644 index 0000000..a227ae9 --- /dev/null +++ b/tests/test_telegram_trigger_mode.py @@ -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), + )