feat(telegram): add mentions-only trigger mode (#142)
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+92
-27
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user