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 @@
CB
-
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