diff --git a/coding_bridge/channels/portal_html.py b/coding_bridge/channels/portal_html.py index 56f6e98..09f398c 100644 --- a/coding_bridge/channels/portal_html.py +++ b/coding_bridge/channels/portal_html.py @@ -254,6 +254,10 @@ el("span",{class:"pill"}, el("span",{class:"dot"+(it.enabled?"":" off")}), it.enabled?"enabled":"disabled"))); }); card.append(list); + card.append(el("div",{class:"row",style:"margin-top:12px;gap:8px;flex-wrap:wrap"}, + el("span",{class:"hint",style:"margin-right:2px"},"Add a channel:"), + el("button",{class:"btn ghost",style:"border:1px solid var(--border)",onclick:()=>addChannel("wechat")},"+ WeChat"), + el("button",{class:"btn ghost",style:"border:1px solid var(--border)",onclick:()=>addChannel("telegram")},"+ Telegram"))); return card; } @@ -266,6 +270,88 @@ stopQrPoll(); STATE.idx=i; render(); loadForCurrent(); } +function newInstance(type){ + const it={ type:type, instance_id:"", token_source:{kind:"env",ref:""}, token_resolvable:false, + enabled:false, require_approval:false, default_provider:"claude", trigger_prefix:"/ask ", free_form:false, + allowed_senders:[], allowed_groups:[], rate_limit_per_min:6, dedup_window_seconds:300 }; + if(type==="telegram") it.api_base="https://api.telegram.org"; else it.base_url=""; + return it; +} + +function addChannel(type){ + STATE.instances.push(newInstance(type)); + STATE.idx=STATE.instances.length-1; + render(); + toast((type==="telegram"?"Telegram":"WeChat")+" channel added — fill it in below, then Save"); +} + +function removeCurrent(){ + const it=current(); if(!it) return; + if(!window.confirm("Remove channel \u201C"+(it.instance_id||"(unnamed)")+"\u201D? It is deleted from channels.toml when you click Save.")) return; + STATE.instances.splice(STATE.idx,1); + STATE.idx=Math.min(STATE.idx, STATE.instances.length-1); + if(STATE.idx<0) STATE.idx=0; + toast("Channel removed — click Save to write channels.toml"); + if(!STATE.instances.length){ + const app=$("#app"); app.innerHTML=""; + app.append(el("div",{id:"approvals"})); + app.append(renderOverview()); + app.append(el("div",{class:"card"}, el("h2",{},"No channels left"), + el("p",{class:"hint"},"Click Save to write the now-empty channels.toml, or add a channel above."))); + return; + } + render(); loadForCurrent(); +} + +function connectionCard(it){ + const isTg=it.type==="telegram"; + const card=el("div",{class:"card"}, + el("h2",{},"Connection"), + el("p",{class:"hint"},"Instance id, endpoint, and where the token is read from — the portal stores an env-var name or a file path, never the token itself.")); + card.append(el("label",{class:"fld"},"Instance ID (unique)")); + const idin=el("input",{type:"text",id:"cid",value:it.instance_id||"",placeholder:isTg?"my-telegram":"my-wechat",autocomplete:"off"}); + idin.addEventListener("input",()=>{ it.instance_id=idin.value.trim(); }); + card.append(idin); + if(isTg){ + card.append(el("label",{class:"fld"},"Bot API base (optional — self-hosted Bot API)")); + const u=el("input",{type:"text",id:"cbase",value:it.api_base||"https://api.telegram.org",autocomplete:"off"}); + u.addEventListener("input",()=>{ it.api_base=u.value.trim(); }); + card.append(u); + } else { + card.append(el("label",{class:"fld"},"WeChat gateway base URL")); + const u=el("input",{type:"text",id:"cbase",value:it.base_url||"",placeholder:"http://127.0.0.1:8000",autocomplete:"off"}); + u.addEventListener("input",()=>{ it.base_url=u.value.trim(); }); + card.append(u); + } + card.append(el("label",{class:"fld"},"Token source")); + const kind=(it.token_source&&it.token_source.kind==="file")?"file":"env"; + card.append(el("div",{class:"seg"}, + el("button",{id:"ts-env",class:kind==="env"?"on":"",onclick:()=>setTokenKind("env")},"Env var"), + el("button",{id:"ts-file",class:kind==="file"?"on":"",onclick:()=>setTokenKind("file")},"File path"))); + const ref=el("input",{type:"text",id:"tref",style:"margin-top:10px",value:(it.token_source&&it.token_source.ref)||"", + placeholder: kind==="file"?"/run/secrets/mybot-token":(isTg?"TELEGRAM_TOKEN_MYBOT":"WECHAT_TOKEN_MYWECHAT"),autocomplete:"off"}); + ref.addEventListener("input",()=>{ it.token_source={kind:(it.token_source&&it.token_source.kind)||"env", ref:ref.value.trim()}; }); + card.append(ref); + card.append(el("p",{class:"hint",style:"margin:8px 0 0"}, isTg + ? "Export the env var before `channels start`; `channels doctor` verifies it via getMe." + : "Export the env var before `channels start`.")); + return card; +} + +function setTokenKind(kind){ + const it=current(); const rEl=$("#tref"); const ref=(rEl?rEl.value:"").trim(); + it.token_source={kind:kind, ref:ref}; + const e=$("#ts-env"), f=$("#ts-file"); if(e)e.className=kind==="env"?"on":""; if(f)f.className=kind==="file"?"on":""; + if(rEl) rEl.placeholder = kind==="file"?"/run/secrets/mybot-token":(it.type==="telegram"?"TELEGRAM_TOKEN_MYBOT":"WECHAT_TOKEN_MYWECHAT"); +} + +function deleteCard(it){ + return el("div",{class:"card",style:"border-color:var(--danger)"}, + el("h2",{},"Remove channel"), + el("p",{class:"hint"},"Delete this channel from channels.toml. Takes effect when you click Save."), + el("button",{class:"btn deny",onclick:()=>removeCurrent()},"Delete this channel")); +} + function render(){ const it=current(); const app=$("#app"); app.innerHTML=""; app.append(el("div",{id:"approvals"})); @@ -336,6 +422,8 @@ "⚠ Token not resolvable ("+(it.token_source.kind==="none"?"no token_env/token_file set":it.token_source.kind+": "+it.token_source.ref)+"). Contacts/groups won't load until it's set.")); } + app.append(connectionCard(it)); + // access card const access=el("div",{class:"card"}, el("h2",{},"Access — who can drive the bot"), @@ -361,6 +449,7 @@ app.append(g); app.append(safetyCard(it)); + app.append(deleteCard(it)); } function renderTelegramEditor(app,it){ @@ -381,6 +470,8 @@ acct.append(el("div",{class:"pill",id:"tgstatus",style:"margin-top:10px"},"checking bot token…")); } + app.append(connectionCard(it)); + // access — manual sender-id allowlist const access=el("div",{class:"card"}, el("h2",{},"Access — who can drive the bot"), @@ -404,6 +495,7 @@ app.append(g); app.append(safetyCard(it)); + app.append(deleteCard(it)); if(it.token_resolvable) loadTelegramStatus(); } diff --git a/tests/test_channels_portal.py b/tests/test_channels_portal.py index f907de5..4f7ad85 100644 --- a/tests/test_channels_portal.py +++ b/tests/test_channels_portal.py @@ -737,3 +737,75 @@ def test_index_save_payload_carries_type_and_channel_fields(): 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 + + +# --------------------------------------------------------------------------- # +# Add / remove channels from the portal (save is a full-file rewrite) +# --------------------------------------------------------------------------- # + + +def test_save_empty_list_writes_empty_config(tmp_path, monkeypatch): + """Removing every channel and saving writes an empty (but valid) config.""" + monkeypatch.setenv("WT", "gw-secret") + s = _settings(tmp_path) + (tmp_path / "channels.toml").write_text( + dump_channels_toml(ChannelsConfig(wechat=(_inst(),))), encoding="utf-8" + ) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + result = svc.save([]) + assert result["instances"] == [] + reloaded = load_channels_config(tmp_path / "channels.toml") + assert reloaded.wechat == () and reloaded.telegram == () + svc.close() + + +def test_save_add_then_remove_roundtrip(tmp_path, monkeypatch): + """Adding a brand-new instance then removing one both go through save().""" + monkeypatch.setenv("WT", "gw-secret") + monkeypatch.setenv("TT", "tg-secret") + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + # add: post the existing wechat + a freshly-created telegram instance + added = svc.save( + [ + {"type": "wechat", "instance_id": "wx", "base_url": "http://gw", "token_env": "WT"}, + {"type": "telegram", "instance_id": "tg-new", "token_env": "TT", "enabled": False}, + ] + ) + assert {i["instance_id"] for i in added["instances"]} == {"wx", "tg-new"} + # remove: post only the wechat instance back (telegram dropped) + removed = svc.save( + [{"type": "wechat", "instance_id": "wx", "base_url": "http://gw", "token_env": "WT"}] + ) + assert [i["instance_id"] for i in removed["instances"]] == ["wx"] + reloaded = load_channels_config(tmp_path / "channels.toml") + assert reloaded.telegram == () + svc.close() + + +def test_save_new_instance_without_id_is_rejected(tmp_path, monkeypatch): + """A newly-added instance with no instance_id fails validation (clear 400).""" + s = _settings(tmp_path) + svc = PortalService(s, client=httpx.Client(transport=_tg_getme_transport())) + with pytest.raises(PortalError) as ei: + svc.save([{"type": "telegram", "enabled": True}]) + assert ei.value.status == 400 + svc.close() + + +def test_index_has_add_remove_channel_controls(): + """Regression guard: the client exposes add/remove + a connection editor + (instance_id / endpoint / token source), so channels can be created and + deleted from the portal instead of hand-editing channels.toml.""" + from coding_bridge.channels.portal_html import INDEX_HTML + + for token in ( + "function addChannel", + "function removeCurrent", + "function connectionCard", + "function newInstance", + "Delete this channel", + "Add a channel", + ): + assert token in INDEX_HTML +