diff --git a/codec_agent_messaging.py b/codec_agent_messaging.py index 8365af9..e890dba 100644 --- a/codec_agent_messaging.py +++ b/codec_agent_messaging.py @@ -214,6 +214,22 @@ def post_message(agent_id: str, type: str, title: str, body: str, _atomic_write_json(_NOTIFICATIONS_PATH, notifs) + # Phase 3.5 — multi-channel notification dispatch. + # Reads agent's notification_channels from manifest. Each non-`pwa` + # channel gets its own dispatch (best-effort; failures don't block). + if not is_silenced(agent_id): + try: + channels = _agent_notification_channels(agent_id) + for ch in channels: + if ch == "pwa": + continue # already covered by notifications.json above + try: + _dispatch_to_channel(ch, agent_id, title, body, type) + except Exception as e: + log.debug("[%s] channel %s dispatch failed: %s", agent_id, ch, e) + except Exception as e: + log.debug("[%s] channel dispatch wrapper failed: %s", agent_id, e) + # Audit emit _audit(AGENT_MESSAGE_SENT, message=f"{type} for {agent_id}", correlation_id=correlation_id, @@ -222,6 +238,106 @@ def post_message(agent_id: str, type: str, title: str, body: str, return record +def _agent_notification_channels(agent_id: str) -> List[str]: + """Read manifest.notification_channels. Defaults to ['pwa'].""" + manifest_path = _AGENTS_DIR / agent_id / "manifest.json" + if not manifest_path.exists(): + return ["pwa"] + try: + data = json.loads(manifest_path.read_text()) + chs = data.get("notification_channels") or ["pwa"] + return [c for c in chs if isinstance(c, str)] or ["pwa"] + except Exception: + return ["pwa"] + + +def _dispatch_to_channel(channel: str, agent_id: str, + title: str, body: str, msg_type: str) -> None: + """Best-effort dispatch to a single channel. Raises on hard failures. + + Supported channels: + - "macos": macOS notification banner via osascript display notification + - "imessage": send via codec_imessage.send_message helper if available + - "telegram": send via codec_telegram.send_message helper if available + + Phase 3.5 multi-channel notifications. Each channel is OPTIONAL — + if the underlying tooling isn't configured, dispatch is a no-op. + """ + short_body = (body or "")[:200] + short_title = (title or f"CODEC agent {agent_id}")[:80] + + if channel == "macos": + # macOS notification banner via osascript. No external dependencies. + import subprocess + # Sanitize for AppleScript single-quoting + def _esc(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"') + script = ( + f'display notification "{_esc(short_body)}" ' + f'with title "{_esc(short_title)}" ' + f'subtitle "agent: {_esc(agent_id)}"' + ) + subprocess.run(["osascript", "-e", script], timeout=5, + capture_output=True, check=False) + return + + if channel == "imessage": + # Reuse the imessage_send skill's _send helper. Recipient is read + # from ~/.codec/config.json:notifications.imessage_recipient + # (phone number or Apple ID). If unset, skip silently. + recipient = _channel_config("imessage_recipient") + if not recipient: + log.debug("notifications.imessage_recipient not configured; skipping imessage") + return + try: + import sys as _sys + from pathlib import Path as _Path + skills_dir = str(_Path(__file__).resolve().parent / "skills") + if skills_dir not in _sys.path: + _sys.path.insert(0, skills_dir) + import imessage_send as _ims + _ims._send(recipient, f"[{short_title}]\n{short_body}") + except Exception as e: + log.debug("imessage send failed: %s", e) + return + + if channel == "telegram": + # Send via Telegram Bot API directly (avoids tight coupling to + # codec_telegram.py's daemon internals). Reads token + chat_id + # from ~/.codec/config.json:notifications.{telegram_token,telegram_chat_id}. + token = _channel_config("telegram_token") + chat_id = _channel_config("telegram_chat_id") + if not token or not chat_id: + log.debug("notifications.telegram_{token,chat_id} not configured; skipping telegram") + return + try: + import requests + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + json={"chat_id": chat_id, + "text": f"*{short_title}*\n{short_body}", + "parse_mode": "Markdown"}, + timeout=5, + ) + except Exception as e: + log.debug("telegram send failed: %s", e) + return + + log.debug("unknown notification channel: %s", channel) + + +def _channel_config(key: str) -> str: + """Read ~/.codec/config.json:notifications.. Empty string if unset.""" + cfg_path = _CODEC_DIR / "config.json" + if not cfg_path.exists(): + return "" + try: + data = json.loads(cfg_path.read_text()) + return str((data.get("notifications") or {}).get(key) or "") + except Exception: + return "" + + def post_user_reply(agent_id: str, body: str) -> Dict[str, Any]: """User → agent reply. Written to messages.jsonl with type=user_reply. Daemon picks up next tick, feeds to next _qwen_next_action call. diff --git a/tests/test_agent_messaging.py b/tests/test_agent_messaging.py index d43cca3..23e25d2 100644 --- a/tests/test_agent_messaging.py +++ b/tests/test_agent_messaging.py @@ -377,3 +377,71 @@ def test_post_api_agents_silence_toggles_state(temp_codec_dir): r2 = client.post("/api/agents/a1/silence", json={"silenced": False}) assert r2.status_code == 200 assert cam.is_silenced("a1") is False + + +# ───────────────────────────────────────────────────────────────────────────── +# Phase 3.5 — Multi-channel notification dispatch (5 tests) +# ───────────────────────────────────────────────────────────────────────────── + +def test_pwa_only_default_no_extra_dispatch(monkeypatch, temp_codec_dir): + """Default channels=['pwa']: no macOS/iMessage/Telegram dispatch.""" + import codec_agent_messaging as cam + import codec_agent_plan as cap + cap.save_manifest("a1", {"agent_id": "a1", "title": "x", + "notification_channels": ["pwa"]}) + dispatched = [] + monkeypatch.setattr(cam, "_dispatch_to_channel", + lambda ch, *a, **k: dispatched.append(ch)) + cam.post_message(agent_id="a1", type="agent_update", title="t", + body="b", actions=[], correlation_id="cid") + assert dispatched == [] # pwa is skipped (handled inline) + + +def test_macos_channel_dispatched_when_configured(monkeypatch, temp_codec_dir): + """When manifest includes 'macos', _dispatch_to_channel called for it.""" + import codec_agent_messaging as cam + import codec_agent_plan as cap + cap.save_manifest("a1", {"agent_id": "a1", "title": "x", + "notification_channels": ["pwa", "macos"]}) + dispatched = [] + monkeypatch.setattr(cam, "_dispatch_to_channel", + lambda ch, *a, **k: dispatched.append(ch)) + cam.post_message(agent_id="a1", type="agent_update", title="t", + body="b", actions=[], correlation_id="cid") + assert dispatched == ["macos"] + + +def test_dispatch_to_channel_macos_invokes_osascript(monkeypatch, temp_codec_dir): + """_dispatch_to_channel('macos', ...) builds an osascript command and runs it.""" + import codec_agent_messaging as cam + captured = {"args": None} + class FakeProc: + returncode = 0 + def fake_run(args, **kw): + captured["args"] = list(args) + return FakeProc() + import subprocess + monkeypatch.setattr(subprocess, "run", fake_run) + cam._dispatch_to_channel("macos", "agent_test", "Test title", "Test body", "agent_update") + assert captured["args"] is not None + assert captured["args"][0] == "osascript" + assert "-e" in captured["args"] + # AppleScript text contains the title + body somewhere + full = " ".join(captured["args"]) + assert "Test title" in full + assert "Test body" in full + + +def test_imessage_channel_skipped_when_recipient_unset(monkeypatch, temp_codec_dir): + """Without config notifications.imessage_recipient, channel is no-op (no exception).""" + import codec_agent_messaging as cam + # No config.json → _channel_config returns "" + cam._dispatch_to_channel("imessage", "agent_test", "T", "B", "agent_update") + # Should not raise; should not call any send + + +def test_telegram_channel_skipped_when_unconfigured(monkeypatch, temp_codec_dir): + """Without telegram token/chat_id, channel is no-op.""" + import codec_agent_messaging as cam + cam._dispatch_to_channel("telegram", "agent_test", "T", "B", "agent_update") + # Should not raise