Skip to content
Merged
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
69 changes: 69 additions & 0 deletions coding_bridge/channels/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions coding_bridge/channels/portal_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down Expand Up @@ -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";
Expand Down
132 changes: 132 additions & 0 deletions tests/test_channels_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading