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.
|
`parse_incoming_update` accepts text messages and voice notes.
|
||||||
|
|
||||||
|
### Voice transcription
|
||||||
|
|
||||||
If voice transcription is enabled, takopi downloads the voice payload from Telegram,
|
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
|
transcribes it with OpenAI, and routes the transcript through the same command and
|
||||||
directive pipeline as typed text.
|
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
|
ignores it. If your server requires a specific model name, set
|
||||||
`voice_transcription_model` (for example, `whisper-1`).
|
`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)
|
## Chat sessions (optional)
|
||||||
|
|
||||||
If you chose the **handoff** workflow during onboarding, Takopi uses stateless mode
|
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)
|
_ID_RE = re.compile(ID_PATTERN)
|
||||||
|
|
||||||
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"})
|
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_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
|
||||||
RESERVED_COMMAND_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):
|
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
|
||||||
default_engine: str | None = None
|
default_engine: str | None = None
|
||||||
|
trigger_mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class _ChatPrefsState(msgspec.Struct, forbid_unknown_fields=False):
|
class _ChatPrefsState(msgspec.Struct, forbid_unknown_fields=False):
|
||||||
@@ -37,6 +38,17 @@ def _normalize_text(value: str | None) -> str | None:
|
|||||||
return value or 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:
|
def _new_state() -> _ChatPrefsState:
|
||||||
return _ChatPrefsState(version=STATE_VERSION, chats={})
|
return _ChatPrefsState(version=STATE_VERSION, chats={})
|
||||||
|
|
||||||
@@ -64,9 +76,14 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
|||||||
normalized = _normalize_text(engine)
|
normalized = _normalize_text(engine)
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._reload_locked_if_needed()
|
self._reload_locked_if_needed()
|
||||||
|
chat = self._get_chat_locked(chat_id)
|
||||||
if normalized is None:
|
if normalized is None:
|
||||||
if self._remove_chat_locked(chat_id):
|
if chat is None:
|
||||||
self._save_locked()
|
return
|
||||||
|
chat.default_engine = None
|
||||||
|
if self._chat_is_empty(chat):
|
||||||
|
self._remove_chat_locked(chat_id)
|
||||||
|
self._save_locked()
|
||||||
return
|
return
|
||||||
chat = self._ensure_chat_locked(chat_id)
|
chat = self._ensure_chat_locked(chat_id)
|
||||||
chat.default_engine = normalized
|
chat.default_engine = normalized
|
||||||
@@ -75,6 +92,34 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
|||||||
async def clear_default_engine(self, chat_id: int) -> None:
|
async def clear_default_engine(self, chat_id: int) -> None:
|
||||||
await self.set_default_engine(chat_id, 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:
|
def _get_chat_locked(self, chat_id: int) -> _ChatPrefs | None:
|
||||||
return self._state.chats.get(_chat_key(chat_id))
|
return self._state.chats.get(_chat_key(chat_id))
|
||||||
|
|
||||||
@@ -87,6 +132,12 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
|||||||
self._state.chats[key] = entry
|
self._state.chats[key] = entry
|
||||||
return 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:
|
def _remove_chat_locked(self, chat_id: int) -> bool:
|
||||||
key = _chat_key(chat_id)
|
key = _chat_key(chat_id)
|
||||||
if key not in self._state.chats:
|
if key not in self._state.chats:
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ def build_bot_commands(
|
|||||||
for cmd, description in [
|
for cmd, description in [
|
||||||
("new", "start a new thread"),
|
("new", "start a new thread"),
|
||||||
("agent", "set default agent"),
|
("agent", "set default agent"),
|
||||||
|
("trigger", "set trigger mode"),
|
||||||
]:
|
]:
|
||||||
if cmd in seen:
|
if cmd in seen:
|
||||||
continue
|
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 import MessageRef, SendOptions
|
||||||
from ..transport_runtime import ResolvedMessage
|
from ..transport_runtime import ResolvedMessage
|
||||||
from ..context import RunContext
|
from ..context import RunContext
|
||||||
|
from ..ids import RESERVED_CHAT_COMMANDS
|
||||||
from .bridge import CANCEL_CALLBACK_DATA, TelegramBridgeConfig, send_plain
|
from .bridge import CANCEL_CALLBACK_DATA, TelegramBridgeConfig, send_plain
|
||||||
from .commands.agent import _handle_agent_command
|
from .commands.agent import _handle_agent_command
|
||||||
from .commands.cancel import handle_callback_cancel, handle_cancel
|
from .commands.cancel import handle_callback_cancel, handle_cancel
|
||||||
@@ -41,6 +42,7 @@ from .commands.topics import (
|
|||||||
_handle_new_command,
|
_handle_new_command,
|
||||||
_handle_topic_command,
|
_handle_topic_command,
|
||||||
)
|
)
|
||||||
|
from .commands.trigger import _handle_trigger_command
|
||||||
from .context import _merge_topic_context, _usage_ctx_set, _usage_topic
|
from .context import _merge_topic_context, _usage_ctx_set, _usage_topic
|
||||||
from .topics import (
|
from .topics import (
|
||||||
_maybe_rename_topic,
|
_maybe_rename_topic,
|
||||||
@@ -55,6 +57,7 @@ from .chat_prefs import ChatPrefsStore, resolve_prefs_path
|
|||||||
from .chat_sessions import ChatSessionStore, resolve_sessions_path
|
from .chat_sessions import ChatSessionStore, resolve_sessions_path
|
||||||
from .engine_defaults import resolve_engine_for_message
|
from .engine_defaults import resolve_engine_for_message
|
||||||
from .topic_state import TopicStateStore, resolve_state_path
|
from .topic_state import TopicStateStore, resolve_state_path
|
||||||
|
from .trigger_mode import resolve_trigger_mode, should_trigger_run
|
||||||
from .types import (
|
from .types import (
|
||||||
TelegramCallbackQuery,
|
TelegramCallbackQuery,
|
||||||
TelegramIncomingMessage,
|
TelegramIncomingMessage,
|
||||||
@@ -182,6 +185,19 @@ def _dispatch_builtin_command(
|
|||||||
scope_chat_ids=scope_chat_ids,
|
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)
|
handler = handlers.get(command_id)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
return False
|
return False
|
||||||
@@ -363,6 +379,7 @@ async def run_main_loop(
|
|||||||
for command_id in list_command_ids(allowlist=cfg.runtime.allowlist)
|
for command_id in list_command_ids(allowlist=cfg.runtime.allowlist)
|
||||||
}
|
}
|
||||||
reserved_commands = _reserved_commands(cfg.runtime)
|
reserved_commands = _reserved_commands(cfg.runtime)
|
||||||
|
reserved_chat_commands = set(RESERVED_CHAT_COMMANDS)
|
||||||
transport_snapshot = (
|
transport_snapshot = (
|
||||||
transport_config.model_dump() if transport_config is not None else None
|
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] = {}
|
media_groups: dict[tuple[int, str], _MediaGroupState] = {}
|
||||||
resolved_topics_scope: str | None = None
|
resolved_topics_scope: str | None = None
|
||||||
topics_chat_ids: frozenset[int] = frozenset()
|
topics_chat_ids: frozenset[int] = frozenset()
|
||||||
|
bot_username: str | None = None
|
||||||
|
|
||||||
def refresh_topics_scope() -> None:
|
def refresh_topics_scope() -> None:
|
||||||
nonlocal resolved_topics_scope, topics_chat_ids
|
nonlocal resolved_topics_scope, topics_chat_ids
|
||||||
@@ -422,6 +440,19 @@ async def run_main_loop(
|
|||||||
state_path=str(resolve_state_path(config_path)),
|
state_path=str(resolve_state_path(config_path)),
|
||||||
)
|
)
|
||||||
await _set_command_menu(cfg)
|
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:
|
async with anyio.create_task_group() as tg:
|
||||||
config_path = cfg.runtime.config_path
|
config_path = cfg.runtime.config_path
|
||||||
watch_enabled = bool(watch_config) and config_path is not None
|
watch_enabled = bool(watch_config) and config_path is not None
|
||||||
@@ -777,6 +808,25 @@ async def run_main_loop(
|
|||||||
continue
|
continue
|
||||||
messages = list(state.messages)
|
messages = list(state.messages)
|
||||||
del media_groups[key]
|
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(
|
await _handle_media_group(
|
||||||
cfg,
|
cfg,
|
||||||
messages,
|
messages,
|
||||||
@@ -809,18 +859,20 @@ async def run_main_loop(
|
|||||||
reply = make_reply(cfg, msg)
|
reply = make_reply(cfg, msg)
|
||||||
text = msg.text
|
text = msg.text
|
||||||
is_voice_transcribed = False
|
is_voice_transcribed = False
|
||||||
if msg.voice is not None:
|
if (
|
||||||
text = await transcribe_voice(
|
cfg.files.enabled
|
||||||
bot=cfg.bot,
|
and msg.document is not None
|
||||||
msg=msg,
|
and msg.media_group_id is not None
|
||||||
enabled=cfg.voice_transcription,
|
):
|
||||||
model=cfg.voice_transcription_model,
|
key = (chat_id, msg.media_group_id)
|
||||||
max_bytes=cfg.voice_max_bytes,
|
state = media_groups.get(key)
|
||||||
reply=reply,
|
if state is None:
|
||||||
)
|
state = _MediaGroupState(messages=[])
|
||||||
if text is None:
|
media_groups[key] = state
|
||||||
continue
|
tg.start_soon(flush_media_group, key)
|
||||||
is_voice_transcribed = True
|
state.messages.append(msg)
|
||||||
|
state.token += 1
|
||||||
|
continue
|
||||||
topic_key = (
|
topic_key = (
|
||||||
_topic_key(msg, cfg, scope_chat_ids=topics_chat_ids)
|
_topic_key(msg, cfg, scope_chat_ids=topics_chat_ids)
|
||||||
if topic_store is not None
|
if topic_store is not None
|
||||||
@@ -840,21 +892,6 @@ async def run_main_loop(
|
|||||||
chat_project=chat_project, bound=bound_context
|
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):
|
if is_cancel_command(text):
|
||||||
tg.start_soon(handle_cancel, cfg, msg, running_tasks, scheduler)
|
tg.start_soon(handle_cancel, cfg, msg, running_tasks, scheduler)
|
||||||
continue
|
continue
|
||||||
@@ -908,6 +945,34 @@ async def run_main_loop(
|
|||||||
task_group=tg,
|
task_group=tg,
|
||||||
):
|
):
|
||||||
continue
|
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 msg.document is not None:
|
||||||
if cfg.files.enabled and cfg.files.auto_put:
|
if cfg.files.enabled and cfg.files.auto_put:
|
||||||
caption_text = text.strip()
|
caption_text = text.strip()
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ def _parse_incoming_message(
|
|||||||
reply = msg.get("reply_to_message")
|
reply = msg.get("reply_to_message")
|
||||||
reply_to_message_id = None
|
reply_to_message_id = None
|
||||||
reply_to_text = None
|
reply_to_text = None
|
||||||
|
reply_to_is_bot = None
|
||||||
|
reply_to_username = None
|
||||||
if isinstance(reply, dict):
|
if isinstance(reply, dict):
|
||||||
reply_to_message_id = (
|
reply_to_message_id = (
|
||||||
reply.get("message_id")
|
reply.get("message_id")
|
||||||
@@ -176,6 +178,14 @@ def _parse_incoming_message(
|
|||||||
reply_to_text = (
|
reply_to_text = (
|
||||||
reply.get("text") if isinstance(reply.get("text"), str) else None
|
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 = msg.get("from")
|
||||||
sender_id = (
|
sender_id = (
|
||||||
sender.get("id")
|
sender.get("id")
|
||||||
@@ -198,6 +208,8 @@ def _parse_incoming_message(
|
|||||||
text=text,
|
text=text,
|
||||||
reply_to_message_id=reply_to_message_id,
|
reply_to_message_id=reply_to_message_id,
|
||||||
reply_to_text=reply_to_text,
|
reply_to_text=reply_to_text,
|
||||||
|
reply_to_is_bot=reply_to_is_bot,
|
||||||
|
reply_to_username=reply_to_username,
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
media_group_id=media_group_id,
|
media_group_id=media_group_id,
|
||||||
thread_id=thread_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)
|
sessions: dict[str, _SessionState] = msgspec.field(default_factory=dict)
|
||||||
topic_title: str | None = None
|
topic_title: str | None = None
|
||||||
default_engine: str | None = None
|
default_engine: str | None = None
|
||||||
|
trigger_mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class _TopicState(msgspec.Struct, forbid_unknown_fields=False):
|
class _TopicState(msgspec.Struct, forbid_unknown_fields=False):
|
||||||
@@ -62,6 +63,17 @@ def _normalize_text(value: str | None) -> str | None:
|
|||||||
return value or 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:
|
def _context_from_state(state: _ContextState | None) -> RunContext | None:
|
||||||
if state is None:
|
if state is None:
|
||||||
return None
|
return None
|
||||||
@@ -161,6 +173,14 @@ class TopicStateStore(JsonStateStore[_TopicState]):
|
|||||||
return None
|
return None
|
||||||
return _normalize_text(thread.default_engine)
|
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(
|
async def set_default_engine(
|
||||||
self, chat_id: int, thread_id: int, engine: str | None
|
self, chat_id: int, thread_id: int, engine: str | None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -174,6 +194,19 @@ class TopicStateStore(JsonStateStore[_TopicState]):
|
|||||||
async def clear_default_engine(self, chat_id: int, thread_id: int) -> None:
|
async def clear_default_engine(self, chat_id: int, thread_id: int) -> None:
|
||||||
await self.set_default_engine(chat_id, thread_id, 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(
|
async def set_session_resume(
|
||||||
self, chat_id: int, thread_id: int, token: ResumeToken
|
self, chat_id: int, thread_id: int, token: ResumeToken
|
||||||
) -> None:
|
) -> 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_message_id: int | None
|
||||||
reply_to_text: str | None
|
reply_to_text: str | None
|
||||||
sender_id: int | None
|
sender_id: int | None
|
||||||
|
reply_to_is_bot: bool | None = None
|
||||||
|
reply_to_username: str | None = None
|
||||||
media_group_id: str | None = None
|
media_group_id: str | None = None
|
||||||
thread_id: int | None = None
|
thread_id: int | None = None
|
||||||
is_topic_message: bool | 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.render import MAX_BODY_CHARS
|
||||||
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
|
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
|
||||||
from takopi.telegram.chat_sessions import ChatSessionStore, resolve_sessions_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.context import RunContext
|
||||||
from takopi.config import ProjectConfig, ProjectsConfig
|
from takopi.config import ProjectConfig, ProjectsConfig
|
||||||
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
|
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 calls["count"] >= 2
|
||||||
assert transport.send_calls[-1]["message"].text == "late"
|
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"
|
path = tmp_path / "telegram_chat_prefs_state.json"
|
||||||
store = ChatPrefsStore(path)
|
store = ChatPrefsStore(path)
|
||||||
await store.set_default_engine(123, "codex")
|
await store.set_default_engine(123, "codex")
|
||||||
|
await store.set_trigger_mode(123, "mentions")
|
||||||
await store.set_default_engine(123, "codex")
|
await store.set_default_engine(123, "codex")
|
||||||
await store.clear_default_engine(456)
|
await store.clear_default_engine(456)
|
||||||
|
|
||||||
assert await store.get_default_engine(123) == "codex"
|
assert await store.get_default_engine(123) == "codex"
|
||||||
|
assert await store.get_trigger_mode(123) == "mentions"
|
||||||
|
|
||||||
store2 = ChatPrefsStore(path)
|
store2 = ChatPrefsStore(path)
|
||||||
assert await store2.get_default_engine(123) == "codex"
|
assert await store2.get_default_engine(123) == "codex"
|
||||||
|
assert await store2.get_trigger_mode(123) == "mentions"
|
||||||
|
|
||||||
await store2.clear_default_engine(123)
|
await store2.clear_default_engine(123)
|
||||||
assert await store2.get_default_engine(123) is None
|
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",
|
"text": "hello",
|
||||||
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
|
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
|
||||||
"from": {"id": 99},
|
"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.text == "hello"
|
||||||
assert msg.reply_to_message_id == 5
|
assert msg.reply_to_message_id == 5
|
||||||
assert msg.reply_to_text == "prev"
|
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.sender_id == 99
|
||||||
assert msg.thread_id is None
|
assert msg.thread_id is None
|
||||||
assert msg.is_topic_message 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")
|
context = RunContext(project="proj", branch="feat/topic")
|
||||||
await store.set_context(1, 10, context)
|
await store.set_context(1, 10, context)
|
||||||
await store.set_default_engine(1, 10, "claude")
|
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"))
|
await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123"))
|
||||||
|
|
||||||
snapshot = await store.get_thread(1, 10)
|
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.context == context
|
||||||
assert snapshot.sessions == {"codex": "abc123"}
|
assert snapshot.sessions == {"codex": "abc123"}
|
||||||
assert snapshot.default_engine == "claude"
|
assert snapshot.default_engine == "claude"
|
||||||
|
assert await store.get_trigger_mode(1, 10) == "mentions"
|
||||||
|
|
||||||
store2 = TopicStateStore(path)
|
store2 = TopicStateStore(path)
|
||||||
snapshot2 = await store2.get_thread(1, 10)
|
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.context == context
|
||||||
assert snapshot2.sessions == {"codex": "abc123"}
|
assert snapshot2.sessions == {"codex": "abc123"}
|
||||||
assert snapshot2.default_engine == "claude"
|
assert snapshot2.default_engine == "claude"
|
||||||
|
assert await store2.get_trigger_mode(1, 10) == "mentions"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@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)
|
snapshot = await store.get_thread(2, 20)
|
||||||
assert snapshot is not None
|
assert snapshot is not None
|
||||||
assert snapshot.default_engine is 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
|
@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