From d995e56e9c5b723c9bbcd8748b66fbed3f64f6e9 Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sat, 4 Jul 2026 15:06:16 +0800 Subject: [PATCH] feat(portal): one-click Telegram allowlist from recent senders The Telegram editor's allowlist was manual numeric-id entry. Now a 'Load recent senders' button peeks at getUpdates and lists whoever recently messaged the bot; click a name to add its id to allowed_senders. Peek uses timeout=0 and NO offset, so it only reads and never acknowledges/drops updates for a running poller. It can't run concurrently with 'channels start' (Telegram allows one getUpdates consumer) -> that surfaces as a clear 409 'stop channels start / add IDs by hand' message. Token stays in the URL path, never logged. Backend telegram_recent_senders() + GET /api/telegram/senders; 5 new tests (parse/dedup/409/empty/route + static guard). Full suite 490 pass/1 skip. Playwright: button renders + click path handled (dead-token error state). Adversarial (Explore subagent): VERDICT CLEAN. --- coding_bridge/channels/portal.py | 69 ++++++++++++++ coding_bridge/channels/portal_html.py | 33 +++++++ tests/test_channels_portal.py | 132 ++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/coding_bridge/channels/portal.py b/coding_bridge/channels/portal.py index 0d55fff..d2ed6ab 100644 --- a/coding_bridge/channels/portal.py +++ b/coding_bridge/channels/portal.py @@ -484,6 +484,73 @@ def telegram_status(self, instance_id: str) -> dict[str, Any]: raise PortalError(502, "telegram rejected the bot token (check token_env/token_file)") raise PortalError(502, f"telegram getMe failed (code {code})") + def telegram_recent_senders(self, instance_id: str) -> list[dict[str, Any]]: + """Peek at recent inbound messages (getUpdates) and return distinct senders. + + Lets an operator allowlist "the person who just messaged my bot" instead + of hunting for a numeric id. Uses ``timeout=0`` (no long-poll) and never + advances the offset — it only *reads*. It still can't run concurrently + with ``channels start`` (Telegram allows a single getUpdates consumer); + that surfaces as a 409, which we translate into a clear message. The + token stays in the URL path and is never logged. + """ + inst = self._telegram_instance(instance_id) + try: + token = inst.resolve_token() + except ConfigError as exc: + raise PortalError(400, str(exc)) from None + url = f"{inst.api_base}/bot{token}/getUpdates" + try: + resp = self._client.request( + "POST", url, json={"timeout": 0, "limit": 100, "allowed_updates": ["message"]} + ) + except httpx.HTTPError as exc: + raise PortalError(502, f"telegram unreachable: {exc.__class__.__name__}") from None + try: + body = resp.json() + except ValueError: + raise PortalError(502, "telegram returned non-JSON") from None + if not (isinstance(body, dict) and body.get("ok")): + code = body.get("error_code") if isinstance(body, dict) else resp.status_code + if code == 409: + raise PortalError( + 409, + "Telegram is busy — stop `channels start` first to load recent " + "senders, or add IDs by hand.", + ) + if code == 401: + raise PortalError( + 502, "telegram rejected the bot token (check token_env/token_file)" + ) + raise PortalError(502, f"telegram getUpdates failed (code {code})") + updates = body.get("result") + if not isinstance(updates, list): + return [] + seen: dict[str, dict[str, Any]] = {} + for u in updates: + if not isinstance(u, dict): + continue + msg = u.get("message") + if not isinstance(msg, dict): + continue + frm = msg.get("from") + if not isinstance(frm, dict): + continue + uid = frm.get("id") + if not isinstance(uid, int): + continue + chat = msg.get("chat") if isinstance(msg.get("chat"), dict) else {} + uname = frm.get("username") if isinstance(frm.get("username"), str) else None + name = frm.get("first_name") or uname or str(uid) + seen[str(uid)] = { + "id": str(uid), + "username": uname, + "name": name if isinstance(name, str) else str(uid), + "chat_type": chat.get("type") if isinstance(chat.get("type"), str) else None, + } + # newest distinct sender first (updates arrive oldest→newest) + return list(reversed(list(seen.values()))) + def search_contacts(self, instance_id: str, query: str, limit: int) -> list[dict[str, Any]]: inst = self._instance(instance_id) contacts = self._contacts(inst) @@ -779,6 +846,8 @@ def _handle_api_get(self, path: str, query: dict[str, list[str]]) -> None: self._send_json(200, {"groups": service.groups(instance)}) elif path == "/api/telegram/status": self._send_json(200, service.telegram_status(instance)) + elif path == "/api/telegram/senders": + self._send_json(200, {"senders": service.telegram_recent_senders(instance)}) elif path == "/api/wechat/contacts": q = query.get("q", [""])[0] try: diff --git a/coding_bridge/channels/portal_html.py b/coding_bridge/channels/portal_html.py index 09f398c..4dd0fbe 100644 --- a/coding_bridge/channels/portal_html.py +++ b/coding_bridge/channels/portal_html.py @@ -480,6 +480,10 @@ senders.value=(it.allowed_senders||[]).join("\n"); senders.addEventListener("input",()=>{ it.allowed_senders=splitLines(senders.value); }); access.append(senders); + access.append(el("div",{class:"row",style:"margin-top:10px;gap:8px;align-items:center;flex-wrap:wrap"}, + el("button",{class:"btn ghost",style:"border:1px solid var(--border)",onclick:()=>loadRecentSenders()},"Load recent senders"), + el("span",{class:"hint"},"Message the bot first, then load and click a name to allowlist it (works while `channels start` is stopped)."))); + access.append(el("div",{id:"tgrecent",class:"grouplist",style:"margin-top:8px"})); app.append(access); app.append(behaviorCard(it,{dedup:true})); @@ -515,6 +519,35 @@ } } +async function loadRecentSenders(){ + const it=current(); if(!it||it.type!=="telegram") return; + const box=$("#tgrecent"); if(!box) return; + if(!it.token_resolvable){ box.replaceChildren(el("div",{class:"empty"},"set the bot token first, then Save")); return; } + box.replaceChildren(el("div",{class:"empty"},"loading…")); + try{ + const d=await api("/api/telegram/senders?instance="+encodeURIComponent(it.instance_id)); + box.innerHTML=""; + const list=d.senders||[]; + if(!list.length){ box.append(el("div",{class:"empty"},"no recent messages — send your bot a message, then click again")); return; } + list.forEach(sd=>{ + const chosen=(it.allowed_senders||[]).includes(sd.id); + box.append(el("div",{class:"grp",style:"cursor:pointer",onclick:()=>addTgSender(sd)}, + el("div",{class:"avatar sm"}, initials(sd.name)), + el("div",{style:"flex:1;min-width:0"}, + el("div",{class:"nm"}, sd.name+(sd.username?(" (@"+sd.username+")"):"")), + el("div",{class:"id"}, sd.id+(sd.chat_type&&sd.chat_type!=="private"?(" · "+sd.chat_type):""))), + chosen?el("div",{class:"pill"},"added"):el("div",{class:"pill",style:"color:var(--accent)"},"+ add"))); + }); + }catch(e){ box.innerHTML=""; box.append(el("div",{class:"empty"},String(e.message||"failed"))); } +} + +function addTgSender(sd){ + const it=current(); if(!it) return; + if(!(it.allowed_senders||[]).includes(sd.id)){ it.allowed_senders=(it.allowed_senders||[]).concat([sd.id]); } + const ta=$("#tgsenders"); if(ta) ta.value=it.allowed_senders.join("\n"); + loadRecentSenders(); +} + function setTrigger(free){ const it=current(); it.free_form=free; it.trigger_prefix = free ? "" : ($("#prefix").value||"/ask "); $("#tf-free").className=free?"on":""; $("#tf-prefix").className=free?"":"on"; diff --git a/tests/test_channels_portal.py b/tests/test_channels_portal.py index 4f7ad85..e12339c 100644 --- a/tests/test_channels_portal.py +++ b/tests/test_channels_portal.py @@ -809,3 +809,135 @@ def test_index_has_add_remove_channel_controls(): ): assert token in INDEX_HTML + +# --------------------------------------------------------------------------- # +# Telegram "recent senders" (getUpdates peek → allowlist candidates) +# --------------------------------------------------------------------------- # + + +def _tg_getupdates_transport(result, *, ok=True, error_code=409): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/getUpdates"): + if ok: + return httpx.Response(200, json={"ok": True, "result": result}) + return httpx.Response( + 200, json={"ok": False, "error_code": error_code, "description": "Conflict"} + ) + return httpx.Response(404, json={}) + + return httpx.MockTransport(handler) + + +def _seed_tg(tmp_path): + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(telegram=(_tg_inst(),))), encoding="utf-8" + ) + + +def test_telegram_recent_senders_parses_and_dedups(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + _seed_tg(tmp_path) + updates = [ + { + "update_id": 1, + "message": { + "from": {"id": 111, "username": "alice", "first_name": "Alice"}, + "chat": {"id": 111, "type": "private"}, + "text": "/ask hi", + }, + }, + { + "update_id": 2, + "message": { + "from": {"id": 222, "first_name": "Bob"}, + "chat": {"id": -100, "type": "supergroup"}, + "text": "/ask yo", + }, + }, + { # same sender again → deduped + "update_id": 3, + "message": { + "from": {"id": 111, "username": "alice", "first_name": "Alice"}, + "chat": {"id": 111, "type": "private"}, + "text": "again", + }, + }, + {"update_id": 4, "edited_message": {"from": {"id": 999}, "text": "x"}}, # not a message + ] + svc = PortalService(s, client=httpx.Client(transport=_tg_getupdates_transport(updates))) + senders = svc.telegram_recent_senders("tg1") + assert {x["id"] for x in senders} == {"111", "222"} # deduped, edited ignored + by_id = {x["id"]: x for x in senders} + assert by_id["111"]["username"] == "alice" and by_id["111"]["name"] == "Alice" + assert by_id["222"]["username"] is None and by_id["222"]["chat_type"] == "supergroup" + svc.close() + + +def test_telegram_recent_senders_409_is_busy(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + _seed_tg(tmp_path) + svc = PortalService( + s, client=httpx.Client(transport=_tg_getupdates_transport(None, ok=False, error_code=409)) + ) + with pytest.raises(PortalError) as ei: + svc.telegram_recent_senders("tg1") + assert ei.value.status == 409 and "channels start" in ei.value.message + svc.close() + + +def test_telegram_recent_senders_empty(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + _seed_tg(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getupdates_transport([]))) + assert svc.telegram_recent_senders("tg1") == [] + svc.close() + + +def test_http_telegram_senders_route(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + _seed_tg(tmp_path) + updates = [ + { + "update_id": 1, + "message": { + "from": {"id": 111, "username": "alice", "first_name": "Alice"}, + "chat": {"id": 111, "type": "private"}, + "text": "hi", + }, + } + ] + svc = PortalService(s, client=httpx.Client(transport=_tg_getupdates_transport(updates))) + token = "tk" + port = _free_port() + httpd = ThreadingHTTPServer(("127.0.0.1", port), _make_handler(svc, token, port)) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + r = httpx.get( + f"http://127.0.0.1:{port}/api/telegram/senders?instance=tg1", + headers={"X-Portal-Token": token}, + timeout=5, + ) + assert r.status_code == 200 + assert r.json()["senders"][0]["username"] == "alice" + finally: + httpd.shutdown() + httpd.server_close() + svc.close() + + +def test_index_has_recent_senders_control(): + from coding_bridge.channels.portal_html import INDEX_HTML + + for token in ( + "function loadRecentSenders", + "function addTgSender", + "Load recent senders", + "/api/telegram/senders", + ): + assert token in INDEX_HTML +