diff --git a/coding_bridge/channels/portal.py b/coding_bridge/channels/portal.py index 59ebf64..0d55fff 100644 --- a/coding_bridge/channels/portal.py +++ b/coding_bridge/channels/portal.py @@ -1,14 +1,16 @@ """Local web portal for editing ``channels.toml`` — ``coding-bridge channels portal``. -Editing the WeChat channel config by hand is painful: you have to know each -admin's raw ``wxid`` and each group's ``…@chatroom`` id. This module serves a -small **localhost-only** web UI that talks to the WeChat gateway on your behalf -(contacts, groups, the bot account) so you can pick admins/groups from a -searchable, avatar-annotated list and flip the trigger mode, then writes the -result back to ``channels.toml``. - -Security model (a config UI that can rewrite local config + reach a gateway -token must be defensive): +Editing channel config by hand is painful: WeChat wants each admin's raw +``wxid`` and each group's ``…@chatroom`` id; Telegram wants numeric user/chat +ids. This module serves a small **localhost-only** web UI that lists every +configured channel (WeChat + Telegram) and opens a type-appropriate editor — +for WeChat it talks to the gateway so you can pick admins/groups from a +searchable, avatar-annotated list; for Telegram it verifies the bot token via +``getMe`` and takes id allowlists directly — then writes the result back to +``channels.toml``. + +Security model (a config UI that can rewrite local config + reach channel +tokens must be defensive): * **Binds 127.0.0.1 only** — never a public interface. ``serve()`` hard-codes it. * **Host-header allowlist** — every request's ``Host`` must be loopback, which @@ -17,8 +19,8 @@ in the served page; every ``/api`` call must present it. A drive-by page on another origin can't read our HTML or our responses (same-origin policy), so it can't learn the token and can't drive the API. -* **The gateway token never reaches the browser** — it's resolved server-side - from ``token_env`` / ``token_file`` and only used for outbound gateway calls. +* **Channel tokens never reach the browser** — they're resolved server-side + from ``token_env`` / ``token_file`` and only used for outbound channel calls. """ from __future__ import annotations @@ -43,6 +45,7 @@ from .config import ( ChannelsConfig, ConfigError, + TelegramInstanceConfig, WeChatInstanceConfig, load_channels_config, parse_channels_config, @@ -106,6 +109,8 @@ def _dump_instance(inst: WeChatInstanceConfig) -> str: if inst.token_file: lines.append(f"token_file = {_toml_str(inst.token_file)}") lines.append(f"enabled = {'true' if inst.enabled else 'false'}") + if inst.require_approval: + lines.append("require_approval = true") if inst.default_provider: lines.append(f"default_provider = {_toml_str(inst.default_provider)}") lines.append(f"trigger_prefix = {_toml_str(inst.trigger_prefix)}") @@ -119,6 +124,33 @@ def _dump_instance(inst: WeChatInstanceConfig) -> str: return "\n".join(lines) +def _dump_telegram_instance(inst: TelegramInstanceConfig) -> str: + lines = [ + "[[channels.telegram]]", + f"instance_id = {_toml_str(inst.instance_id)}", + ] + if inst.token_env: + lines.append(f"token_env = {_toml_str(inst.token_env)}") + if inst.token_file: + lines.append(f"token_file = {_toml_str(inst.token_file)}") + # Only write api_base when it deviates from the default, to keep files clean. + if inst.api_base and inst.api_base != "https://api.telegram.org": + lines.append(f"api_base = {_toml_str(inst.api_base)}") + lines.append(f"enabled = {'true' if inst.enabled else 'false'}") + if inst.require_approval: + lines.append("require_approval = true") + if inst.default_provider: + lines.append(f"default_provider = {_toml_str(inst.default_provider)}") + lines.append(f"trigger_prefix = {_toml_str(inst.trigger_prefix)}") + senders = ", ".join(_toml_str(s) for s in inst.allowed_senders) + lines.append(f"allowed_senders = [{senders}]") + groups = ", ".join(_toml_str(s) for s in inst.allowed_groups) + lines.append(f"allowed_groups = [{groups}]") + lines.append(f"rate_limit_per_min = {inst.rate_limit_per_min}") + lines.append(f"dedup_window_seconds = {inst.dedup_window_seconds!r}") + return "\n".join(lines) + + def dump_channels_toml(config: ChannelsConfig) -> str: """Render a :class:`ChannelsConfig` back to ``channels.toml`` text.""" header = ( @@ -126,9 +158,11 @@ def dump_channels_toml(config: ChannelsConfig) -> str: "# Managed by `coding-bridge channels portal` — hand edits are preserved\n" "# in spirit but reformatted on the next portal save.\n" ) - if not config.wechat: + if not config.wechat and not config.telegram: return header - body = "\n\n".join(_dump_instance(inst) for inst in config.wechat) + blocks = [_dump_instance(inst) for inst in config.wechat] + blocks += [_dump_telegram_instance(inst) for inst in config.telegram] + body = "\n\n".join(blocks) return f"{header}\n{body}\n" @@ -163,11 +197,41 @@ def _instance_public(inst: WeChatInstanceConfig, *, resolvable: bool) -> dict[st else: token_source = {"kind": "none", "ref": None} return { + "type": "wechat", "instance_id": inst.instance_id, "base_url": inst.base_url, "token_source": token_source, "token_resolvable": resolvable, "enabled": inst.enabled, + "require_approval": inst.require_approval, + "default_provider": inst.default_provider or "claude", + "trigger_prefix": inst.trigger_prefix, + "free_form": inst.trigger_prefix == "", + "allowed_senders": list(inst.allowed_senders), + "allowed_groups": list(inst.allowed_groups), + "rate_limit_per_min": inst.rate_limit_per_min, + "dedup_window_seconds": inst.dedup_window_seconds, + } + + +def _telegram_instance_public( + inst: TelegramInstanceConfig, *, resolvable: bool +) -> dict[str, Any]: + """JSON view of one Telegram instance — never includes the token value.""" + if inst.token_env: + token_source = {"kind": "env", "ref": inst.token_env} + elif inst.token_file: + token_source = {"kind": "file", "ref": inst.token_file} + else: + token_source = {"kind": "none", "ref": None} + return { + "type": "telegram", + "instance_id": inst.instance_id, + "api_base": inst.api_base, + "token_source": token_source, + "token_resolvable": resolvable, + "enabled": inst.enabled, + "require_approval": inst.require_approval, "default_provider": inst.default_provider or "claude", "trigger_prefix": inst.trigger_prefix, "free_form": inst.trigger_prefix == "", @@ -221,9 +285,15 @@ def public_config(self) -> dict[str, Any]: _instance_public(inst, resolvable=self._token_resolvable(inst)) for inst in cfg.wechat ] + instances += [ + _telegram_instance_public(inst, resolvable=self._token_resolvable(inst)) + for inst in cfg.telegram + ] return {"config_path": str(self._path), "instances": instances} - def _token_resolvable(self, inst: WeChatInstanceConfig) -> bool: + def _token_resolvable( + self, inst: WeChatInstanceConfig | TelegramInstanceConfig + ) -> bool: try: inst.resolve_token() return True @@ -236,13 +306,22 @@ def save(self, instances: list[dict[str, Any]]) -> dict[str, Any]: raise PortalError(400, "instances must be a list") # Round-trip through the real loader so the portal can never write a # config the daemon would then refuse to start with. - blocks: list[dict[str, Any]] = [] + wechat_blocks: list[dict[str, Any]] = [] + telegram_blocks: list[dict[str, Any]] = [] for item in instances: if not isinstance(item, dict): raise PortalError(400, "each instance must be an object") - blocks.append(_instance_from_payload(item)) + kind = item.get("type", "wechat") + if kind == "telegram": + telegram_blocks.append(_telegram_instance_from_payload(item)) + elif kind == "wechat": + wechat_blocks.append(_instance_from_payload(item)) + else: + raise PortalError(400, f"unknown instance type {kind!r}") try: - cfg = parse_channels_config({"channels": {"wechat": blocks}}) + cfg = parse_channels_config( + {"channels": {"wechat": wechat_blocks, "telegram": telegram_blocks}} + ) except ConfigError as exc: raise PortalError(400, str(exc)) from None try: @@ -260,6 +339,12 @@ def _instance(self, instance_id: str) -> WeChatInstanceConfig: return inst raise PortalError(404, f"no such instance {instance_id!r}") + def _telegram_instance(self, instance_id: str) -> TelegramInstanceConfig: + for inst in self._load().telegram: + if inst.instance_id == instance_id: + return inst + raise PortalError(404, f"no such telegram instance {instance_id!r}") + # ---- tool approvals ------------------------------------------------- # def list_approvals(self) -> list[dict[str, Any]]: @@ -368,6 +453,37 @@ def groups(self, instance_id: str) -> list[dict[str, Any]]: ) return out + # ---- Telegram Bot API probe ----------------------------------------- # + + def telegram_status(self, instance_id: str) -> dict[str, Any]: + """Confirm a Telegram bot token via ``getMe``. Never logs the token. + + The token lives only in the request URL path; on any transport error we + surface the exception *class name* only — never ``str(exc)`` (which could + echo the URL) — mirroring the async client's redaction contract. + """ + 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}/getMe" + try: + resp = self._client.request("POST", url, json={}) + 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 isinstance(body, dict) and body.get("ok"): + result = body.get("result") if isinstance(body.get("result"), dict) else {} + return {"ok": True, "username": result.get("username"), "id": result.get("id")} + code = body.get("error_code") if isinstance(body, dict) else resp.status_code + if code == 401: + raise PortalError(502, "telegram rejected the bot token (check token_env/token_file)") + raise PortalError(502, f"telegram getMe failed (code {code})") + def search_contacts(self, instance_id: str, query: str, limit: int) -> list[dict[str, Any]]: inst = self._instance(instance_id) contacts = self._contacts(inst) @@ -475,6 +591,8 @@ def _instance_from_payload(item: dict[str, Any]) -> dict[str, Any]: block[key] = val if "enabled" in item: block["enabled"] = bool(item["enabled"]) + if "require_approval" in item: + block["require_approval"] = bool(item["require_approval"]) # ``free_form`` is the UI-friendly form of ``trigger_prefix == ""``. if item.get("free_form") is True: block["trigger_prefix"] = "" @@ -496,6 +614,41 @@ def _instance_from_payload(item: dict[str, Any]) -> dict[str, Any]: return block +def _telegram_instance_from_payload(item: dict[str, Any]) -> dict[str, Any]: + """Map a browser payload to a raw ``[[channels.telegram]]`` dict. + + Allow-listed like the WeChat mapper: only known keys pass through, and + ``parse_channels_config`` then enforces every type/constraint. + """ + block: dict[str, Any] = {} + for key in ("instance_id", "api_base", "token_env", "token_file", "default_provider"): + val = item.get(key) + if isinstance(val, str) and val: + block[key] = val + if "enabled" in item: + block["enabled"] = bool(item["enabled"]) + if "require_approval" in item: + block["require_approval"] = bool(item["require_approval"]) + if item.get("free_form") is True: + block["trigger_prefix"] = "" + elif isinstance(item.get("trigger_prefix"), str): + block["trigger_prefix"] = item["trigger_prefix"] + senders = item.get("allowed_senders") + if isinstance(senders, list): + block["allowed_senders"] = [s for s in senders if isinstance(s, str) and s] + groups = item.get("allowed_groups") + if isinstance(groups, list): + block["allowed_groups"] = [s for s in groups if isinstance(s, str) and s] + if isinstance(item.get("rate_limit_per_min"), int) and not isinstance( + item.get("rate_limit_per_min"), bool + ): + block["rate_limit_per_min"] = item["rate_limit_per_min"] + dedup = item.get("dedup_window_seconds") + if isinstance(dedup, (int, float)) and not isinstance(dedup, bool): + block["dedup_window_seconds"] = dedup + return block + + # --------------------------------------------------------------------------- # # HTTP layer. # --------------------------------------------------------------------------- # @@ -624,6 +777,8 @@ def _handle_api_get(self, path: str, query: dict[str, list[str]]) -> None: self._send_json(200, service.qr(instance)) elif path == "/api/wechat/groups": self._send_json(200, {"groups": service.groups(instance)}) + elif path == "/api/telegram/status": + self._send_json(200, service.telegram_status(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 181c923..56f6e98 100644 --- a/coding_bridge/channels/portal_html.py +++ b/coding_bridge/channels/portal_html.py @@ -100,6 +100,20 @@ .appr .btn{padding:7px 14px} .btn.approve{background:var(--ok);color:#fff} .btn.deny{background:transparent;color:var(--danger);border:1px solid var(--danger)} +.chanlist{display:flex;flex-direction:column;gap:6px;margin-top:6px} +.chan{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--border);border-radius:12px;cursor:pointer;background:var(--bg)} +.chan:hover{border-color:var(--accent)} +.chan.active{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 20%,transparent)} +.chan .nm{font-weight:600;font-size:14px} +.chan .id{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.chan-ic{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:#fff;font-weight:700;font-size:16px;flex:0 0 auto} +.chan-ic.wechat{background:#09b83e} +.chan-ic.telegram{background:#2aabee} +.badge{display:inline-block;margin-left:8px;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:var(--chip);color:var(--chip-fg);vertical-align:middle} +.badge.telegram{background:#e8f6fd;color:#1c7ab5} +.badge.wechat{background:#e7f8ec;color:#12833a} +textarea.ta{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--fg);font:inherit;resize:vertical;min-height:64px;outline:none} +textarea.ta:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)} @@ -107,7 +121,7 @@
-

Channels · WeChat

+

Channels

loading config…
@@ -160,10 +174,16 @@ stopQrPoll(); stopApprovalPoll(); const cfg=await api("/api/config"); STATE.instances=cfg.instances; STATE.idx=0; $("#cfgpath").textContent=cfg.config_path; - renderInstSelect(); + $("#instwrap").innerHTML=""; if(!STATE.instances.length){ $("#app").innerHTML=""; $("#app").append(noInstances()); $("#bar").style.display="none"; return; } $("#bar").style.display="block"; render(); - loadAccount(); loadGroups(); warmContacts(); startApprovalPoll(); + loadForCurrent(); startApprovalPoll(); +} + +function loadForCurrent(){ + const it=current(); if(!it) return; + if(it.type==="telegram"){ loadTelegramStatus(); } + else { loadAccount(); loadGroups(); warmContacts(); } } function warmContacts(){ @@ -213,23 +233,94 @@ function noInstances(){ return el("div",{class:"card"}, - el("h2",{},"No WeChat instance configured"), - el("p",{class:"hint"},"Run `coding-bridge channels init`, set base_url + a token env var, then reload. The portal edits existing instances.")); + el("h2",{},"No channels configured"), + el("p",{class:"hint"},"Run `coding-bridge channels init`, add a [[channels.wechat]] or [[channels.telegram]] block with a token, then reload. The portal edits existing instances.")); +} + +function chanIcon(type){ return el("div",{class:"chan-ic "+type}, type==="telegram"?"✈":"微"); } + +function renderOverview(){ + const card=el("div",{class:"card",id:"overview"}, + el("h2",{},"Channels — "+STATE.instances.length), + el("p",{class:"hint"},"Every configured channel. Click one to edit it; the editor adapts to each channel type.")); + const list=el("div",{class:"chanlist"}); + STATE.instances.forEach((it,i)=>{ + const active=i===STATE.idx; + list.append(el("div",{class:"chan"+(active?" active":""),onclick:()=>select(i)}, + chanIcon(it.type), + el("div",{style:"flex:1;min-width:0"}, + el("div",{class:"nm"}, it.instance_id, el("span",{class:"badge "+it.type}, it.type)), + el("div",{class:"id"}, it.type==="telegram"?(it.api_base||"telegram"):it.base_url)), + el("span",{class:"pill"}, el("span",{class:"dot"+(it.enabled?"":" off")}), it.enabled?"enabled":"disabled"))); + }); + card.append(list); + return card; } -function renderInstSelect(){ - const w=$("#instwrap"); w.innerHTML=""; - if(STATE.instances.length<=1) return; - const sel=el("select",{id:"inst",onchange:e=>{stopQrPoll();STATE.idx=+e.target.value;render();loadAccount();loadGroups();warmContacts();}}); - STATE.instances.forEach((it,i)=>sel.append(el("option",{value:i},it.instance_id))); - w.append(sel); +function refreshOverview(){ + const cur=$("#overview"); if(cur) cur.replaceWith(renderOverview()); +} + +function select(i){ + if(i===STATE.idx) return; + stopQrPoll(); STATE.idx=i; render(); loadForCurrent(); } function render(){ const it=current(); const app=$("#app"); app.innerHTML=""; app.append(el("div",{id:"approvals"})); + app.append(renderOverview()); app.append(el("div",{id:"onboarding"})); + if(it.type==="telegram") renderTelegramEditor(app,it); + else renderWeChatEditor(app,it); +} + +function behaviorCard(it,opts){ + opts=opts||{}; + const freeform=it.free_form!==false && (it.trigger_prefix===""||it.free_form===true); + const beh=el("div",{class:"card"}, + el("h2",{},"Behavior"), + el("p",{class:"hint"},"How the bot responds.")); + beh.append(el("label",{class:"fld"},"Trigger")); + const seg=el("div",{class:"seg"}, + el("button",{class:freeform?"on":"",id:"tf-free",onclick:()=>setTrigger(true)},"Free-form"), + el("button",{class:freeform?"":"on",id:"tf-prefix",onclick:()=>setTrigger(false)},"Require prefix")); + beh.append(seg); + const pfx=el("input",{type:"text",id:"prefix",placeholder:"/ask ",style:"margin-top:10px;"+(freeform?"display:none":""), + value: freeform? "/ask " : (it.trigger_prefix||"/ask ")}); + pfx.addEventListener("input",()=>{it.trigger_prefix=pfx.value;}); + beh.append(pfx); + beh.append(el("label",{class:"fld"},"Provider")); + const prov=el("select",{id:"prov",onchange:e=>{it.default_provider=e.target.value;}}); + ["claude","codex","copilot"].forEach(p=>prov.append(el("option",{value:p,...(it.default_provider===p?{selected:"selected"}:{})},p))); + beh.append(prov); + beh.append(el("label",{class:"fld"},"Rate limit (messages / sender / minute, 0 = off)")); + const rl=el("input",{type:"number",id:"rl",min:"0",value:String(it.rate_limit_per_min)}); + rl.addEventListener("input",()=>{const v=parseInt(rl.value||"0",10);it.rate_limit_per_min=isNaN(v)?0:Math.max(0,v);}); + beh.append(rl); + if(opts.dedup){ + beh.append(el("label",{class:"fld"},"Dedup window (seconds, 0 = off)")); + const dd=el("input",{type:"number",id:"dedup",min:"0",step:"1",value:String(it.dedup_window_seconds)}); + dd.addEventListener("input",()=>{const v=parseFloat(dd.value||"0");it.dedup_window_seconds=isNaN(v)?0:Math.max(0,v);}); + beh.append(dd); + } + return beh; +} + +function safetyCard(it){ + const card=el("div",{class:"card"}, + el("h2",{},"Safety"), + el("p",{class:"hint"},"Require approval holds every tool action for Approve/Deny in this portal instead of running unattended.")); + card.append(el("label",{class:"row",style:"cursor:pointer;gap:12px"}, + el("label",{class:"switch"}, + el("input",{type:"checkbox",id:"reqappr",...(it.require_approval?{checked:"checked"}:{}),onchange:e=>{it.require_approval=e.target.checked;}}), + el("span",{class:"slider"})), + el("div",{}, el("div",{style:"font-weight:600"},"Require approval for tool use"), + el("div",{class:"pill"},"Off = run tools unattended (default)")))); + return card; +} +function renderWeChatEditor(app,it){ // account card const acct=el("div",{class:"card"}, el("div",{class:"row"}, @@ -237,7 +328,7 @@ el("div",{style:"font-weight:650"},it.instance_id), el("div",{class:"pill",id:"acctline"}, it.base_url))), el("label",{class:"switch",style:"margin-left:auto",title:"Enabled"}, - el("input",{type:"checkbox",...(it.enabled?{checked:"checked"}:{}),onchange:e=>{it.enabled=e.target.checked;}}), + el("input",{type:"checkbox",...(it.enabled?{checked:"checked"}:{}),onchange:e=>{it.enabled=e.target.checked;refreshOverview();}}), el("span",{class:"slider"})))); app.append(acct); if(!it.token_resolvable){ @@ -259,31 +350,8 @@ const q=$("#q"); let dbt; q.addEventListener("input",()=>{clearTimeout(dbt);dbt=setTimeout(()=>doSearch(q.value),200);}); q.addEventListener("focus",()=>{ if(q.value)doSearch(q.value); }); - document.addEventListener("click",e=>{ if(!search.contains(e.target))$("#results").classList.remove("show"); }); - // behavior card - const freeform=it.free_form!==false && (it.trigger_prefix===""||it.free_form===true); - const beh=el("div",{class:"card"}, - el("h2",{},"Behavior"), - el("p",{class:"hint"},"How the bot responds.")); - beh.append(el("label",{class:"fld"},"Trigger")); - const seg=el("div",{class:"seg"}, - el("button",{class:freeform?"on":"",id:"tf-free",onclick:()=>setTrigger(true)},"Free-form"), - el("button",{class:freeform?"":"on",id:"tf-prefix",onclick:()=>setTrigger(false)},"Require prefix")); - beh.append(seg); - const pfx=el("input",{type:"text",id:"prefix",placeholder:"/ask ",style:"margin-top:10px;"+(freeform?"display:none":""), - value: freeform? "/ask " : (it.trigger_prefix||"/ask ")}); - pfx.addEventListener("input",()=>{it.trigger_prefix=pfx.value;}); - beh.append(pfx); - beh.append(el("label",{class:"fld"},"Provider")); - const prov=el("select",{id:"prov",onchange:e=>{it.default_provider=e.target.value;}}); - ["claude","codex","copilot"].forEach(p=>prov.append(el("option",{value:p,...(it.default_provider===p?{selected:"selected"}:{})},p))); - beh.append(prov); - beh.append(el("label",{class:"fld"},"Rate limit (messages / sender / minute, 0 = off)")); - const rl=el("input",{type:"number",id:"rl",min:"0",value:String(it.rate_limit_per_min)}); - rl.addEventListener("input",()=>{const v=parseInt(rl.value||"0",10);it.rate_limit_per_min=isNaN(v)?0:Math.max(0,v);}); - beh.append(rl); - app.append(beh); + app.append(behaviorCard(it)); // groups — allowed_groups picker const g=el("div",{class:"card"}, @@ -291,6 +359,68 @@ el("p",{class:"hint"},"Check groups to restrict the bot to only those. None checked = it may answer in every group it's in (still gated by prefix/sender). Keep a prefix in busy groups."), el("div",{class:"grouplist",id:"groups"}, el("div",{class:"empty"},"loading…"))); app.append(g); + + app.append(safetyCard(it)); +} + +function renderTelegramEditor(app,it){ + // account / token-status card + const acct=el("div",{class:"card"}, + el("div",{class:"row"}, + el("div",{class:"acct"}, chanIcon("telegram"), el("div",{}, + el("div",{style:"font-weight:650"}, it.instance_id, el("span",{class:"badge telegram"},"telegram")), + el("div",{class:"pill"}, it.api_base))), + el("label",{class:"switch",style:"margin-left:auto",title:"Enabled"}, + el("input",{type:"checkbox",...(it.enabled?{checked:"checked"}:{}),onchange:e=>{it.enabled=e.target.checked;refreshOverview();}}), + el("span",{class:"slider"})))); + app.append(acct); + if(!it.token_resolvable){ + acct.append(el("p",{class:"hint",style:"color:var(--danger);margin:12px 0 0"}, + "⚠ Bot token not resolvable ("+(it.token_source.kind==="none"?"no token_env/token_file set":it.token_source.kind+": "+it.token_source.ref)+"). Create a bot with @BotFather and set the token env var.")); + } else { + acct.append(el("div",{class:"pill",id:"tgstatus",style:"margin-top:10px"},"checking bot token…")); + } + + // access — manual sender-id allowlist + const access=el("div",{class:"card"}, + el("h2",{},"Access — who can drive the bot"), + el("p",{class:"hint"},"Telegram numeric user IDs, one per line. Empty = anyone who can message the bot. Message @userinfobot to find an id.")); + const senders=el("textarea",{class:"ta",id:"tgsenders",rows:"3",placeholder:"123456789\n987654321",autocomplete:"off",spellcheck:"false"}); + senders.value=(it.allowed_senders||[]).join("\n"); + senders.addEventListener("input",()=>{ it.allowed_senders=splitLines(senders.value); }); + access.append(senders); + app.append(access); + + app.append(behaviorCard(it,{dedup:true})); + + // groups — manual chat-id allowlist + const g=el("div",{class:"card"}, + el("h2",{},"Groups — where the bot may answer"), + el("p",{class:"hint"},"Group / supergroup chat IDs (usually negative), one per line. Empty = every group the bot is in. Disable the bot's privacy mode in @BotFather to receive group messages.")); + const groups=el("textarea",{class:"ta",id:"tggroups",rows:"3",placeholder:"-1001234567890",autocomplete:"off",spellcheck:"false"}); + groups.value=(it.allowed_groups||[]).join("\n"); + groups.addEventListener("input",()=>{ it.allowed_groups=splitLines(groups.value); }); + g.append(groups); + app.append(g); + + app.append(safetyCard(it)); + + if(it.token_resolvable) loadTelegramStatus(); +} + +function splitLines(s){ return (s||"").split(/\r?\n/).map(x=>x.trim()).filter(Boolean); } + +async function loadTelegramStatus(){ + const it=current(); if(!it||it.type!=="telegram"||!it.token_resolvable) return; + const box=$("#tgstatus"); if(!box) return; + try{ + const s=await api("/api/telegram/status?instance="+encodeURIComponent(it.instance_id)); + box.innerHTML=""; box.append(el("span",{class:"dot"}), + document.createTextNode(" bot "+(s.username?("@"+s.username):("id "+(s.id||"?")))+" — token OK")); + }catch(e){ + box.innerHTML=""; box.append(el("span",{class:"dot off"}), + document.createTextNode(" "+String(e.message||"token check failed"))); + } } function setTrigger(free){ @@ -405,25 +535,39 @@ async function save(){ const btn=$("#save"); btn.disabled=true; try{ - const payload={instances:STATE.instances.map(it=>({ - instance_id:it.instance_id, base_url:it.base_url, - token_env:it.token_source.kind==="env"?it.token_source.ref:null, - token_file:it.token_source.kind==="file"?it.token_source.ref:null, - enabled:it.enabled, default_provider:it.default_provider, - free_form: it.free_form===true || it.trigger_prefix==="", - trigger_prefix: it.trigger_prefix, - allowed_senders: it.allowed_senders, - allowed_groups: it.allowed_groups, - rate_limit_per_min: it.rate_limit_per_min, - dedup_window_seconds: it.dedup_window_seconds}))}; + const payload={instances:STATE.instances.map(it=>{ + const o={ + type: it.type, + instance_id: it.instance_id, + token_env: it.token_source.kind==="env"?it.token_source.ref:null, + token_file: it.token_source.kind==="file"?it.token_source.ref:null, + enabled: it.enabled, + require_approval: it.require_approval===true, + default_provider: it.default_provider, + free_form: it.free_form===true || it.trigger_prefix==="", + trigger_prefix: it.trigger_prefix, + allowed_senders: it.allowed_senders, + allowed_groups: it.allowed_groups, + rate_limit_per_min: it.rate_limit_per_min, + dedup_window_seconds: it.dedup_window_seconds}; + if(it.type==="telegram") o.api_base=it.api_base; else o.base_url=it.base_url; + return o; + })}; const res=await api("/api/config",{method:"POST",body:JSON.stringify(payload)}); - STATE.instances=res.instances; toast("Saved to channels.toml"); render(); loadAccount(); loadGroups(); + STATE.instances=res.instances; toast("Saved to channels.toml"); render(); loadForCurrent(); }catch(e){ toast(e.message,true); } finally{ btn.disabled=false; } } $("#save").addEventListener("click",save); $("#reload").addEventListener("click",load); +// One global listener (added once) closes the contact-search dropdown on an +// outside click — avoids stacking a fresh listener on every editor render. +document.addEventListener("click",e=>{ + const box=$("#results"); if(!box) return; + const s=box.closest(".search"); + if(s && !s.contains(e.target)) box.classList.remove("show"); +}); load().catch(e=>{ $("#app").innerHTML=""; $("#app").append(el("div",{class:"card"},el("h2",{},"Failed to load"),el("p",{class:"hint"},String(e.message)))); }); diff --git a/tests/test_channels_portal.py b/tests/test_channels_portal.py index 8e019fd..f907de5 100644 --- a/tests/test_channels_portal.py +++ b/tests/test_channels_portal.py @@ -12,6 +12,7 @@ from coding_bridge.channels.approvals import ApprovalStore from coding_bridge.channels.config import ( ChannelsConfig, + TelegramInstanceConfig, WeChatInstanceConfig, load_channels_config, ) @@ -504,3 +505,235 @@ def test_http_approvals_endpoints(tmp_path, monkeypatch): httpd.shutdown() httpd.server_close() svc.close() + + +# --------------------------------------------------------------------------- # +# Channel-agnostic portal — Telegram as a WeChat peer +# --------------------------------------------------------------------------- # + + +def _tg_inst(**over): + base = { + "instance_id": "tg1", + "token_env": "TT", + "enabled": True, + "default_provider": "claude", + "trigger_prefix": "/ask ", + "allowed_senders": ("123",), + "rate_limit_per_min": 6, + "dedup_window_seconds": 300.0, + } + base.update(over) + return TelegramInstanceConfig(**base) + + +def _tg_getme_transport(*, ok=True, username="mybot", error_code=401): + """MockTransport answering Telegram ``getMe`` (token is only in the URL path).""" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/getMe"): + if ok: + return httpx.Response( + 200, json={"ok": True, "result": {"id": 42, "username": username}} + ) + return httpx.Response( + 200, json={"ok": False, "error_code": error_code, "description": "Unauthorized"} + ) + return httpx.Response(404, json={}) + + return httpx.MockTransport(handler) + + +def test_toml_roundtrip_telegram(tmp_path): + cfg = ChannelsConfig( + wechat=(_inst(instance_id="wx"),), + telegram=( + _tg_inst( + instance_id="tg", + api_base="https://tg.example.com", + require_approval=True, + allowed_groups=("-100",), + ), + ), + ) + path = tmp_path / "channels.toml" + path.write_text(dump_channels_toml(cfg), encoding="utf-8") + back = load_channels_config(path) + assert [i.instance_id for i in back.wechat] == ["wx"] + tg = back.telegram[0] + assert tg.instance_id == "tg" + assert tg.api_base == "https://tg.example.com" + assert tg.require_approval is True + assert tg.allowed_groups == ("-100",) + + +def test_public_config_includes_type_and_telegram(tmp_path, monkeypatch): + monkeypatch.setenv("WT", "gw-secret") + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(wechat=(_inst(),), telegram=(_tg_inst(),))), + encoding="utf-8", + ) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + pub = svc.public_config() + types = {i["instance_id"]: i["type"] for i in pub["instances"]} + assert types == {"beijing": "wechat", "tg1": "telegram"} + tg = next(i for i in pub["instances"] if i["type"] == "telegram") + assert tg["api_base"] == "https://api.telegram.org" + assert tg["token_resolvable"] is True + # neither secret ever appears in the payload + assert "tg-secret" not in str(pub) and "gw-secret" not in str(pub) + svc.close() + + +def test_save_roundtrips_wechat_and_telegram(tmp_path, monkeypatch): + monkeypatch.setenv("WT", "gw-secret") + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + result = svc.save( + [ + { + "type": "wechat", + "instance_id": "wx", + "base_url": "http://gw", + "token_env": "WT", + "enabled": True, + "allowed_groups": ["g@chatroom"], + }, + { + "type": "telegram", + "instance_id": "tg", + "token_env": "TT", + "enabled": True, + "free_form": False, + "trigger_prefix": "/ask ", + "allowed_senders": ["123", ""], # empty dropped + "allowed_groups": ["-100"], + "rate_limit_per_min": 4, + "dedup_window_seconds": 120, + }, + ] + ) + kinds = {i["instance_id"]: i["type"] for i in result["instances"]} + assert kinds == {"wx": "wechat", "tg": "telegram"} + reloaded = load_channels_config(tmp_path / "channels.toml") + assert reloaded.wechat[0].allowed_groups == ("g@chatroom",) + tg = reloaded.telegram[0] + assert tg.allowed_senders == ("123",) and tg.allowed_groups == ("-100",) + assert tg.rate_limit_per_min == 4 and tg.dedup_window_seconds == 120.0 + svc.close() + + +def test_save_preserves_require_approval_both_types(tmp_path, monkeypatch): + monkeypatch.setenv("WT", "gw-secret") + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + result = svc.save( + [ + { + "type": "wechat", + "instance_id": "wx", + "base_url": "http://gw", + "token_env": "WT", + "require_approval": True, + }, + { + "type": "telegram", + "instance_id": "tg", + "token_env": "TT", + "require_approval": True, + }, + ] + ) + for inst in result["instances"]: + assert inst["require_approval"] is True + reloaded = load_channels_config(tmp_path / "channels.toml") + assert reloaded.wechat[0].require_approval is True + assert reloaded.telegram[0].require_approval is True + svc.close() + + +def test_save_unknown_type_rejected(tmp_path, monkeypatch): + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + with pytest.raises(PortalError) as ei: + svc.save([{"type": "discord", "instance_id": "x"}]) + assert ei.value.status == 400 + svc.close() + + +def test_telegram_status_ok(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(telegram=(_tg_inst(),))), encoding="utf-8" + ) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport(username="acebot"))) + st = svc.telegram_status("tg1") + assert st["ok"] is True and st["username"] == "acebot" + svc.close() + + +def test_telegram_status_401_no_token_leak(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "supersecrettoken") + s = _settings(tmp_path) + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(telegram=(_tg_inst(),))), encoding="utf-8" + ) + svc = PortalService( + s, client=httpx.Client(transport=_tg_getme_transport(ok=False, error_code=401)) + ) + with pytest.raises(PortalError) as ei: + svc.telegram_status("tg1") + assert ei.value.status == 502 + assert "supersecrettoken" not in ei.value.message + svc.close() + + +def test_telegram_status_unknown_instance_404(tmp_path, monkeypatch): + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + with pytest.raises(PortalError) as ei: + svc.telegram_status("ghost") + assert ei.value.status == 404 + svc.close() + + +def test_http_telegram_status_route(tmp_path, monkeypatch): + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(telegram=(_tg_inst(),))), encoding="utf-8" + ) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport(username="acebot"))) + token = "tgtok" + 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: + base = f"http://127.0.0.1:{port}" + r = httpx.get( + base + "/api/telegram/status?instance=tg1", + headers={"X-Portal-Token": token}, + timeout=5, + ) + assert r.status_code == 200 and r.json()["username"] == "acebot" + finally: + httpd.shutdown() + httpd.server_close() + svc.close() + + +def test_index_save_payload_carries_type_and_channel_fields(): + """Regression guard: the client save() must send ``type`` (+ ``api_base`` and + ``require_approval``) so a Telegram instance can never be mis-serialized as a + WeChat block (which drops it / errors on the missing base_url).""" + from coding_bridge.channels.portal_html import INDEX_HTML + + assert "type: it.type" in INDEX_HTML + assert "o.api_base=it.api_base" in INDEX_HTML + assert "require_approval: it.require_approval" in INDEX_HTML