From b4cd3bd565f5be71e4284a34272faf648c9154d8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 05:03:21 -0700 Subject: [PATCH 1/2] revert: auto-subscribe gateway chat on tool-driven kanban_create (#19718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts ff3d2773e2. Teknium reviewed the merged PR and decided this behavior isn't wanted — tool-driven kanban_create should not mirror the slash-command path's auto-subscribe. Orchestrators that want their originating chat notified can call kanban_notify-subscribe explicitly; we're not going to make it implicit. --- tests/tools/test_kanban_tools.py | 100 ------------------------------- tools/kanban_tools.py | 30 ---------- 2 files changed, 130 deletions(-) diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index fdde48a2aa7e9..9031d81d8eb0f 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -610,103 +610,3 @@ def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path): out = kt._handle_complete({"task_id": tid, "summary": "orchestrator close"}) d = json.loads(out) assert d.get("ok") is True and d.get("task_id") == tid - - -# --------------------------------------------------------------------------- -# kanban_create auto-subscribe to gateway notifications (#19479) -# --------------------------------------------------------------------------- -# -# When an orchestrator agent (running under the gateway) calls kanban_create, -# the originating (platform, chat, thread) should be auto-subscribed to the -# new task's terminal events — matching the /kanban create slash-command -# behavior in gateway/run.py. In CLI / cron contexts (no session vars set), -# no subscription row is written. - - -def test_create_no_subscribe_in_cli_context(worker_env): - """Classic CLI: no gateway session vars -> no notify subscription.""" - from tools import kanban_tools as kt - from hermes_cli import kanban_db as kb - out = kt._handle_create({"title": "cli task", "assignee": "peer"}) - d = json.loads(out) - assert d.get("ok") is True - assert d.get("subscribed") is False - conn = kb.connect() - try: - assert kb.list_notify_subs(conn, d["task_id"]) == [] - finally: - conn.close() - - -def test_create_auto_subscribes_in_gateway_context(worker_env): - """Gateway session vars set -> auto-subscribe the originating source.""" - from gateway.session_context import set_session_vars, clear_session_vars - from tools import kanban_tools as kt - from hermes_cli import kanban_db as kb - - tokens = set_session_vars( - platform="telegram", - chat_id="1234567", - thread_id="42", - user_id="u_alice", - ) - try: - out = kt._handle_create({"title": "gateway task", "assignee": "peer"}) - d = json.loads(out) - assert d.get("ok") is True - assert d.get("subscribed") is True - conn = kb.connect() - try: - subs = kb.list_notify_subs(conn, d["task_id"]) - finally: - conn.close() - assert len(subs) == 1 - assert subs[0]["platform"] == "telegram" - assert subs[0]["chat_id"] == "1234567" - assert subs[0]["thread_id"] == "42" - assert subs[0]["user_id"] == "u_alice" - finally: - clear_session_vars(tokens) - - -def test_create_subscribe_without_thread_id(worker_env): - """DM / no-thread platforms subscribe without a thread_id.""" - from gateway.session_context import set_session_vars, clear_session_vars - from tools import kanban_tools as kt - from hermes_cli import kanban_db as kb - - tokens = set_session_vars(platform="discord", chat_id="ch_dm_789") - try: - out = kt._handle_create({"title": "dm task", "assignee": "peer"}) - d = json.loads(out) - assert d.get("subscribed") is True - conn = kb.connect() - try: - subs = kb.list_notify_subs(conn, d["task_id"]) - finally: - conn.close() - assert len(subs) == 1 - assert subs[0]["thread_id"] == "" - assert subs[0]["user_id"] is None - finally: - clear_session_vars(tokens) - - -def test_create_no_subscribe_when_chat_id_missing(worker_env): - """Partial gateway context (platform but no chat_id) -> no subscription.""" - from gateway.session_context import set_session_vars, clear_session_vars - from tools import kanban_tools as kt - from hermes_cli import kanban_db as kb - - tokens = set_session_vars(platform="telegram", chat_id="") - try: - out = kt._handle_create({"title": "partial ctx", "assignee": "peer"}) - d = json.loads(out) - assert d.get("subscribed") is False - conn = kb.connect() - try: - assert kb.list_notify_subs(conn, d["task_id"]) == [] - finally: - conn.close() - finally: - clear_session_vars(tokens) diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index baa5f2210a675..1f99f6896cb6d 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -380,40 +380,10 @@ def _handle_create(args: dict, **kw) -> str: skills=skills, created_by=os.environ.get("HERMES_PROFILE") or "worker", ) - # Auto-subscribe the originating gateway source (if any) to the - # new task's terminal events. Mirrors the behavior of the - # `/kanban create` slash command in gateway/run.py so that - # tool-driven creation (orchestrator agents calling kanban_create) - # gets the same blocked/completed/gave_up notifications as human- - # driven creation. No-op in CLI / cron contexts where no gateway - # session context is active. See issue #19479. - subscribed = False - try: - from gateway.session_context import get_session_env - platform = get_session_env("HERMES_SESSION_PLATFORM") - chat_id = get_session_env("HERMES_SESSION_CHAT_ID") - thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None - user_id = get_session_env("HERMES_SESSION_USER_ID") or None - if platform and chat_id: - kb.add_notify_sub( - conn, - task_id=new_tid, - platform=platform, - chat_id=chat_id, - thread_id=thread_id, - user_id=user_id, - ) - subscribed = True - except Exception: - # Subscription is best-effort; don't fail the whole create - # if the gateway context module isn't importable (e.g. in - # test rigs that stub out gateway.*). - logger.debug("kanban_create notify-sub skipped", exc_info=True) new_task = kb.get_task(conn, new_tid) return _ok( task_id=new_tid, status=new_task.status if new_task else None, - subscribed=subscribed, ) finally: conn.close() From f7577b4887398e84c8a31af595c422731cf19eec Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:17:50 -0700 Subject: [PATCH 2/2] feat(kanban-dashboard): per-platform home-channel notification toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Notify home channels" section to the task drawer in the kanban dashboard plugin. Each platform where the user has set a home channel (/sethome, TELEGRAM_HOME_CHANNEL env var, gateway.platforms.

.home_channel in config.yaml) gets a toggle pill. Toggling on writes a kanban_notify_subs row keyed to that platform's home (chat_id + thread_id); toggling off removes it. The existing gateway notifier watcher delivers completed / blocked / gave_up events without any new plumbing — this is purely a GUI surface over existing machinery. Replaces the reverted auto-subscribe behavior from #19718 with an explicit, per-task, per-platform, user-controlled opt-in. No implicit subscription on tool-driven kanban_create; no CLI commands; no slash commands. Just a toggle in the drawer. Backend (plugins/kanban/dashboard/plugin_api.py): - GET /api/plugins/kanban/home-channels[?task_id=X] Returns every platform with a configured home, plus a per-entry subscribed: bool relative to task_id (false when task_id omitted). Reads the live GatewayConfig via load_gateway_config() so env-var overlays stay honored. - POST /api/plugins/kanban/tasks/:id/home-subscribe/:platform Idempotent add_notify_sub keyed to the platform's home. - DELETE /api/plugins/kanban/tasks/:id/home-subscribe/:platform remove_notify_sub for the same tuple. - 404 when the platform has no home configured, or task_id doesn't exist (POST only). Frontend (plugins/kanban/dashboard/dist/index.js): - TaskDrawer fetches /home-channels on open, keyed on task_id. - HomeSubsSection renders nothing when zero platforms have a home (so users who haven't set one up don't see an empty UI block). - Optimistic toggle with busy flag + revert-on-failure. One pill per platform; ✓ prefix and --on class indicate the subscribed state. CSS (plugins/kanban/dashboard/dist/style.css): - .hermes-kanban-home-subs flex row + .hermes-kanban-home-sub pill style + --on subscribed variant (subtle ring-colored background). Live-tested against a dashboard with TELEGRAM + DISCORD_BOT_TOKEN / HOME_CHANNEL env vars set: drawer shows both pills, toggling each flips its visual state AND writes/removes the correct kanban_notify_subs row (verified via direct DB read). Tests (tests/plugins/test_kanban_dashboard_plugin.py, 11 new, 53/53 pass total): - home-channels lists only platforms with a home (slack with a token but no home is excluded) - no task_id -> all subscribed=false - subscribe creates notify_sub row with correct chat/thread/platform - subscribed=true reflected in subsequent GET - idempotent re-subscribe - unknown platform -> 404 - unknown task -> 404 - unsubscribe removes the row - telegram + discord subscribe/unsubscribe independent - zero homes -> empty list --- plugins/kanban/dashboard/dist/index.js | 96 +++++++++++ plugins/kanban/dashboard/dist/style.css | 20 +++ plugins/kanban/dashboard/plugin_api.py | 149 +++++++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 158 ++++++++++++++++++ 4 files changed, 423 insertions(+) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 3bdd92d47e1fb..d60bc19289557 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1375,6 +1375,11 @@ const [err, setErr] = useState(null); const [newComment, setNewComment] = useState(""); const [editing, setEditing] = useState(false); + // Home-channel notification toggles. homeChannels is the list of platforms + // the user has a /sethome on; each entry has a `subscribed` bool telling + // us whether this task is currently subscribed via that platform's home. + const [homeChannels, setHomeChannels] = useState([]); + const [homeBusy, setHomeBusy] = useState({}); const boardSlug = props.boardSlug; const load = useCallback(function () { @@ -1384,10 +1389,19 @@ .finally(function () { setLoading(false); }); }, [props.taskId, boardSlug]); + const loadHomeChannels = useCallback(function () { + const qs = new URLSearchParams({ task_id: props.taskId }); + const url = withBoard(`${API}/home-channels?${qs}`, boardSlug); + return SDK.fetchJSON(url) + .then(function (d) { setHomeChannels(d.home_channels || []); }) + .catch(function () { /* silent — endpoint optional on older gateways */ }); + }, [props.taskId, boardSlug]); + // Reload when the WS stream reports new events for this task id // (completion, block, crash, etc. — anything that'd make the drawer // show stale data if we only loaded on mount). useEffect(function () { load(); }, [load, props.eventTick]); + useEffect(function () { loadHomeChannels(); }, [loadHomeChannels]); useEffect(function () { function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); } window.addEventListener("keydown", onKey); @@ -1448,6 +1462,43 @@ .catch(function (e) { setErr(String(e.message || e)); }); }; + const toggleHomeSubscription = function (platform, currentlySubscribed) { + // Optimistic flip + busy flag to keep double-clicks idempotent. + setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); }); + setHomeChannels(function (list) { + return list.map(function (h) { + return h.platform === platform + ? Object.assign({}, h, { subscribed: !currentlySubscribed }) + : h; + }); + }); + const method = currentlySubscribed ? "DELETE" : "POST"; + const url = withBoard( + `${API}/tasks/${encodeURIComponent(props.taskId)}/home-subscribe/${encodeURIComponent(platform)}`, + boardSlug, + ); + return SDK.fetchJSON(url, { method: method }) + .then(function () { return loadHomeChannels(); }) + .catch(function (e) { + // Revert optimistic flip on failure. + setHomeChannels(function (list) { + return list.map(function (h) { + return h.platform === platform + ? Object.assign({}, h, { subscribed: currentlySubscribed }) + : h; + }); + }); + setErr(String(e.message || e)); + }) + .finally(function () { + setHomeBusy(function (b) { + const next = Object.assign({}, b); + delete next[platform]; + return next; + }); + }); + }; + return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose }, h("div", { className: "hermes-kanban-drawer", @@ -1474,6 +1525,9 @@ onRemoveParent: removeLink, onAddChild: addChild, onRemoveChild: removeChild, + homeChannels: homeChannels, + homeBusy: homeBusy, + onToggleHomeSub: toggleHomeSubscription, }) : null, data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, h(Input, { @@ -1535,6 +1589,11 @@ t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, ), h(StatusActions, { task: t, onPatch: props.onPatch }), + h(HomeSubsSection, { + homeChannels: props.homeChannels || [], + homeBusy: props.homeBusy || {}, + onToggle: props.onToggleHomeSub, + }), h(BodyEditor, { task: t, renderMarkdown: props.renderMarkdown, @@ -1950,6 +2009,43 @@ ); } + + // One toggle per gateway platform the user has a home channel set on + // (telegram, discord, slack, etc.). Toggling on creates a kanban_notify_subs + // row routed to that platform's home; toggling off removes it. Nothing + // renders when no platforms have a home configured — this section stays + // invisible for users who haven't set one up. + function HomeSubsSection(props) { + const channels = props.homeChannels || []; + if (channels.length === 0) return null; + const busy = props.homeBusy || {}; + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, + "Notify home channels"), + h("div", { className: "hermes-kanban-home-subs" }, + channels.map(function (hc) { + const isBusy = !!busy[hc.platform]; + const label = hc.subscribed ? "✓ " + hc.platform : hc.platform; + const title = hc.subscribed + ? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.` + : `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`; + return h(Button, { + key: hc.platform, + size: "sm", + title: title, + disabled: isBusy || !props.onToggle, + onClick: function () { + if (props.onToggle) props.onToggle(hc.platform, hc.subscribed); + }, + className: hc.subscribed + ? "hermes-kanban-home-sub hermes-kanban-home-sub--on" + : "hermes-kanban-home-sub", + }, label); + }) + ) + ); + } + // ------------------------------------------------------------------------- // Register // ------------------------------------------------------------------------- diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 3c197e6209536..34fc714d118fb 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -351,6 +351,26 @@ gap: 0.3rem; } +/* ---- Home channel subscription toggles (per-platform, per-task) ----- */ + +.hermes-kanban-home-subs { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.hermes-kanban-home-sub { + font-family: var(--font-mono, ui-monospace, monospace); + text-transform: lowercase; + letter-spacing: 0.02em; +} +.hermes-kanban-home-sub--on { + /* Subtly indicate the subscribed state without a hard color change so + * dashboard themes stay coherent. Border + tinted background. */ + border-color: color-mix(in srgb, var(--color-ring) 55%, var(--color-border)); + background: color-mix(in srgb, var(--color-ring) 14%, transparent); + color: var(--color-foreground); +} + .hermes-kanban-section { display: flex; flex-direction: column; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 1c25f372e61f0..2378baaac7aca 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -733,6 +733,155 @@ def get_config(): } +# --------------------------------------------------------------------------- +# Home-channel subscriptions (per-task, per-platform toggles) +# --------------------------------------------------------------------------- +# +# Home channels are a first-class gateway concept — each configured platform +# can have exactly one (chat_id, thread_id, name) it considers "home". The +# dashboard surfaces these as per-task toggles so a user can opt a specific +# task into receiving terminal notifications (completed / blocked / gave_up) +# at their telegram/discord/slack home, without touching the CLI. +# +# The wire format mirrors kanban_db.add_notify_sub — (task_id, platform, +# chat_id, thread_id) — so toggle-on creates exactly the same row the +# `/kanban create` slash command would, and the existing gateway notifier +# watcher delivers events without any additional plumbing. + + +def _configured_home_channels() -> list[dict]: + """Return every platform that has a home_channel set, fully hydrated. + + Reads the live GatewayConfig so env-var overlays (``TELEGRAM_HOME_CHANNEL`` + etc.) are honored alongside config.yaml. Returns platforms in a stable + order and drops platforms without a home. + """ + try: + from gateway.config import load_gateway_config + except Exception: + return [] + try: + gw_cfg = load_gateway_config() + except Exception: + return [] + result: list[dict] = [] + for platform, pcfg in gw_cfg.platforms.items(): + if not pcfg or not pcfg.home_channel: + continue + hc = pcfg.home_channel + result.append({ + "platform": platform.value, + "chat_id": hc.chat_id, + "thread_id": hc.thread_id or "", + "name": hc.name or "Home", + }) + # Stable order for deterministic UI — platform name alphabetical. + result.sort(key=lambda r: r["platform"]) + return result + + +def _home_sub_matches(sub: dict, home: dict) -> bool: + """True if a notify_subs row corresponds to the given home channel.""" + return ( + sub.get("platform") == home["platform"] + and str(sub.get("chat_id", "")) == str(home["chat_id"]) + and str(sub.get("thread_id") or "") == str(home["thread_id"] or "") + ) + + +@router.get("/home-channels") +def get_home_channels( + task_id: Optional[str] = Query(None), + board: Optional[str] = Query(None), +): + """List every platform with a home channel, plus whether *task_id* + (if given) is currently subscribed to that home. + + When ``task_id`` is omitted, every entry's ``subscribed`` is ``false`` + — useful for the "no task selected" state of the UI. + """ + homes = _configured_home_channels() + subscribed_homes: set[tuple[str, str, str]] = set() + if task_id: + board = _resolve_board(board) + conn = _conn(board=board) + try: + subs = kanban_db.list_notify_subs(conn, task_id) + finally: + conn.close() + for sub in subs: + key = ( + str(sub.get("platform") or ""), + str(sub.get("chat_id") or ""), + str(sub.get("thread_id") or ""), + ) + subscribed_homes.add(key) + result = [] + for home in homes: + key = (home["platform"], home["chat_id"], home["thread_id"]) + result.append({**home, "subscribed": key in subscribed_homes}) + return {"home_channels": result} + + +@router.post("/tasks/{task_id}/home-subscribe/{platform}") +def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)): + """Subscribe *task_id* to notifications routed to *platform*'s home channel. + + Idempotent — re-subscribing is a no-op at the DB layer. 404 if the + platform has no home channel configured. 404 if the task doesn't exist. + """ + homes = _configured_home_channels() + home = next((h for h in homes if h["platform"] == platform), None) + if not home: + raise HTTPException( + status_code=404, + detail=f"No home channel configured for platform {platform!r}. " + f"Set one from the messenger via /sethome, or configure " + f"gateway.platforms.{platform}.home_channel in config.yaml.", + ) + board = _resolve_board(board) + conn = _conn(board=board) + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + kanban_db.add_notify_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=home["chat_id"], + thread_id=home["thread_id"] or None, + ) + return {"ok": True, "task_id": task_id, "home_channel": home} + finally: + conn.close() + + +@router.delete("/tasks/{task_id}/home-subscribe/{platform}") +def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)): + """Remove any notify subscription on *task_id* that matches *platform*'s home.""" + homes = _configured_home_channels() + home = next((h for h in homes if h["platform"] == platform), None) + if not home: + raise HTTPException( + status_code=404, + detail=f"No home channel configured for platform {platform!r}.", + ) + board = _resolve_board(board) + conn = _conn(board=board) + try: + kanban_db.remove_notify_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=home["chat_id"], + thread_id=home["thread_id"] or None, + ) + return {"ok": True, "task_id": task_id, "home_channel": home} + finally: + conn.close() + + # --------------------------------------------------------------------------- # Stats (per-profile / per-status counts + oldest-ready age) # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 0055fc80f0481..23589ce6909af 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -914,3 +914,161 @@ def _raise(): ) assert r.status_code == 200 assert r.json()["task"]["title"] == "resilient" + + + +# --------------------------------------------------------------------------- +# Home-channel subscription endpoints (#19534 follow-up: GUI opt-in) +# --------------------------------------------------------------------------- +# +# Dashboard surface for per-task, per-platform notification toggles. The +# backend endpoints read the live GatewayConfig, so tests set env vars +# (BOT_TOKEN + HOME_CHANNEL) to simulate a user who has run /sethome on +# telegram and discord. + + +@pytest.fixture +def with_home_channels(monkeypatch): + """Simulate a user with home channels set on telegram and discord.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc:fake") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "1234567") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_THREAD_ID", "42") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_NAME", "Main TG") + monkeypatch.setenv("DISCORD_BOT_TOKEN", "disc_fake") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "9999999") + monkeypatch.setenv("DISCORD_HOME_CHANNEL_NAME", "Main Discord") + # Slack has a token but NO home — should be excluded from the list. + monkeypatch.setenv("SLACK_BOT_TOKEN", "slack_fake") + + +def test_home_channels_lists_only_platforms_with_home(client, with_home_channels): + """GET /home-channels returns entries only for platforms where the + user has set a home; untoggled-subscribed bool is false by default.""" + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + platforms = {h["platform"] for h in r.json()["home_channels"]} + assert platforms == {"telegram", "discord"}, ( + f"slack has a token but no home — must not appear. got {platforms}" + ) + for h in r.json()["home_channels"]: + assert h["subscribed"] is False + + +def test_home_channels_no_task_id_all_unsubscribed(client, with_home_channels): + """Without task_id, every entry's subscribed=false (UI "no task" state).""" + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + assert all(not h["subscribed"] for h in r.json()["home_channels"]) + + +def test_home_subscribe_creates_notify_sub_row(client, with_home_channels): + """POST .../home-subscribe/telegram writes a kanban_notify_subs row + keyed to the telegram home's (chat_id, thread_id).""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + + r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + assert r.status_code == 200 + assert r.json()["ok"] is True + + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, t["id"]) + finally: + conn.close() + assert len(subs) == 1 + assert subs[0]["platform"] == "telegram" + assert subs[0]["chat_id"] == "1234567" + assert subs[0]["thread_id"] == "42" + + +def test_home_subscribe_flips_subscribed_flag_in_subsequent_get(client, with_home_channels): + """After subscribe, the GET endpoint reports subscribed=true for that + platform and false for the others.""" + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + + r = client.get(f"/api/plugins/kanban/home-channels?task_id={t['id']}") + flags = {h["platform"]: h["subscribed"] for h in r.json()["home_channels"]} + assert flags == {"telegram": True, "discord": False} + + +def test_home_subscribe_is_idempotent(client, with_home_channels): + """Re-subscribing keeps a single row at the DB layer.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + conn = kb.connect() + try: + assert len(kb.list_notify_subs(conn, t["id"])) == 1 + finally: + conn.close() + + +def test_home_subscribe_unknown_platform_returns_404(client, with_home_channels): + """Platforms without a home configured (slack in the fixture) return 404.""" + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/slack") + assert r.status_code == 404 + assert "slack" in r.json()["detail"] + + +def test_home_subscribe_unknown_task_returns_404(client, with_home_channels): + r = client.post("/api/plugins/kanban/tasks/t_nonexistent/home-subscribe/telegram") + assert r.status_code == 404 + + +def test_home_unsubscribe_removes_notify_sub_row(client, with_home_channels): + """DELETE .../home-subscribe/telegram removes the matching row.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + assert r.status_code == 200 + + conn = kb.connect() + try: + assert kb.list_notify_subs(conn, t["id"]) == [] + finally: + conn.close() + + +def test_home_subscribe_multiple_platforms_independent(client, with_home_channels): + """Subscribing on telegram does not affect discord and vice versa.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/discord") + + conn = kb.connect() + try: + subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])} + finally: + conn.close() + assert set(subs) == {"telegram", "discord"} + + # Unsubscribe telegram only. + client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + conn = kb.connect() + try: + subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])} + finally: + conn.close() + assert set(subs) == {"discord"} + + +def test_home_channels_empty_when_no_homes_configured(client, monkeypatch): + """Zero platforms with a home -> empty list (UI hides the section).""" + # No BOT_TOKEN env vars set → load_gateway_config().platforms is empty. + for var in [ + "TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL", + "DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL", + "SLACK_BOT_TOKEN", + ]: + monkeypatch.delenv(var, raising=False) + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + assert r.json()["home_channels"] == []