diff --git a/.env.example b/.env.example index f140a4b..6d96b82 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ BOT_TOKEN=replace-me DATABASE_URL=sqlite+aiosqlite:////data/when2gram.db LOG_LEVEL=INFO + +# Experimental Bot API 10.4 private-chat availability widget +ENABLE_WIDGETS=false diff --git a/WIDGETS.md b/WIDGETS.md new file mode 100644 index 0000000..92f1be4 --- /dev/null +++ b/WIDGETS.md @@ -0,0 +1,70 @@ +# When2Gram HTML widget experiment (Bot API 10.4) + +This integration adds a compact **private-chat availability editor** without replacing +the existing inline invitations, event management, database, or keyboard fallback. + +## Enable in a local checkout + +This PR includes the integrated source files; no patch script is necessary. +Install dependencies and run checks from the repository root: + +```bash +uv sync --locked --all-groups +uv run ruff check . +uv run pytest -p no:warnings -o addopts='' +``` + +Set `ENABLE_WIDGETS=true` in `.env` (default: **off**). Start the existing bot +as before. Opening a When2Gram event invitation now sends an HTML widget +in the bot's private chat. The widget also offers a **Classic grid** button for the old keyboard flow. +The event's original inline invitation still links +participants into the private chat, because Telegram currently disallows +widget inline results in production. + +Never launch a second poller on the same bot token. `WidgetDispatcher` extends the +existing aiogram poller; it requests `widget_query` in `allowed_updates` and +dispatches that update from the Pydantic `Update.model_extra`, until aiogram +gains native Bot API 10.4 support. + +## Transport + +- HTML: 9,012 UTF-8 bytes; zlib level 9: 3,596 bytes. +- Enforced budgets: 14,600 bytes (optional network target); Telegram: + 32,768 uncompressed / 4,096 zlib-compressed. +- Widget `chat_data`: ≤1,024 UTF-8 bytes, generated by the server. +- Client requests: `v::` reads one day; + `s:::::` saves one day. + The latter binds the submission to the exact event date and time window, + preventing a stale widget from writing to an edited schedule. +- Each request must be ≤64 bytes; server responses ≤1,024 bytes. +- Saving uses `save_submitted_availability`, preserving response counts and + notification semantics. Each day is saved independently, by choice. +- The widget is fixed HTML; data is fetched from the bot as needed. Drafts + live in message-local `localStorage` until saved, isolated by event, date, + and event time window. + +## Important limitations + +- Bot API 10.4 production widgets currently can be sent only in private chats + and are protected from forwarding. **Group invitations remain text and buttons**. +- The HTML widget is not an internet-hosted page and cannot use external + JS/CSS/fonts. It has no `fetch`; all writes go through `TelegramWidget.request`. +- Up to 8 hours (32 quarter-hour cells) are shown per page to stay within + the 600 logical-pixel height limit. At 24 hours, there are three time pages. +- This is a reviewable prototype, not a live-deployed release. Widget runtime + behavior still needs checking on the current Telegram iOS, Android, desktop, + and web clients. +- The compatibility dispatcher and method classes should be removed when + aiogram supports these new Bot API update and method types natively. +- Publishing, merging, tagging, and live bot deployment were not performed. + +## Browser preview + +Open `src/when2gram/bot/availability_widget.html` locally in a browser. +When `window.TelegramWidget` is absent it displays example event data and +treats Save as a demo-only action. + +## Rollback + +Set `ENABLE_WIDGETS=false` and restart the single poller. Existing events +and saved availability remain in SQLite. No migration is required. \ No newline at end of file diff --git a/src/when2gram/__main__.py b/src/when2gram/__main__.py index 68c1631..abee17d 100644 --- a/src/when2gram/__main__.py +++ b/src/when2gram/__main__.py @@ -1,13 +1,14 @@ import asyncio import logging -from aiogram import Bot, Dispatcher +from aiogram import Bot from aiogram.exceptions import TelegramAPIError from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import BotCommand from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from when2gram.bot.routers import event_creation_router, inline_router, start_router +from when2gram.bot.widgets import WidgetDispatcher from when2gram.config import get_settings from when2gram.db import ( claim_due_target_notifications, @@ -47,7 +48,7 @@ async def main() -> None: BotCommand(command="events", description="Manage your events"), ] ) - dispatcher = Dispatcher(storage=MemoryStorage()) + dispatcher = WidgetDispatcher(storage=MemoryStorage()) dispatcher["session_factory"] = session_factory dispatcher.include_router(event_creation_router) dispatcher.include_router(inline_router) @@ -67,7 +68,8 @@ async def main() -> None: except TelegramAPIError: logging.exception("Unable to send overdue response-target notification") cleanup_task = asyncio.create_task(_delete_expired_events_periodically(session_factory)) - await dispatcher.start_polling(bot) + allowed = sorted(set(dispatcher.resolve_used_update_types()) | {"widget_query"}) + await dispatcher.start_polling(bot, allowed_updates=allowed) finally: if cleanup_task is not None: cleanup_task.cancel() diff --git a/src/when2gram/bot/availability_widget.html b/src/when2gram/bot/availability_widget.html new file mode 100644 index 0000000..79e4d15 --- /dev/null +++ b/src/when2gram/bot/availability_widget.html @@ -0,0 +1,172 @@ + +

Availability

+ +
+ +
+
+
Tap quarter-hours to select or deselect
+ \ No newline at end of file diff --git a/src/when2gram/bot/routers/event_creation.py b/src/when2gram/bot/routers/event_creation.py index 25beed0..a333a73 100644 --- a/src/when2gram/bot/routers/event_creation.py +++ b/src/when2gram/bot/routers/event_creation.py @@ -31,6 +31,8 @@ time_range_keyboard, ) from when2gram.bot.routers.inline import refresh_inline_invitations +from when2gram.bot.widgets import send_availability_widget +from when2gram.config import get_settings from when2gram.db.models import Event from when2gram.db.repositories import ( create_event, @@ -598,6 +600,7 @@ async def _start_event_availability( is_creating: bool, replace_message_id: int | None = None, bot: Bot | None = None, + use_widget: bool = True, ) -> None: selected_days = _read_selected_days(await state.get_data()) if not selected_days: @@ -607,6 +610,22 @@ async def _start_event_availability( await message.answer("That invitation is no longer available.") return user_id = message.chat.id + widget_bot = bot if bot is not None else message.bot + if ( + get_settings().enable_widgets + and use_widget + and message.chat.type == ChatType.PRIVATE + and widget_bot is not None + ): + if await send_availability_widget( + widget_bot, + session_factory, + event, + user_id, + organizer=is_organizer, + ): + await state.clear() + return async with session_factory() as session: masks = await get_user_availability_masks( session, @@ -1878,3 +1897,32 @@ def _event_preview_text( f"{title}\n{format_selected_days(days)}\n" f"{start_hour:02d}:00 - {end_hour:02d}:00\n{notification}" ) + + +@router.callback_query(F.data.regexp(r"^event:classic:[^:]+$")) +async def classic_event_availability( + callback: CallbackQuery, + state: FSMContext, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + if callback.message is None or callback.from_user is None or callback.data is None: + await callback.answer() + return + token = callback.data.removeprefix("event:classic:") + async with session_factory() as session: + event = await get_event_by_token(session, token) + if event is None or event_is_expired(event): + await callback.answer("Invitation expired.", show_alert=True) + return + await state.clear() + await _start_event_availability( + callback.message, + state, + event, + session_factory, + replace=False, + is_organizer=callback.from_user.id == event.organizer_id, + is_creating=False, + use_widget=False, + ) + await callback.answer() diff --git a/src/when2gram/bot/widgets.py b/src/when2gram/bot/widgets.py new file mode 100644 index 0000000..8ec5727 --- /dev/null +++ b/src/when2gram/bot/widgets.py @@ -0,0 +1,299 @@ +"""Bot API 10.4 widget transport and availability handling for aiogram 3.31. + +This extension consumes widget_query in aiogram's existing long poller. +Remove it when aiogram adds first-class support for the new update. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date +from functools import lru_cache +from importlib.resources import files +from typing import Any, ClassVar + +from aiogram import Bot, Dispatcher +from aiogram.exceptions import TelegramBadRequest +from aiogram.methods.base import TelegramMethod +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from when2gram.bot.keyboards.event_creation import ( + event_preview_keyboard, + event_responses_keyboard, +) +from when2gram.bot.routers.inline import refresh_inline_invitations +from when2gram.db.models import Event, Response +from when2gram.db.repositories import ( + event_is_expired, + get_event_availability_masks, + get_event_by_token, + get_user_availability_masks, + save_submitted_availability, + upsert_user, +) +from when2gram.domain.availability import aggregate_counts + +log = logging.getLogger(__name__) + + +class SendWidget(TelegramMethod[Message]): + __returning__: ClassVar[type[Message]] = Message + __api_method__: ClassVar[str] = "sendWidget" + + chat_id: int + html: str + chat_data: str + allow_queries: bool = True + reply_markup: InlineKeyboardMarkup | None = None + + +class AnswerWidgetQuery(TelegramMethod[bool]): + __returning__: ClassVar[type[bool]] = bool + __api_method__: ClassVar[str] = "answerWidgetQuery" + + widget_query_id: str + data: str + + +def _json(data: dict[str, Any]) -> str: + return json.dumps(data, separators=(",", ":"), ensure_ascii=False) + + +def _future_days(event: Event) -> list[date]: + return [event_day.day for event_day in event.days if event_day.day >= date.today()] + + +async def _day_payload( + session: AsyncSession, + event: Event, + user_id: int, + index: int, + *, + initial: bool = False, +) -> dict[str, Any]: + days = _future_days(event) + if not 0 <= index < len(days): + raise ValueError("This event day is no longer available") + day = days[index] + mask = ( + await get_user_availability_masks( + session, event_id=event.id, user_id=user_id, days=[day] + ) + )[0] + response = await session.get(Response, (event.id, user_id)) + submitted = bool(response and response.submitted_at is not None) + respondents, masks = await get_event_availability_masks(session, event.id) + event_day_index = next(i for i, event_day in enumerate(event.days) if event_day.day == day) + counts = aggregate_counts(masks[event_day_index]) + slot_count = (event.end_minute - event.start_minute) // 15 + result: dict[str, Any] = { + "ok": 1, + "i": index, + "days": len(days), + "day": day.isoformat(), + "mask": format(mask, "x"), + "counts": counts[:slot_count], + "responses": respondents, + "submitted": submitted, + } + result.update(title=event.title[:72], start=event.start_minute, end=event.end_minute) + if initial: + result["t"] = event.token + return result + + +@lru_cache(maxsize=1) +def _widget_html() -> str: + return files("when2gram.bot").joinpath("availability_widget.html").read_text("utf-8") + + +async def send_availability_widget( + bot: Bot, + session_factory: async_sessionmaker[AsyncSession], + event: Event, + user_id: int, + *, + organizer: bool, +) -> bool: + """Post a private-chat widget. Return False for known API rejection.""" + if not _future_days(event) or event_is_expired(event): + return False + async with session_factory() as session: + payload = await _day_payload(session, event, user_id, 0, initial=True) + serialized = _json(payload) + if len(serialized.encode("utf-8")) > 1024: + # A huge title and large respondent counts can exceed chat_data limits. + payload["title"] = event.title[:24] + payload["counts"] = [] + serialized = _json(payload) + if len(serialized.encode("utf-8")) > 1024: + raise ValueError("Widget chat_data exceeds Telegram's limit") + html = _widget_html() + markup = ( + event_preview_keyboard(event.token) + if organizer + else event_responses_keyboard(event.token) + ) + markup.inline_keyboard.append( + [InlineKeyboardButton(text="Classic grid", callback_data=f"event:classic:{event.token}")] + ) + try: + await bot( + SendWidget( + chat_id=user_id, + html=html, + chat_data=serialized, + reply_markup=markup, + ) + ) + except TelegramBadRequest as exc: + log.warning("Widget rejected by Telegram; using keyboard: %s", exc) + return False + return True + + +async def handle_widget_query( + query: dict[str, Any], + bot: Bot, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Verify sender and event on every request; never trust the HTML state.""" + query_id = query.get("id") + if not isinstance(query_id, str): + return + + answer: dict[str, Any] = {"ok": 0, "error": "Invalid request"} + event_id: int | None = None + organizer_id: int | None = None + title = "" + target_reached = False + user = query.get("from") + chat = query.get("chat") + data = query.get("data") + try: + if not isinstance(user, dict) or not isinstance(chat, dict): + raise ValueError("Private chat required") + user_id = user.get("id") + if ( + not isinstance(user_id, int) + or chat.get("id") != user_id + or chat.get("type") != "private" + ): + raise ValueError("Private chat required") + kind = query.get("type") + if kind is not None and ( + not isinstance(kind, dict) or kind.get("type") != "data" + ): + raise ValueError("Unsupported query type") + if not isinstance(data, str) or len(data.encode("utf-8")) > 64: + raise ValueError("Invalid action") + parts = data.split(":") + action = parts[0] + if not ( + (action == "v" and len(parts) == 3) + or (action == "s" and len(parts) == 6) + ): + raise ValueError("Invalid action") + token = parts[1] + if not token: + raise ValueError("Invalid event") + + if action == "v": + index_text = parts[2] + if not index_text.isascii() or not index_text.isdigit() or len(index_text) > 4: + raise ValueError("Invalid day") + index = int(index_text) + else: + date_text, start_hex, end_hex, mask_hex = parts[2:] + if len(date_text) != 8 or not date_text.isascii() or not date_text.isdigit(): + raise ValueError("Invalid date") + if any(not chunk or len(chunk) > 24 for chunk in (start_hex, end_hex, mask_hex)): + raise ValueError("Invalid availability") + try: + requested_day = date.fromisoformat( + f"{date_text[:4]}-{date_text[4:6]}-{date_text[6:]}" + ) + start_minute, end_minute, mask = ( + int(start_hex, 16), + int(end_hex, 16), + int(mask_hex, 16), + ) + except ValueError as exc: + raise ValueError("Invalid availability") from exc + + async with session_factory() as session, session.begin(): + event = await get_event_by_token(session, token) + if event is None or event_is_expired(event): + raise ValueError("Invitation expired") + days = _future_days(event) + if action == "s": + if ( + requested_day not in days + or (start_minute, end_minute) != (event.start_minute, event.end_minute) + ): + raise ValueError("Schedule changed; reopen the invitation") + index = days.index(requested_day) + if mask < 0 or mask >> ((event.end_minute - event.start_minute) // 15): + raise ValueError("Availability outside event time") + if not 0 <= index < len(days): + raise ValueError("Event day unavailable") + if action == "s": + await upsert_user( + session, + telegram_id=user_id, + username=user.get("username"), + first_name=user.get("first_name") or "Participant", + ) + result = await save_submitted_availability( + session, + event_id=event.id, + user_id=user_id, + days=[days[index]], + masks=[mask], + ) + event_id = event.id + organizer_id = event.organizer_id + title = event.title + target_reached = result.target_reached + answer = await _day_payload(session, event, user_id, index) + except ValueError as exc: + answer = {"ok": 0, "error": str(exc)[:100]} + except Exception: + log.exception("Widget query failed") + answer = {"ok": 0, "error": "Temporary server error"} + + serialized = _json(answer) + if len(serialized.encode("utf-8")) > 1024: + answer["counts"] = [] + serialized = _json(answer) + try: + await bot(AnswerWidgetQuery(widget_query_id=query_id, data=serialized)) + except Exception: + log.exception("Unable to answer widget query") + if event_id is not None: + try: + await refresh_inline_invitations(bot, session_factory, event_id) + if target_reached and organizer_id is not None: + await bot.send_message( + organizer_id, f"{title} reached {answer['responses']} replies." + ) + except Exception: + log.exception("Widget availability saved, but notification failed") + + +class WidgetDispatcher(Dispatcher): + """Dispatch widget updates before aiogram's 10.3 update-type switch.""" + + async def _listen_update(self, update: Update, **kwargs: Any) -> Any: + extra = update.model_extra or {} + query = extra.get("widget_query") + if isinstance(query, dict): + await handle_widget_query( + query, + bot=kwargs["bot"], + session_factory=kwargs["session_factory"], + ) + return True + return await super()._listen_update(update, **kwargs) \ No newline at end of file diff --git a/src/when2gram/config.py b/src/when2gram/config.py index 442207d..5c97ef2 100644 --- a/src/when2gram/config.py +++ b/src/when2gram/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): alias="DATABASE_URL", ) log_level: str = Field(default="INFO", alias="LOG_LEVEL") + enable_widgets: bool = Field(default=False, alias="ENABLE_WIDGETS") model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..9270fbc --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,182 @@ +"""Regression checks for the Bot API 10.4 compatibility shim.""" + +import json +import zlib +from datetime import date, timedelta +from importlib.resources import files +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiogram import Bot +from aiogram.types import Update + +from when2gram.bot import widgets +from when2gram.db.models import Event, EventDay +from when2gram.db.repositories import SubmissionResult + + +def test_html_payload_fits_transport_and_latency_budgets() -> None: + html = files("when2gram.bot").joinpath("availability_widget.html").read_bytes() + assert len(html) <= 14_600 + assert len(html) <= 32_768 + assert len(zlib.compress(html, level=9)) <= 4096 + assert b"TelegramWidget.request" not in html # calls go through T.request + assert b"T.request(action)" in html + + +@pytest.mark.asyncio +async def test_widget_update_reaches_handler_in_existing_dispatcher(monkeypatch) -> None: + capture = AsyncMock() + monkeypatch.setattr(widgets, "handle_widget_query", capture) + dispatcher = widgets.WidgetDispatcher() + query = { + "id": "widget-1", + "from": {"id": 42, "is_bot": False, "first_name": "Ada"}, + "chat": {"id": 42, "type": "private"}, + "data": "v:opaque:0", + "type": {"type": "data"}, + "chat_instance": "private", + } + async with Bot(token="123456:testing") as bot: + update = Update.model_validate( + {"update_id": 1, "widget_query": query}, context={"bot": bot} + ) + await dispatcher.feed_update(bot, update, session_factory="sessions") + capture.assert_awaited_once() + assert capture.await_args.args == (query,) + assert capture.await_args.kwargs["session_factory"] == "sessions" + + +@pytest.mark.asyncio +async def test_old_schedule_cannot_write_availability(monkeypatch) -> None: + future = date.today() + timedelta(days=1) + event = Event( + id=10, + token="opaque", + title="Review", + organizer_id=42, + start_minute=600, + end_minute=1200, + days=[EventDay(day=future)], + ) + monkeypatch.setattr(widgets, "get_event_by_token", AsyncMock(return_value=event)) + monkeypatch.setattr(widgets, "event_is_expired", lambda _: False) + save = AsyncMock() + monkeypatch.setattr(widgets, "save_submitted_availability", save) + + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=None) + session.begin.return_value = session + factory = MagicMock(return_value=session) + bot = AsyncMock() + request = { + "id": "widget-2", + "from": {"id": 42, "username": "ada", "first_name": "Ada"}, + "chat": {"id": 42, "type": "private"}, + # 540 = 0x21c: a stale widget's old schedule. + "data": f"s:opaque:{future:%Y%m%d}:21c:4b0:1", + } + await widgets.handle_widget_query(request, bot, factory) + save.assert_not_awaited() + method = bot.await_args.args[0] + assert isinstance(method, widgets.AnswerWidgetQuery) + answer = json.loads(method.data) + assert answer["ok"] == 0 + assert "Schedule changed" in answer["error"] + + +@pytest.mark.asyncio +async def test_widget_rejects_queries_from_other_chats() -> None: + bot = AsyncMock() + request = { + "id": "widget-3", + "from": {"id": 42}, + "chat": {"id": -100123, "type": "supergroup"}, + "data": "v:opaque:0", + } + await widgets.handle_widget_query(request, bot, None) + answer = json.loads(bot.await_args.args[0].data) + assert answer["ok"] == 0 + assert "Private chat" in answer["error"] + + +@pytest.mark.asyncio +async def test_payload_counts_and_hex_mask(monkeypatch) -> None: + future = date.today() + timedelta(days=1) + event = Event( + id=10, + token="opaque", + title="Review", + organizer_id=42, + start_minute=9 * 60, + end_minute=10 * 60, + days=[EventDay(day=future)], + ) + monkeypatch.setattr(widgets, "get_user_availability_masks", AsyncMock(return_value=[3])) + monkeypatch.setattr( + widgets, "get_event_availability_masks", AsyncMock(return_value=(2, [[3, 2]])) + ) + session = AsyncMock() + session.get.return_value = None + payload = await widgets._day_payload(session, event, 42, 0, initial=True) + assert payload["mask"] == "3" + assert payload["counts"] == [1, 2, 0, 0] + assert payload["responses"] == 2 + assert payload["t"] == "opaque" + + +@pytest.mark.asyncio +async def test_widget_accepts_explicit_empty_availability(monkeypatch) -> None: + future = date.today() + timedelta(days=1) + event = Event( + id=10, + token="opaque", + title="Review", + organizer_id=42, + start_minute=540, + end_minute=600, + days=[EventDay(day=future)], + ) + monkeypatch.setattr(widgets, "get_event_by_token", AsyncMock(return_value=event)) + monkeypatch.setattr(widgets, "event_is_expired", lambda _: False) + monkeypatch.setattr(widgets, "upsert_user", AsyncMock()) + monkeypatch.setattr(widgets, "refresh_inline_invitations", AsyncMock()) + save = AsyncMock(return_value=SubmissionResult(response_count=1, target_reached=False)) + monkeypatch.setattr(widgets, "save_submitted_availability", save) + monkeypatch.setattr( + widgets, + "_day_payload", + AsyncMock( + return_value={ + "ok": 1, + "i": 0, + "days": 1, + "day": future.isoformat(), + "mask": "0", + "counts": [0] * 4, + "responses": 1, + "submitted": True, + "title": "Review", + "start": 540, + "end": 600, + } + ), + ) + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=None) + session.begin.return_value = session + bot = AsyncMock() + await widgets.handle_widget_query( + { + "id": "widget-4", + "from": {"id": 42, "first_name": "Ada"}, + "chat": {"id": 42, "type": "private"}, + "data": f"s:opaque:{future:%Y%m%d}:21c:258:0", + }, + bot, + MagicMock(return_value=session), + ) + assert save.await_args.kwargs["masks"] == [0] + assert json.loads(bot.await_args.args[0].data)["ok"] == 1 \ No newline at end of file