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

This commit is contained in:
banteg
2026-01-15 21:56:31 +04:00
committed by GitHub
parent 6c5763b014
commit cabb796b19
15 changed files with 644 additions and 31 deletions
+95
View File
@@ -37,6 +37,7 @@ from takopi.telegram.client import BotClient
from takopi.telegram.render import MAX_BODY_CHARS
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.telegram.chat_sessions import ChatSessionStore, resolve_sessions_path
from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
@@ -2922,3 +2923,97 @@ async def test_run_main_loop_refreshes_command_ids(monkeypatch) -> None:
assert calls["count"] >= 2
assert transport.send_calls[-1]["message"].text == "late"
@pytest.mark.anyio
async def test_run_main_loop_mentions_only_skips_voice_and_files(
monkeypatch, tmp_path
) -> None:
calls = {"voice": 0, "file": 0}
async def fake_transcribe_voice(**kwargs):
_ = kwargs
calls["voice"] += 1
return "hello"
async def fake_handle_file_put_default(*args, **kwargs):
_ = args, kwargs
calls["file"] += 1
return None
monkeypatch.setattr(telegram_loop, "transcribe_voice", fake_transcribe_voice)
monkeypatch.setattr(
telegram_loop, "_handle_file_put_default", fake_handle_file_put_default
)
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
config_path = tmp_path / "takopi.toml"
runtime = TransportRuntime(
router=_make_router(runner),
projects=_empty_projects(),
config_path=config_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
voice_transcription=True,
files=TelegramFilesSettings(enabled=True, auto_put=True),
)
prefs = ChatPrefsStore(resolve_prefs_path(config_path))
await prefs.set_trigger_mode(123, "mentions")
voice = TelegramVoice(
file_id="voice-id",
mime_type="audio/ogg",
file_size=5,
duration=1,
raw={},
)
document = TelegramDocument(
file_id="doc-id",
file_name="doc.txt",
mime_type="text/plain",
file_size=5,
raw={},
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
voice=voice,
raw={},
)
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=2,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
document=document,
raw={},
)
await run_main_loop(cfg, poller)
assert calls["voice"] == 0
assert calls["file"] == 0
assert runner.calls == []
+7
View File
@@ -8,13 +8,20 @@ async def test_chat_prefs_store_roundtrip(tmp_path) -> None:
path = tmp_path / "telegram_chat_prefs_state.json"
store = ChatPrefsStore(path)
await store.set_default_engine(123, "codex")
await store.set_trigger_mode(123, "mentions")
await store.set_default_engine(123, "codex")
await store.clear_default_engine(456)
assert await store.get_default_engine(123) == "codex"
assert await store.get_trigger_mode(123) == "mentions"
store2 = ChatPrefsStore(path)
assert await store2.get_default_engine(123) == "codex"
assert await store2.get_trigger_mode(123) == "mentions"
await store2.clear_default_engine(123)
assert await store2.get_default_engine(123) is None
assert await store2.get_trigger_mode(123) == "mentions"
await store2.clear_trigger_mode(123)
assert await store2.get_trigger_mode(123) is None
+7 -1
View File
@@ -13,7 +13,11 @@ def test_parse_incoming_update_maps_fields() -> None:
"text": "hello",
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
"from": {"id": 99},
"reply_to_message": {"message_id": 5, "text": "prev"},
"reply_to_message": {
"message_id": 5,
"text": "prev",
"from": {"id": 77, "is_bot": True, "username": "ReplyBot"},
},
},
}
@@ -26,6 +30,8 @@ def test_parse_incoming_update_maps_fields() -> None:
assert msg.text == "hello"
assert msg.reply_to_message_id == 5
assert msg.reply_to_text == "prev"
assert msg.reply_to_is_bot is True
assert msg.reply_to_username == "ReplyBot"
assert msg.sender_id == 99
assert msg.thread_id is None
assert msg.is_topic_message is None
+5
View File
@@ -12,6 +12,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
context = RunContext(project="proj", branch="feat/topic")
await store.set_context(1, 10, context)
await store.set_default_engine(1, 10, "claude")
await store.set_trigger_mode(1, 10, "mentions")
await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123"))
snapshot = await store.get_thread(1, 10)
@@ -19,6 +20,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
assert snapshot.context == context
assert snapshot.sessions == {"codex": "abc123"}
assert snapshot.default_engine == "claude"
assert await store.get_trigger_mode(1, 10) == "mentions"
store2 = TopicStateStore(path)
snapshot2 = await store2.get_thread(1, 10)
@@ -26,6 +28,7 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
assert snapshot2.context == context
assert snapshot2.sessions == {"codex": "abc123"}
assert snapshot2.default_engine == "claude"
assert await store2.get_trigger_mode(1, 10) == "mentions"
@pytest.mark.anyio
@@ -54,6 +57,8 @@ async def test_topic_state_store_clear_and_find(tmp_path) -> None:
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.default_engine is None
await store.clear_trigger_mode(2, 20)
assert await store.get_trigger_mode(2, 20) is None
@pytest.mark.anyio
+112
View File
@@ -0,0 +1,112 @@
from pathlib import Path
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.ids import RESERVED_CHAT_COMMANDS
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner
from takopi.telegram.trigger_mode import should_trigger_run
from takopi.telegram.types import TelegramIncomingMessage
from takopi.transport_runtime import TransportRuntime
def _runtime() -> TransportRuntime:
runner = ScriptRunner([Return(answer="ok")], engine="codex")
router = AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
default_engine=runner.engine,
)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=Path("."),
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
return TransportRuntime(router=router, projects=projects)
def _msg(text: str, **kwargs) -> TelegramIncomingMessage:
return TelegramIncomingMessage(
transport="telegram",
chat_id=1,
message_id=1,
text=text,
reply_to_message_id=None,
reply_to_text=None,
sender_id=1,
**kwargs,
)
def test_should_trigger_run_mentions() -> None:
runtime = _runtime()
msg = _msg("hello @bot")
assert should_trigger_run(
msg,
bot_username="bot",
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_engine_and_project() -> None:
runtime = _runtime()
assert should_trigger_run(
_msg("/codex hello"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
assert should_trigger_run(
_msg("/proj hello"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_reply_to_bot() -> None:
runtime = _runtime()
msg = _msg("hello", reply_to_is_bot=True)
assert should_trigger_run(
msg,
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_known_commands() -> None:
runtime = _runtime()
assert should_trigger_run(
_msg("/agent"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
assert should_trigger_run(
_msg("/ping"),
bot_username=None,
runtime=runtime,
command_ids={"ping"},
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)
def test_should_trigger_run_ignores_unknown_commands() -> None:
runtime = _runtime()
assert not should_trigger_run(
_msg("/wat"),
bot_username=None,
runtime=runtime,
command_ids=set(),
reserved_chat_commands=set(RESERVED_CHAT_COMMANDS),
)