feat(telegram): add inline cancel button (#79)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
@@ -8,8 +9,10 @@ import takopi.telegram.bridge as bridge
|
||||
from takopi.directives import parse_directives
|
||||
from takopi.telegram.bridge import (
|
||||
TelegramBridgeConfig,
|
||||
TelegramPresenter,
|
||||
TelegramTransport,
|
||||
_build_bot_commands,
|
||||
_handle_callback_cancel,
|
||||
_handle_cancel,
|
||||
_is_cancel_command,
|
||||
_send_with_resume,
|
||||
@@ -20,10 +23,11 @@ from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config
|
||||
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
|
||||
from takopi.markdown import MarkdownPresenter
|
||||
from takopi.model import EngineId, ResumeToken
|
||||
from takopi.progress import ProgressTracker
|
||||
from takopi.router import AutoRouter, RunnerEntry
|
||||
from takopi.transport_runtime import TransportRuntime
|
||||
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
|
||||
from takopi.telegram.types import TelegramIncomingMessage
|
||||
from takopi.telegram.types import TelegramCallbackQuery, TelegramIncomingMessage
|
||||
from takopi.transport import MessageRef, RenderedMessage, SendOptions
|
||||
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
|
||||
|
||||
@@ -91,6 +95,7 @@ class _FakeTransport:
|
||||
class _FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self.command_calls: list[dict] = []
|
||||
self.callback_calls: list[dict] = []
|
||||
self.send_calls: list[dict] = []
|
||||
self.edit_calls: list[dict] = []
|
||||
self.delete_calls: list[dict] = []
|
||||
@@ -122,6 +127,7 @@ class _FakeBot:
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
replace_message_id: int | None = None,
|
||||
) -> dict:
|
||||
@@ -133,6 +139,7 @@ class _FakeBot:
|
||||
"disable_notification": disable_notification,
|
||||
"entities": entities,
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
"replace_message_id": replace_message_id,
|
||||
}
|
||||
)
|
||||
@@ -145,6 +152,7 @@ class _FakeBot:
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict:
|
||||
@@ -155,6 +163,7 @@ class _FakeBot:
|
||||
"text": text,
|
||||
"entities": entities,
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
"wait": wait,
|
||||
}
|
||||
)
|
||||
@@ -186,6 +195,21 @@ class _FakeBot:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def answer_callback_query(
|
||||
self,
|
||||
callback_query_id: str,
|
||||
text: str | None = None,
|
||||
show_alert: bool | None = None,
|
||||
) -> bool:
|
||||
self.callback_calls.append(
|
||||
{
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": text,
|
||||
"show_alert": show_alert,
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _make_cfg(
|
||||
transport: _FakeTransport, runner: ScriptRunner | None = None
|
||||
@@ -356,6 +380,35 @@ def test_build_bot_commands_caps_total() -> None:
|
||||
assert any(cmd["command"] == "cancel" for cmd in commands)
|
||||
|
||||
|
||||
def test_telegram_presenter_progress_shows_cancel_button() -> None:
|
||||
presenter = TelegramPresenter()
|
||||
state = ProgressTracker(engine="codex").snapshot()
|
||||
|
||||
rendered = presenter.render_progress(state, elapsed_s=0.0)
|
||||
|
||||
reply_markup = rendered.extra["reply_markup"]
|
||||
assert reply_markup["inline_keyboard"][0][0]["text"] == "cancel"
|
||||
assert reply_markup["inline_keyboard"][0][0]["callback_data"] == "takopi:cancel"
|
||||
|
||||
|
||||
def test_telegram_presenter_clears_button_on_cancelled() -> None:
|
||||
presenter = TelegramPresenter()
|
||||
state = ProgressTracker(engine="codex").snapshot()
|
||||
|
||||
rendered = presenter.render_progress(state, elapsed_s=0.0, label="`cancelled`")
|
||||
|
||||
assert rendered.extra["reply_markup"]["inline_keyboard"] == []
|
||||
|
||||
|
||||
def test_telegram_presenter_final_clears_button() -> None:
|
||||
presenter = TelegramPresenter()
|
||||
state = ProgressTracker(engine="codex").snapshot()
|
||||
|
||||
rendered = presenter.render_final(state, elapsed_s=0.0, status="done", answer="ok")
|
||||
|
||||
assert rendered.extra["reply_markup"]["inline_keyboard"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_transport_passes_replace_and_wait() -> None:
|
||||
bot = _FakeBot()
|
||||
@@ -380,6 +433,28 @@ async def test_telegram_transport_passes_replace_and_wait() -> None:
|
||||
assert bot.edit_calls[0]["wait"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_transport_passes_reply_markup() -> None:
|
||||
bot = _FakeBot()
|
||||
transport = TelegramTransport(bot)
|
||||
markup = {"inline_keyboard": []}
|
||||
|
||||
await transport.send(
|
||||
channel_id=123,
|
||||
message=RenderedMessage(text="hello", extra={"reply_markup": markup}),
|
||||
)
|
||||
assert bot.send_calls
|
||||
assert bot.send_calls[0]["reply_markup"] == markup
|
||||
|
||||
ref = MessageRef(channel_id=123, message_id=1)
|
||||
await transport.edit(
|
||||
ref=ref,
|
||||
message=RenderedMessage(text="edit", extra={"reply_markup": markup}),
|
||||
)
|
||||
assert bot.edit_calls
|
||||
assert bot.edit_calls[0]["reply_markup"] == markup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
class _OutboxBot:
|
||||
@@ -410,9 +485,11 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
replace_message_id: int | None = None,
|
||||
) -> dict | None:
|
||||
_ = reply_markup
|
||||
return None
|
||||
|
||||
async def edit_message_text(
|
||||
@@ -422,6 +499,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict | None:
|
||||
@@ -432,6 +510,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
"text": text,
|
||||
"entities": entities,
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
"wait": wait,
|
||||
}
|
||||
)
|
||||
@@ -461,6 +540,15 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def answer_callback_query(
|
||||
self,
|
||||
callback_query_id: str,
|
||||
text: str | None = None,
|
||||
show_alert: bool | None = None,
|
||||
) -> bool:
|
||||
_ = callback_query_id, text, show_alert
|
||||
return True
|
||||
|
||||
bot = _OutboxBot()
|
||||
transport = TelegramTransport(bot)
|
||||
ref = MessageRef(channel_id=123, message_id=1)
|
||||
@@ -590,6 +678,52 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
|
||||
assert len(transport.send_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_callback_cancel_cancels_running_task() -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
progress_id = 42
|
||||
running_task = RunningTask()
|
||||
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task}
|
||||
query = TelegramCallbackQuery(
|
||||
transport="telegram",
|
||||
chat_id=123,
|
||||
message_id=progress_id,
|
||||
callback_query_id="cbq-1",
|
||||
data="takopi:cancel",
|
||||
sender_id=123,
|
||||
)
|
||||
|
||||
await _handle_callback_cancel(cfg, query, running_tasks)
|
||||
|
||||
assert running_task.cancel_requested.is_set() is True
|
||||
assert len(transport.send_calls) == 0
|
||||
bot = cast(_FakeBot, cfg.bot)
|
||||
assert bot.callback_calls
|
||||
assert bot.callback_calls[-1]["text"] == "cancelling..."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_callback_cancel_without_task_acknowledges() -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
query = TelegramCallbackQuery(
|
||||
transport="telegram",
|
||||
chat_id=123,
|
||||
message_id=99,
|
||||
callback_query_id="cbq-2",
|
||||
data="takopi:cancel",
|
||||
sender_id=123,
|
||||
)
|
||||
|
||||
await _handle_callback_cancel(cfg, query, {})
|
||||
|
||||
assert len(transport.send_calls) == 0
|
||||
bot = cast(_FakeBot, cfg.bot)
|
||||
assert bot.callback_calls
|
||||
assert "nothing is currently running" in bot.callback_calls[-1]["text"].lower()
|
||||
|
||||
|
||||
def test_cancel_command_accepts_extra_text() -> None:
|
||||
assert _is_cancel_command("/cancel now") is True
|
||||
assert _is_cancel_command("/cancel@takopi please") is True
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from takopi.telegram import parse_incoming_update
|
||||
from takopi.telegram import (
|
||||
TelegramCallbackQuery,
|
||||
TelegramIncomingMessage,
|
||||
parse_incoming_update,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_incoming_update_maps_fields() -> None:
|
||||
@@ -15,6 +19,7 @@ def test_parse_incoming_update_maps_fields() -> None:
|
||||
|
||||
msg = parse_incoming_update(update, chat_id=123)
|
||||
assert msg is not None
|
||||
assert isinstance(msg, TelegramIncomingMessage)
|
||||
assert msg.transport == "telegram"
|
||||
assert msg.chat_id == 123
|
||||
assert msg.message_id == 10
|
||||
@@ -66,9 +71,34 @@ def test_parse_incoming_update_voice_message() -> None:
|
||||
|
||||
msg = parse_incoming_update(update, chat_id=123)
|
||||
assert msg is not None
|
||||
assert isinstance(msg, TelegramIncomingMessage)
|
||||
assert msg.text == ""
|
||||
assert msg.voice is not None
|
||||
assert msg.voice.file_id == "voice-id"
|
||||
assert msg.voice.mime_type == "audio/ogg"
|
||||
assert msg.voice.file_size == 1234
|
||||
assert msg.voice.duration == 3
|
||||
|
||||
|
||||
def test_parse_incoming_update_callback_query() -> None:
|
||||
update = {
|
||||
"update_id": 1,
|
||||
"callback_query": {
|
||||
"id": "cbq-1",
|
||||
"data": "takopi:cancel",
|
||||
"from": {"id": 321},
|
||||
"message": {
|
||||
"message_id": 55,
|
||||
"chat": {"id": 123},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msg = parse_incoming_update(update, chat_id=123)
|
||||
assert isinstance(msg, TelegramCallbackQuery)
|
||||
assert msg.transport == "telegram"
|
||||
assert msg.chat_id == 123
|
||||
assert msg.message_id == 55
|
||||
assert msg.callback_query_id == "cbq-1"
|
||||
assert msg.data == "takopi:cancel"
|
||||
assert msg.sender_id == 321
|
||||
|
||||
@@ -22,6 +22,7 @@ class _FakeBot:
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
replace_message_id: int | None = None,
|
||||
) -> dict:
|
||||
@@ -29,6 +30,7 @@ class _FakeBot:
|
||||
_ = disable_notification
|
||||
_ = entities
|
||||
_ = parse_mode
|
||||
_ = reply_markup
|
||||
_ = replace_message_id
|
||||
self.calls.append("send_message")
|
||||
return {"message_id": 1}
|
||||
@@ -40,6 +42,7 @@ class _FakeBot:
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict:
|
||||
@@ -47,6 +50,7 @@ class _FakeBot:
|
||||
_ = message_id
|
||||
_ = entities
|
||||
_ = parse_mode
|
||||
_ = reply_markup
|
||||
_ = wait
|
||||
self.calls.append("edit_message_text")
|
||||
self.edit_calls.append(text)
|
||||
@@ -106,6 +110,15 @@ class _FakeBot:
|
||||
async def get_me(self) -> dict | None:
|
||||
return {"id": 1}
|
||||
|
||||
async def answer_callback_query(
|
||||
self,
|
||||
callback_query_id: str,
|
||||
text: str | None = None,
|
||||
show_alert: bool | None = None,
|
||||
) -> bool:
|
||||
_ = callback_query_id, text, show_alert
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edits_coalesce_latest() -> None:
|
||||
@@ -123,6 +136,7 @@ async def test_edits_coalesce_latest() -> None:
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict:
|
||||
@@ -136,6 +150,7 @@ async def test_edits_coalesce_latest() -> None:
|
||||
text=text,
|
||||
entities=entities,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=reply_markup,
|
||||
wait=wait,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user