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
92 changes: 92 additions & 0 deletions coding_bridge/channels/portal_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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"}));
Expand Down Expand Up @@ -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"),
Expand All @@ -361,6 +449,7 @@
app.append(g);

app.append(safetyCard(it));
app.append(deleteCard(it));
}

function renderTelegramEditor(app,it){
Expand All @@ -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"),
Expand All @@ -404,6 +495,7 @@
app.append(g);

app.append(safetyCard(it));
app.append(deleteCard(it));

if(it.token_resolvable) loadTelegramStatus();
}
Expand Down
72 changes: 72 additions & 0 deletions tests/test_channels_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading