Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions WIDGETS.md
Original file line number Diff line number Diff line change
@@ -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:<token>:<day-index>` reads one day;
`s:<token>:<YYYYMMDD>:<start-hex>:<end-hex>:<mask-hex>` 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.
8 changes: 5 additions & 3 deletions src/when2gram/__main__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
172 changes: 172 additions & 0 deletions src/when2gram/bot/availability_widget.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<!doctype html><html lang="en"><meta charset="utf-8"><style>
:root{color-scheme:light dark;font:13px system-ui,-apple-system,sans-serif}
*{box-sizing:border-box}body{margin:0;padding:10px;background:var(--tg-theme-bg-color,#f7f9fc);color:var(--tg-theme-text-color,#192538)}
header{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:9px}
h2{font-size:16px;line-height:1.2;margin:0;max-width:85%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
small,.hint{color:var(--tg-theme-hint-color,#677485)}
small{font-size:11px}#count{white-space:nowrap}button{border:0;font:inherit;cursor:pointer;touch-action:manipulation}
nav,.actions{display:flex;gap:5px;align-items:center;justify-content:space-between;margin:8px 0}
nav button,.actions button{border-radius:8px;padding:9px 12px;background:var(--tg-theme-secondary-bg-color,#e8edf3);color:var(--tg-theme-text-color,#192538)}
nav span{font-weight:650;text-align:center;min-width:0}
button:disabled{color:var(--tg-theme-hint-color,#8793a0);cursor:default}
#grid{display:grid;grid-template-columns:44px repeat(4,minmax(0,1fr));gap:3px}
#grid span{display:flex;align-items:center;justify-content:center;color:var(--tg-theme-hint-color,#677485);font-size:11px;font-weight:650}
#grid button{height:28px;border-radius:5px;background:var(--tg-theme-secondary-bg-color,#e9edf3);color:var(--tg-theme-text-color,#192538);font-weight:650}
#grid .h1{background:#d8eee8;color:#285a51}#grid .h2{background:#99d4b9;color:#154f3a}
#grid .h3{background:#46ac83;color:#fff}
#grid button.anchor{outline:2px solid #f5a742;outline-offset:-2px}
#grid button.sel{background:var(--tg-theme-button-color,#388bd8);color:var(--tg-theme-button-text-color,#fff);box-shadow:inset 0 0 0 2px #fff7}
.actions{margin:10px 0 0}.actions button{flex:1}.actions #mode.active{box-shadow:inset 0 0 0 2px var(--tg-theme-button-color,#388bd8)}
.actions #save{background:var(--tg-theme-button-color,#388bd8);color:var(--tg-theme-button-text-color,#fff);font-weight:700}
#status{min-height:18px;margin-top:7px;font-size:11px}.note{margin-top:6px;text-align:center;font-size:11px}
</style><body>
<header><h2 id="title">Availability</h2><small id="count"></small></header>
<nav><button id="prev">‹ Day</button><span id="day"></span><button id="next">Day ›</button></nav>
<div id="grid" role="group" aria-label="Time slots"></div>
<nav><button id="early">‹ Earlier</button><span id="hours"></span><button id="later">Later ›</button></nav>
<div class="actions"><button id="mode">Range</button><button id="clear">Clear</button><button id="save">Save day</button></div>
<div id="status" class="hint" aria-live="polite"></div>
<div class="note hint">Tap quarter-hours to select or deselect</div>
<script>
(function(){
"use strict";
var T=window.TelegramWidget, demo={
t:"demo",title:"Project meeting",i:0,days:3,day:"2026-11-12",
start:540,end:1440,mask:"0",counts:Array(60).fill(0),responses:3,submitted:false
};
var initial;
try{initial=JSON.parse(T&&T.chatData||"null")}catch(e){}
if(!initial)initial=T?{
t:"",title:"Schedule unavailable",i:0,days:0,day:"—",
start:540,end:600,mask:"0",counts:[],responses:0,submitted:true
}:demo;
var v=initial,slots=(v.end-v.start)/15,idx=v.i,page=0,busy=false,dirty=false;
var bits=[],saved="",drafts={},range=false,anchor=null;
var $=function(id){return document.getElementById(id)};
var hexToBits=function(hex){
var a=[];hex=String(hex||"0");
for(var i=0;i<slots;i++)a.push(!!(parseInt(hex.charAt(hex.length-1-(i>>2))||"0",16)&(1<<(i&3))));
return a;
};
var bitsToHex=function(a){
var out="";for(var p=0;p<a.length;p+=4){
var n=0;for(var k=0;k<4;k++)if(a[p+k])n|=1<<k;
out=n.toString(16)+out;
}return out.replace(/^0+(?=.)/,"");
};
var key=function(){return "w2g:"+v.t+":"+v.day+":"+v.start+":"+v.end};
function persist(){
drafts[key()]=bitsToHex(bits);try{localStorage.setItem(key(),bitsToHex(bits))}catch(e){}
}
function forget(){
delete drafts[key()];try{localStorage.removeItem(key())}catch(e){}
}
function restore(){
var h=drafts[key()];if(h===undefined)try{h=localStorage.getItem(key())}catch(e){}
bits=hexToBits(h===null||h===undefined?saved:h);
dirty=h!==null&&h!==undefined&&h!==saved;
}
function time(slot){
var minute=v.start+slot*15;
return ("0"+Math.floor(minute/60)).slice(-2)+":"+("0"+minute%60).slice(-2);
}
function status(s){$("status").textContent=s||""}
function render(){
$("title").textContent=v.title||"Availability";
$("count").textContent=(v.responses||0)+" replied";
$("day").textContent=v.day+" · "+(idx+1)+"/"+v.days;
$("prev").disabled=busy||idx===0||!v.t;$("next").disabled=busy||idx===v.days-1||!v.t;
$("early").disabled=busy||page===0;
$("later").disabled=busy||(page+1)*32>=slots;
$("hours").textContent=time(page*32)+"–"+time(Math.min(slots,(page+1)*32));
$("save").disabled=busy||(!dirty&&v.submitted)||!v.t;$("clear").disabled=busy;
$("mode").disabled=busy;$("mode").className=range?"active":"";$("mode").setAttribute("aria-pressed",range);
var g=$("grid");g.textContent="";
var frag=document.createDocumentFragment();
["",":00",":15",":30",":45"].forEach(function(t){var s=document.createElement("span");s.textContent=t;frag.appendChild(s)});
var limit=Math.min(slots,(page+1)*32),responses=v.responses||0,counts=v.counts||[];
for(var row=page*32;row<limit;row+=4){
var label=document.createElement("span");label.textContent=time(row).slice(0,2);frag.appendChild(label);
for(var q=0;q<4&&row+q<limit;q++){
var slot=row+q,count=counts[slot]||0,btn=document.createElement("button");
var shade=responses?count/responses:0;
btn.className=(bits[slot]?"sel":shade>=.7?"h3":shade>=.4?"h2":shade>0?"h1":"")+(anchor===slot?" anchor":"");
btn.textContent=bits[slot]?"✓":count||"·";
btn.setAttribute("data-slot",slot);
btn.setAttribute("aria-label",time(slot)+" "+(bits[slot]?"selected":"not selected")+", "+count+" available");
btn.disabled=busy;frag.appendChild(btn);
}
}
g.appendChild(frag);
}
function setData(next){
v.day=next.day;v.i=next.i;v.days=next.days;v.mask=next.mask;
v.title=next.title||v.title;v.start=next.start;v.end=next.end;
slots=(v.end-v.start)/15;v.counts=next.counts;v.responses=next.responses;v.submitted=next.submitted;
idx=v.i;saved=v.mask;anchor=null;restore();page=0;
}
function endpoint(action,callback){
if(!T||!T.request){callback(null);return}
busy=true;render();status("Contacting bot…");
T.request(action).then(function(answer){
var data;try{data=JSON.parse(answer.result||"{}")}catch(e){data={}}
if(!data.ok)throw Error(data.error||"Request failed");
callback(data);
}).catch(function(err){status("Unable to sync: "+(err.code||err.message||"retry"));})
.finally(function(){busy=false;render()});
}
function gotoDay(delta){
if(busy||idx+delta<0||idx+delta>=v.days)return;
if(dirty)persist()
if(!T){idx+=delta;v.i=idx;v.day="Demo day "+(idx+1);saved="0";restore();page=0;render();return}
endpoint("v:"+v.t+":"+(idx+delta),function(data){setData(data);status(dirty?"Unsaved draft restored":"")});
}
$("grid").addEventListener("click",function(e){
var s=e.target.getAttribute("data-slot");
if(busy||s===null)return;
var n=Number(s);
if(range&&anchor===null){anchor=n;status("Select the end time");render();return}
if(range){
var select=!bits[anchor],lo=Math.min(anchor,n),hi=Math.max(anchor,n);
for(var i=lo;i<=hi;i++)bits[i]=select;
anchor=null;
}else bits[n]=!bits[n];
dirty=bitsToHex(bits)!==saved;
if(dirty)persist();else forget();
status(dirty?"Unsaved changes":"");render();
});
$("mode").onclick=function(){
range=!range;anchor=null;
status(range?"Tap start time, then end time":dirty?"Unsaved changes":"");
render();
};
$("prev").onclick=function(){gotoDay(-1)};
$("next").onclick=function(){gotoDay(1)};
$("early").onclick=function(){if(page>0){page--;render()}};
$("later").onclick=function(){if((page+1)*32<slots){page++;render()}};
$("clear").onclick=function(){
if(busy)return;bits.fill(false);dirty=bitsToHex(bits)!==saved;
if(dirty)persist();else forget();status(dirty?"Unsaved changes":"");render();
};
$("save").onclick=function(){
if(busy||(!dirty&&v.submitted))return;
if(anchor!==null){status("Choose the end time first");return}
var hex=bitsToHex(bits),current=idx;
if(!T){saved=hex;dirty=false;v.submitted=true;forget();status("Demo saved locally");render();return}
endpoint("s:"+v.t+":"+v.day.replace(/-/g,"")+":"+v.start.toString(16)+":"+v.end.toString(16)+":"+hex,function(data){
if(idx!==current)return;
saved=data.mask;v.counts=data.counts;v.responses=data.responses;v.submitted=true;
if(bitsToHex(bits)===saved){dirty=false;forget()}else{dirty=true;persist()}
status("Day saved ✓");if(T.HapticFeedback)T.HapticFeedback.notificationOccurred("success");
});
};
saved=v.mask;restore();render();
status(T?"":"Preview mode — no Telegram connection");
if(T){
if(T.ready)T.ready();
if(v.t&&!T.isPreview&&!T.isForward)endpoint("v:"+v.t+":"+idx,function(data){
setData(data);if(!dirty)status("");
});
}
})();
</script></body></html>
48 changes: 48 additions & 0 deletions src/when2gram/bot/routers/event_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Loading
Loading