diff --git a/plugins/platforms/carbonvoice/__init__.py b/plugins/platforms/carbonvoice/__init__.py new file mode 100644 index 000000000000..b15fded8738e --- /dev/null +++ b/plugins/platforms/carbonvoice/__init__.py @@ -0,0 +1,3 @@ +from .setup import register + +__all__ = ["register"] diff --git a/plugins/platforms/carbonvoice/adapter.py b/plugins/platforms/carbonvoice/adapter.py new file mode 100644 index 000000000000..16b51e595e44 --- /dev/null +++ b/plugins/platforms/carbonvoice/adapter.py @@ -0,0 +1,2303 @@ +"""Carbon Voice platform adapter for Hermes Agent. + +Architecture: + Hermes <──Socket.IO (primary)── api.carbonvoice.app + Hermes ── REST poll fallback ──> /v3/messages/recent + Hermes ── POST /v3/messages/start ──> outbound replies + +This module is the thin orchestrator that wires together: + + parse — payload-shape helpers (pure) + api — REST client (CarbonVoiceAPI) + transport — Socket.IO + polling lifecycle (Transport) + state — disk-persisted cursor (Cursor) + dedupe — in-memory seen-message TTL cache (SeenCache) + reactions — visual ack on inbound (ReactionService) + channels — chat_type ("dm"/"group") + participant roster cache + audit — allowlist gate + ignored-sender audit log + +No public webhook is required — the adapter holds an outbound Socket.IO +connection and polls /v3/messages/recent as a fallback. Cursor state is +persisted to disk so messages received while Hermes was offline are +processed on the next startup. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import mimetypes +import os +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, Optional + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.session import SessionSource + +from .api import CarbonVoiceAPI +from .audit import AllowlistGate, IgnoredSenderLog, default_ignored_log_path +from .permits import ApprovalStore, parse_admin_command +from .channels import ChannelCache +from .conversations import ConversationTracker +from .constants import ( + DEFAULT_APPROVE_REACTION_ID, + DEFAULT_BASE_URL, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_REJECT_REACTION_ID, + DEFAULT_REVISIT_MAX_AGE_S, + DEFAULT_STUCK_MAX_AGE_S, + DEFAULT_WS_RETRY_MAX_MS, + MAX_MESSAGE_LENGTH, + STUCK_RETRY_DELAY_S, +) +from .dedupe import SeenCache +from .gate import MentionGate +from .parse import ( + bot_has_reacted, + extract_attachments, + extract_channel_id, + extract_creator_id, + extract_message_id, + extract_share_link_id, + extract_transcript, + first_str, + message_age_seconds, + now_iso, + now_utc, + reactors_for, +) +# extract_transcript is also re-exported via parse for the parent-text path +# (now handled by ConversationTracker, but the import here is kept so +# extract_transcript stays available for any future inline use). +from .reactions import ReactionService +from .state import Cursor, default_state_path +from .transport import Transport + +logger = logging.getLogger(__name__) + + +class CarbonVoiceAdapter(BasePlatformAdapter): + """Hermes ↔ Carbon Voice bridge over Socket.IO + REST polling fallback.""" + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + + # Cap on tracked one-tap approval prompts (prompt msg_id → creator_id). + # A flood of unknown senders can't grow this unbounded; oldest evict. + _MAX_PENDING_PROMPTS = 200 + + # Carbon Voice has no in-place message edit API — a "reply" is always a + # new message. Declaring this False (like Signal / Weixin / WeCom) tells + # the core's stream consumer NOT to attempt progressive edits, so it uses + # the send-once path instead of editing a streamed bubble. Without it the + # consumer keeps an editable-message assumption that, when a send fails + # (e.g. CV returns 502), leaves "final delivery" unconfirmed and the core + # re-sends the same response once per queued follow-up — the observed + # "same message multiple times" duplication. + SUPPORTS_MESSAGE_EDITING = False + + # Voice-out integration with Hermes core's auto-TTS pipeline. + # + # When core generates a TTS audio for the agent's reply and ships it + # via ``send_voice`` → ``/v5/messages/audio``, Carbon Voice runs + # server-side STT and renders the resulting message as a voice-memo + # bubble with the transcript inline. That means the spoken text IS + # the visible text — sending the same content again as a text bubble + # is pure duplication. + # + # ``voice_out_carries_text = True`` tells Hermes core (see + # ``gateway/platforms/base.py``'s ``_tts_caption_delivered`` check) + # to suppress the follow-up text send when auto-TTS succeeded. + # Conceptually it's the CV analog of Telegram's caption field on + # voice messages — different mechanism (STT vs caption), same UX + # contract (one bubble, text + audio together). + # + # The general delivery contract that honors this flag (suppressing + # the follow-up text send after a successful play_tts, with a + # completeness guard so truncated speech never drops content) is + # PR #32655. Until it lands, the attribute is ignored and voice-out + # delivers the audio memo plus a duplicate text bubble — degraded + # UX, never lost content. + voice_out_carries_text = True + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("carbonvoice")) + extra = config.extra or {} + pat: str = config.token or extra.get("pat") or "" + base_url: str = (extra.get("base_url") or DEFAULT_BASE_URL).rstrip("/") + poll_interval_s: float = ( + float(extra.get("poll_interval_ms") or DEFAULT_POLL_INTERVAL_MS) / 1000.0 + ) + ws_retry_max_s: float = ( + float(extra.get("ws_retry_max_ms") or DEFAULT_WS_RETRY_MAX_MS) / 1000.0 + ) + sp = extra.get("state_path") + state_path: Path = Path(sp).expanduser() if sp else default_state_path() + ilp = extra.get("ignored_senders_log") + ignored_log_path: Path = ( + Path(ilp).expanduser() if ilp else default_ignored_log_path() + ) + + self._pat = pat + self._creator_id: Optional[str] = extra.get("creator_id") or None + self._self_user_id: Optional[str] = None + self._mark_read_enabled: bool = not bool(extra.get("disable_mark_read")) + # Voice-out: when true, every inbound MessageEvent is marked + # ``MessageType.VOICE`` so Hermes core's auto-TTS pipeline + # (``base.py:3493``) converts the agent's text reply to audio + # and ships it via :meth:`send_voice` → ``/v5/messages/audio``. + # Requires ``voice.auto_tts: true`` and a TTS provider in + # ``config.yaml`` to actually fire — without those, marking + # VOICE is a no-op (the gate's other conditions still fail). + # Default ``False`` to preserve text-out for existing + # deployments that haven't opted in. + self._voice_out: bool = bool(extra.get("voice_out")) + # Inbound multimodal (PR 7): per-attachment byte cap. CV's S3 + # URLs can hand back arbitrarily large files, and Hermes core's + # vision / document pipeline pays per token for image bytes and + # extracted text — a 50MB PDF blowing through the size limit + # crashes the agent's API call. Default 10 MB matches what + # Claude / OpenAI vision recommend; operators can raise it for + # specialized use cases via ``CARBONVOICE_MAX_ATTACHMENT_MB``. + self._max_attachment_bytes: int = int( + extra.get("max_attachment_mb") or 10 + ) * 1024 * 1024 + # Stuck-message cutoff: a message with no transcript holds the cursor + # (gets retried) only while younger than this. Past it we assume the + # transcript will never arrive (image-only / system / failed STT) and + # advance past it, so a single permanently-empty message can't pin the + # cursor and re-feed the whole window on every poll/restart. + self._stuck_max_age_s: float = float( + extra.get("stuck_max_age_s") + or os.environ.get("CARBONVOICE_STUCK_MAX_AGE_S") + or DEFAULT_STUCK_MAX_AGE_S + ) + # Revisit window for tag-lagged voice messages: a *voice* message in + # a group channel gets its ``tagged_user_ids`` ~10–30s after creation + # (Flutter applies the picker tags via the batch PUT only once STT + # finishes). A gate rejection for "no mention" on such a message is + # therefore provisional — we hold the cursor (stuck signal) while the + # message is younger than this, so the next tick re-fetches and + # re-evaluates with the by-then-populated array. Without the hold, + # the tag-set ``message:updated`` is the LAST event that message ever + # emits — one stale read (or one coalesced-away tick) and the cursor + # is past it forever (observed live: tag landed at +30s, bot never + # answered). Text messages carry tags at create time and never hold. + self._revisit_max_age_s: float = float( + extra.get("revisit_max_age_s") + or os.environ.get("CARBONVOICE_REVISIT_MAX_AGE_S") + or DEFAULT_REVISIT_MAX_AGE_S + ) + # Outbound dedup: defense-in-depth against the core re-sending the + # same response once per queued follow-up when delivery confirmation + # is lost (e.g. CV 502 mid-stream). Keyed by channel → (text-hash, + # monotonic ts); a repeat of the SAME text to the SAME channel within + # the window is dropped. Independent of SUPPORTS_MESSAGE_EDITING, so + # it also covers cores/paths we don't control. Window is short so a + # user legitimately repeating themselves isn't blocked for long. + self._send_dedup_window_s: float = float( + extra.get("send_dedup_window_s") + or os.environ.get("CARBONVOICE_SEND_DEDUP_WINDOW_S") + or 90 + ) + self._last_sent: Dict[str, "tuple[str, float]"] = {} + # Serialize message fetches. Every WS ``message:created`` / + # ``message:updated`` event (and each reconnect) fires on_tick → + # _fetch_missed_messages. A burst of events would otherwise run many + # overlapping fetches over the SAME cursor window in parallel, each + # re-processing the same messages — a key amplifier of the + # duplicate-processing bursts. The lock makes fetches mutually + # exclusive; _fetch_missed_messages coalesces overlapping ticks to a + # single *trailing* re-fetch (``_tick_pending``) — never a plain + # drop, because the event that fired mid-flight may announce a write + # (e.g. the tag-resolution PUT) that the in-flight fetch's HTTP query + # predates. Dropping it would lose the only re-fire that message + # ever gets. + self._fetch_lock = asyncio.Lock() + self._tick_pending = False + # One-shot delayed re-tick while something is stuck (no-transcript + # or revisit-held messages). In WS mode polling is stopped, so + # without this a held message would only retry when the *next* + # unrelated event happens to arrive — potentially much later on a + # quiet workspace. + self._stuck_retry_task: Optional[asyncio.Task] = None + + self._api = CarbonVoiceAPI(pat, base_url) if pat and HTTPX_AVAILABLE else None + self._cursor = Cursor(state_path) + self._seen = SeenCache() + self._transport = Transport( + base_url=base_url, + pat=pat, + poll_interval_s=poll_interval_s, + ws_retry_max_s=ws_retry_max_s, + on_tick=self._fetch_missed_messages, + ) + self._channels = ChannelCache(self._api) if self._api else None + self._reactions = ( + ReactionService( + self._api, + reaction_id=extra.get("reaction_id"), + enabled=not bool(extra.get("disable_ack_reaction")), + pending_reaction_id=( + extra.get("pending_reaction_id") + or os.environ.get("CARBONVOICE_PENDING_REACTION_ID") + or None + ), + ) + if self._api + else None + ) + # Dynamic allow-list (Hermes core's PairingStore) + deny-by-default + # gate. The owner is filled in at connect() from whoami.created_by. + self._approvals = ApprovalStore() + self._allowlist = AllowlistGate.from_env(self._approvals) + self._gate = MentionGate.from_env() + self._ignored_log = ( + IgnoredSenderLog(ignored_log_path, self._channels) + if self._channels + else None + ) + + # Interactive onboarding: the channel where the agent asks the owner + # to approve unknown senders. ``home_channel`` falls back to the + # legacy CARBONVOICE_HOME_CHANNEL env if not in ``extra``. + self._home_channel: Optional[str] = ( + first_str(extra.get("home_channel")) + or first_str(os.environ.get("CARBONVOICE_HOME_CHANNEL")) + ) + # Per-process record of unauthorized senders we've prompted about: + # ``user_id → {"channel": , "notified_at": }``. + # Rate-limits the owner prompt (and the "request sent" reply to the + # sender) to once per cooldown, and remembers the channel so + # /cv-allow-user can resolve their name. /cv-deny-user CLEARS the + # entry (rather than silencing it) so a denied user can ask again + # and the owner is re-notified — the add/remove cycle stays open. + self._pending_approval: Dict[str, Dict[str, Any]] = {} + self._approval_cooldown_s: int = int( + extra.get("approval_notify_cooldown_s") + or os.environ.get("CARBONVOICE_APPROVAL_COOLDOWN_S") + or 1800 # 30 min + ) + # One-tap owner approval: maps the bot's prompt message_id → the + # creator_id it asks about, so when the owner reacts 👍/👎 on that + # prompt we know who to approve/deny without them typing the id. + # Mirrors cv-claude-channels' pendingPermissionMessages. Bounded by + # _MAX_PENDING_PROMPTS so a flood of strangers can't grow it forever. + self._pending_prompts: "OrderedDict[str, str]" = OrderedDict() + self._approve_reaction_id: str = ( + extra.get("approve_reaction_id") + or os.environ.get("CARBONVOICE_APPROVE_REACTION_ID") + or DEFAULT_APPROVE_REACTION_ID + ) + self._reject_reaction_id: str = ( + extra.get("reject_reaction_id") + or os.environ.get("CARBONVOICE_REJECT_REACTION_ID") + or DEFAULT_REJECT_REACTION_ID + ) + + # Per-thread reply anchors + parent-text cache + (eventually) + # engagement / outbound tracking. See conversations.py and + # DEVELOPMENT.md §7.5 for the design. + self._tracker = ConversationTracker(self._api) + + # ── Lifecycle ──────────────────────────────────────────────────────── + + async def connect(self, *, is_reconnect: bool = False) -> bool: + # ``is_reconnect`` (cold boot vs gateway-driven reconnect) needs no + # special handling here: the disk cursor makes both paths identical — + # we always catch up from the last processed message either way. + if not self._pat or self._api is None: + logger.error("carbonvoice: CARBONVOICE_PAT not set") + return False + + # Credential-scoped lock: prevent two gateways (e.g. different + # HERMES_HOME dirs) from claiming the same PAT at once. Same-pid + # re-acquire is allowed, so reconnect retries don't self-block. + if not self._acquire_platform_lock( + "carbonvoice-pat", self._pat, "Carbon Voice PAT" + ): + return False + + await self._api.open() + + try: + self._self_user_id, owner_id = await self._api.whoami() + except Exception as exc: + logger.error("carbonvoice: /whoami failed: %s", exc) + await self._api.close() + return False + if not self._self_user_id: + logger.error("carbonvoice: /whoami returned no user id") + await self._api.close() + return False + # From here on every request also carries agent-id, so the backend + # can attribute traffic to this specific agent account. + self._api.set_agent_id(self._self_user_id) + + # Deny-by-default: the bot's creator (whoami.created_by) is the + # owner — always authorized, and the seed from which they approve + # everyone else via /cv-allow. Auto-detected so the security + # default needs no manual setup. + self._allowlist.set_owner(owner_id) + if owner_id: + logger.info("carbonvoice: owner is %s (auto-detected from created_by)", owner_id) + # Mirror the owner into the dynamic allow-list (PairingStore). + # Hermes core's own authorization checks the pairing store for + # every platform but doesn't know about `created_by`, so without + # this the owner could pass the plugin gate yet be blocked by + # core. Idempotent. + if self._approvals.approve(owner_id, "owner"): + logger.info("carbonvoice: owner mirrored into pairing store") + else: + logger.warning( + "carbonvoice: could NOT mirror owner into pairing store " + "(PairingStore available=%s) — core may block the owner", + self._approvals.available, + ) + if not self._allowlist.has_any_authorizer: + logger.warning( + "carbonvoice: deny-by-default is active but NO authorized " + "users — whoami returned no owner and CARBONVOICE_ALLOWED_USERS " + "is empty. The bot will ignore everyone. Set " + "CARBONVOICE_ALLOWED_USERS to your user_guid, or " + "CARBONVOICE_ALLOW_ALL_USERS=true to disable gating." + ) + + if self._reactions is not None: + await self._reactions.discover() + + await self._cursor.load() + + try: + await self._fetch_missed_messages() + except Exception as exc: + logger.warning("carbonvoice: initial catch-up failed: %s", exc) + + await self._transport.start() + + self._mark_connected() + logger.info( + "carbonvoice: connected as %s (mode=%s, state=%s)", + self._self_user_id, self._transport.mode, self._cursor.path, + ) + return True + + async def disconnect(self) -> None: + if self._stuck_retry_task is not None and not self._stuck_retry_task.done(): + self._stuck_retry_task.cancel() + self._stuck_retry_task = None + await self._transport.stop() + await self._cursor.stop() + if self._api is not None: + await self._api.close() + self._release_platform_lock() + self._mark_disconnected() + + # ── Outbound (Hermes → Carbon Voice) ───────────────────────────────── + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if self._api is None: + return SendResult(success=False, error="adapter not connected") + if not content or not content.strip(): + return SendResult(success=False, error="empty content") + + # Outbound dedup: drop an identical re-send to the same channel inside + # the dedup window. The core re-sends the same "first response" once + # per queued follow-up when streaming delivery wasn't confirmed (CV + # 502s make this common); without this guard the user sees the same + # reply many times. We report success (not failure) so the core + # treats it as delivered and stops retrying. Keyed by an order-stable + # hash of the exact text. + dedup_key = chat_id or "" + content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() + if dedup_key: + prev = self._last_sent.get(dedup_key) + now_m = time.monotonic() + if ( + prev is not None + and prev[0] == content_hash + and now_m - prev[1] < self._send_dedup_window_s + ): + logger.info( + "carbonvoice: suppressed duplicate send to %s " + "(same text within %.0fs dedup window)", + dedup_key, self._send_dedup_window_s, + ) + return SendResult(success=True, message_id=None) + + # v5 transport: the resolved thread root is sent to the server as + # ``reply_to_message_id`` (cv-api PR #277 renamed the old + # ``thread_id`` input). The server resolves threading itself — + # ``resolveRootParentMessageId`` roots whatever id we pass, so + # sending the thread root keeps it the root and no client-side + # reply-anchor lookup is required. + # + # ``thread_id`` priority (Hermes-side concept; the value becomes + # the wire ``reply_to_message_id`` below): + # 1. ``metadata['thread_id']`` — populated by Hermes core from + # ``SessionSource.thread_id`` for group messages. + # 2. ``reply_to`` from the caller — used as a fallback when no + # thread context exists (DMs keep thread_id=None on + # ``SessionSource`` to preserve one-session-per-DM-pair). + thread_id = (metadata or {}).get("thread_id") or reply_to + + try: + data = await self._api.send_text_v5( + conversation_id=chat_id, + transcript=content, + reply_to_message_id=thread_id, + ) + msg_id = first_str(data.get("id"), data.get("message_id")) + # Record for outbound dedup only on a real, successful send — a + # failed send must NOT prime the dedup (else a legit retry of a + # genuinely-undelivered message would be suppressed). + if dedup_key: + self._last_sent[dedup_key] = (content_hash, time.monotonic()) + return SendResult(success=True, message_id=msg_id, raw_response=data) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else 0 + body = exc.response.text if exc.response is not None else "" + return SendResult( + success=False, + error=f"HTTP {status}: {body[:500]}", + retryable=status in (408, 429, 500, 502, 503, 504), + ) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + return SendResult(success=False, error=str(exc), retryable=True) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + return None + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> SendResult: + """Send a voice memo via ``POST /v5/messages/audio`` (multipart). + + ``audio_path`` is a local audio file. CV transcribes it + server-side and threads the resulting message via the resolved + thread root sent as ``reply_to_message_id`` (same resolution + rules as :meth:`send`). + + Parameter names match :class:`BasePlatformAdapter.send_voice` — + Hermes core's media dispatch (``base.py:3640``) invokes us with + the keyword ``audio_path=``, so renaming this would break the + agent's "MEDIA:/foo.mp3 in reply" flow. ``caption`` is accepted + for signature compatibility but currently ignored (CV's audio + endpoint doesn't take a caption — the transcript IS the caption). + """ + if self._api is None: + return SendResult(success=False, error="adapter not connected") + thread_id = (metadata or {}).get("thread_id") or reply_to + try: + data = await self._api.send_audio_v5( + conversation_id=chat_id, + audio_path=audio_path, + reply_to_message_id=thread_id, + ) + msg_id = first_str(data.get("id"), data.get("message_id")) + return SendResult(success=True, message_id=msg_id, raw_response=data) + except FileNotFoundError as exc: + return SendResult(success=False, error=str(exc)) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else 0 + body = exc.response.text if exc.response is not None else "" + return SendResult( + success=False, + error=f"HTTP {status}: {body[:500]}", + retryable=status in (408, 429, 500, 502, 503, 504), + ) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + return SendResult(success=False, error=str(exc), retryable=True) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Attach an image to the conversation. + + ``image_url`` accepts either a publicly-resolvable URL or a path + to a local file. URL → single attachment with ``type:"link"``. + Local file → 4-step signed-URL flow (see + :meth:`_send_file_or_link`). ``caption`` becomes the transcript + on the same bubble — agent text and image arrive together. + """ + return await self._send_file_or_link( + chat_id=chat_id, + path_or_url=image_url, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> SendResult: + """Attach a local image file (``.jpg``, ``.png``, ``.webp``, ...). + + Hermes core's media dispatch wraps local image paths as + ``file://...`` URIs and routes them through + :meth:`BasePlatformAdapter.send_multiple_images`, whose default + implementation calls :meth:`send_image_file` per item. Without + this override the agent's "MEDIA:/foo.png" flow would fall back + to "🖼️ Image: /foo.png" plain-text from the base class — useless + on CV. Routes through the same signed-URL flow as + :meth:`send_document`; the file just happens to be an image. + """ + return await self._send_file_or_link( + chat_id=chat_id, + path_or_url=image_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> SendResult: + """Attach a document (any non-image file) to the conversation. + + Same mechanics as :meth:`send_image` — both go through the + ``type:"file"`` attachment shape on CV; the difference is purely + in the Hermes core method dispatch. Use this for ``.md``, PDFs, + archives, audio clips not meant as voice memos, etc. For voice + memos (transcribed server-side) use :meth:`send_voice`. + + Parameter names match :class:`BasePlatformAdapter.send_document` + so Hermes core's media dispatch (``base.py:3652``) reaches us + with the right keywords. ``file_name``, if provided, overrides + the on-disk basename when building the attachment payload — e.g. + for renaming ``/tmp/tmpXYZ`` to ``report.md`` on the recipient + side. + """ + return await self._send_file_or_link( + chat_id=chat_id, + path_or_url=file_path, + caption=caption, + file_name=file_name, + reply_to=reply_to, + metadata=metadata, + ) + + # ── Attachment flow (URL or local file) ───────────────────────────── + # + # Mirrors the Flutter client's pattern: the agent sees its message + # appear in the conversation immediately with an "Initializing" + # placeholder, while the actual S3 upload runs in the background and + # flips the status to ``Uploaded`` (or ``Failed``) when it settles. + # + # URL inputs skip the upload entirely — they just attach the URL + # with ``type:"link"`` since the file is already hosted somewhere + # the recipient can fetch. + + @staticmethod + def _is_url(path_or_url: str) -> bool: + return path_or_url.startswith(("http://", "https://")) + + @staticmethod + def _guess_mime(path: Path) -> str: + """Best-effort MIME type from filename extension. + + ``mimetypes`` ships a tiny built-in DB plus the system's + ``/etc/mime.types``. We add ``.md`` → ``text/markdown`` because + the stdlib still classifies markdown as ``text/x-markdown`` on + some platforms and ``None`` on others; ``text/markdown`` is the + IANA-registered form (RFC 7763) and what the agent's tooling + will actually produce. + """ + if path.suffix.lower() == ".md": + return "text/markdown" + guessed, _ = mimetypes.guess_type(str(path)) + return guessed or "application/octet-stream" + + async def _send_file_or_link( + self, + *, + chat_id: str, + path_or_url: str, + caption: Optional[str], + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + file_name: Optional[str] = None, + ) -> SendResult: + if self._api is None: + return SendResult(success=False, error="adapter not connected") + if not path_or_url: + return SendResult(success=False, error="attachment path/URL required") + + thread_id = (metadata or {}).get("thread_id") or reply_to + caption_text = (caption or "").strip() + + try: + if self._is_url(path_or_url): + attachment = {"type": "link", "link": path_or_url} + data = await self._create_attachment_message( + chat_id=chat_id, + thread_id=thread_id, + caption=caption_text, + attachment=attachment, + ) + msg_id = first_str(data.get("id"), data.get("message_id")) + return SendResult(success=True, message_id=msg_id, raw_response=data) + + # Local file: signed URL → message-create with Initializing → + # background S3 PUT + status update. + path = Path(path_or_url).expanduser() + if not path.is_file(): + return SendResult( + success=False, error=f"file not found: {path}", + ) + mime_type = self._guess_mime(path) + # Caller may override the basename so a temp path like + # ``/tmp/tmpXYZ`` shows up as ``report.md`` on the recipient. + filename = file_name or path.name + + urls = await self._api.get_signed_upload_urls( + [{"filename": filename, "mimetype": mime_type}], + ) + if not urls or not urls[0].get("url"): + return SendResult( + success=False, + error="signedurl: empty response from /v3/attachments/signedurl", + ) + signed_url = urls[0]["url"] + canonical_link = signed_url.split("?", 1)[0] + + attachment = { + "type": "file", + "link": canonical_link, + "filename": filename, + "mime_type": mime_type, + "status": "Initializing", + "percent_complete": 0, + } + try: + attachment["length_in_bytes"] = path.stat().st_size + except OSError: + pass # non-fatal; server tolerates missing size + + data = await self._create_attachment_message( + chat_id=chat_id, + thread_id=thread_id, + caption=caption_text, + attachment=attachment, + ) + msg_id = first_str(data.get("id"), data.get("message_id")) + + # Find the just-created attachment id in the response so the + # background task can flip its status when S3 settles. The + # server returns ``attachments[]`` in the order we sent them, + # so the first/only entry is ours. + created_attachments = data.get("attachments") or [] + attachment_id: Optional[str] = None + if created_attachments: + first_att = created_attachments[0] + if isinstance(first_att, dict): + attachment_id = first_str( + first_att.get("id"), first_att.get("_id"), + ) + + if attachment_id: + base_body = { + "type": "file", + "link": canonical_link, + "filename": filename, + "mime_type": mime_type, + } + # Fire-and-forget — survives this method returning. + asyncio.create_task( + self._upload_attachment_in_background( + signed_url=signed_url, + file_path=str(path), + mime_type=mime_type, + message_id=msg_id or "", + attachment_id=attachment_id, + base_body=base_body, + ) + ) + else: + logger.warning( + "carbonvoice: no attachment id in response for %s — " + "skipping background upload + status update (message " + "will show 'Initializing' indefinitely on the recipient)", + filename, + ) + + return SendResult(success=True, message_id=msg_id, raw_response=data) + except FileNotFoundError as exc: + return SendResult(success=False, error=str(exc)) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else 0 + body = exc.response.text if exc.response is not None else "" + return SendResult( + success=False, + error=f"HTTP {status}: {body[:500]}", + retryable=status in (408, 429, 500, 502, 503, 504), + ) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + return SendResult(success=False, error=str(exc), retryable=True) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def _create_attachment_message( + self, + *, + chat_id: str, + thread_id: Optional[str], + caption: str, + attachment: Dict[str, Any], + ) -> Dict[str, Any]: + """Create the message that carries *attachment*. + + Routes based on whether the caller supplied a caption: + - caption present → ``POST /v5/messages/text`` with + ``transcript`` + ``attachments`` (the server requires + ``transcript`` to be non-empty on this endpoint). + - caption absent → ``POST /v5/messages/attachment`` + (attachment-only message, no transcript). + """ + if caption: + return await self._api.send_text_v5( + conversation_id=chat_id, + transcript=caption, + reply_to_message_id=thread_id, + attachments=[attachment], + ) + return await self._api.send_attachment_v5( + conversation_id=chat_id, + attachments=[attachment], + reply_to_message_id=thread_id, + ) + + async def _upload_attachment_in_background( + self, + *, + signed_url: str, + file_path: str, + mime_type: str, + message_id: str, + attachment_id: str, + base_body: Dict[str, Any], + ) -> None: + """Push the bytes to S3 then flip the attachment status. + + Runs detached from ``send_document``/``send_image`` so the agent + gets ``SendResult(success=True)`` immediately — the recipient + sees the message bubble appear with an ``Initializing`` + placeholder and the file fills in once S3 acks. Mirrors how the + Flutter client behaves on send. + + On S3 failure we PUT ``status:"Failed"`` so the recipient's UI + renders a clear error state rather than a perpetual spinner. + Both branches are wrapped in try/except — a transient failure on + the status-update PUT must not crash the gateway event loop. + """ + try: + await self._api.upload_to_s3(signed_url, file_path, mime_type) + except Exception as exc: + logger.warning( + "carbonvoice: S3 upload failed for %s (msg=%s att=%s): %s", + file_path, message_id, attachment_id, exc, + ) + try: + await self._api.update_attachment( + message_id, + attachment_id, + {**base_body, "status": "Failed", "percent_complete": 0}, + ) + except Exception as inner: + logger.warning( + "carbonvoice: update_attachment(Failed) also failed for " + "%s: %s", attachment_id, inner, + ) + return + + try: + await self._api.update_attachment( + message_id, + attachment_id, + {**base_body, "status": "Uploaded", "percent_complete": 100}, + ) + logger.info( + "carbonvoice: attachment uploaded — msg=%s att=%s file=%s", + message_id, attachment_id, file_path, + ) + except Exception as exc: + logger.warning( + "carbonvoice: update_attachment(Uploaded) failed for %s: %s — " + "S3 upload itself succeeded; recipient may see stale " + "'Initializing' status", + attachment_id, exc, + ) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "carbonvoice", "chat_id": chat_id} + + # ── Thread-context fetch (PR 4) ────────────────────────────────────── + # + # When the agent gets @mentioned in a group thread for the first time + # (no Hermes session yet for that thread), we have no history to feed + # the LLM — it sees one isolated message and has to guess at context. + # We fetch the thread's prior messages via CV's REST API and prepend + # them as a ``[Thread context …]`` block to the user's message so + # the agent has the prior history from turn 1. + # + # The "no active session" guard means this only fires on the first + # turn in any given thread; every subsequent turn rides on the session + # history that Hermes core maintains in SQLite, so there is no + # duplication. + # + # CV has no native "list messages in thread" endpoint today, so we + # combine two calls — a lightweight channel index (just ids + + # ``parent_message_id``) plus a batched ``by-ids`` fetch — to assemble + # the thread's transcript. When cv-api adds a direct thread-listing + # endpoint the workaround collapses to one call; see + # ``api.list_channel_message_index`` for the full rationale. + + def _has_active_session_for_thread( + self, + channel_id: str, + thread_id: str, + user_id: str, + ) -> bool: + """Return True when a Hermes session already covers this thread. + + Uses ``build_session_key()`` as the single source of truth so the + key respects ``group_sessions_per_user`` / + ``thread_sessions_per_user`` exactly the way Hermes core does at + message-routing time. A drift here would mean we'd inject thread + context on a turn where Hermes already has session history, + duplicating the parent in every prompt. + """ + session_store = getattr(self, "_session_store", None) + if not session_store: + return False + try: + from gateway.session import build_session_key + + store_cfg = getattr(session_store, "config", None) + gspu = ( + getattr(store_cfg, "group_sessions_per_user", True) + if store_cfg + else True + ) + tspu = ( + getattr(store_cfg, "thread_sessions_per_user", False) + if store_cfg + else False + ) + + source = SessionSource( + platform=Platform("carbonvoice"), + chat_id=channel_id, + chat_type="group", + user_id=user_id, + thread_id=thread_id, + ) + session_key = build_session_key( + source, + group_sessions_per_user=gspu, + thread_sessions_per_user=tspu, + ) + + ensure = getattr(session_store, "_ensure_loaded", None) + if callable(ensure): + ensure() + entries = getattr(session_store, "_entries", None) or {} + return session_key in entries + except Exception: + return False + + async def _fetch_thread_context( + self, + channel_id: str, + thread_id: str, + current_msg_id: str, + *, + limit: int = 30, + ) -> str: + """Return a formatted ``[Thread context …]`` prefix for *thread_id*. + + Returns ``""`` (empty string) on any failure or when the thread + has no prior content — callers should treat empty as "nothing to + prepend" and pass the original user text through unchanged. + + Steps: + 1. Cache hit via :meth:`ConversationTracker.get_cached_thread_context`. + 2. ``api.list_channel_message_index`` → ids + ``parent_message_id``. + 3. Client-side filter to thread (root + replies whose + ``parent_message_id == thread_id``). + 4. ``api.get_messages_by_ids_v5`` for the last ``limit`` + transcripts in chronological order. + 5. Exclude the current triggering message (it will be delivered + as the user message itself) and exclude our own prior bot + replies (circular context — feeding them back creates an + echo that the LLM tends to repeat). + 6. Format ``[thread parent] name: text`` for the root and + ``name: text`` for replies, wrap in the standard delimiters, + cache, return. + """ + if self._api is None or not thread_id: + return "" + + cached = self._tracker.get_cached_thread_context(thread_id) + if cached is not None: + return cached + + try: + index = await self._api.list_channel_message_index( + channel_id, limit=200, direction="older" + ) + except Exception as exc: + logger.debug( + "carbonvoice: list_channel_message_index(%s) failed: %s", + channel_id, exc, + ) + return "" + + if not index: + return "" + + # Pick out items in this thread: the root and its direct replies. + # CV is flat (DEVELOPMENT.md §4) so a single equality check on + # ``parent_message_id`` covers every sibling — no walk needed. + thread_items = [] + for item in index: + mid = first_str( + item.get("message_id"), item.get("_id"), item.get("id"), + ) + if not mid: + continue + parent = first_str( + item.get("parent_message_id"), + item.get("parent_message_guid"), + item.get("thread_id"), + ) + is_root = mid == thread_id + is_sibling = parent == thread_id + if not (is_root or is_sibling): + continue + if mid == current_msg_id: + continue + thread_items.append((mid, item, is_root)) + + if not thread_items: + # Cache the empty result so we don't refetch on every turn + # in an otherwise empty thread. + self._tracker.set_cached_thread_context(thread_id, "") + return "" + + # Order chronologically. The index endpoint returns ``created_at`` + # as either ISO or epoch ms depending on call; sort lexically when + # string and numerically when number — both give the right order. + def _ts(entry): + ts = entry[1].get("created_at") or entry[1].get("created") or 0 + return ts + thread_items.sort(key=_ts) + + # Cap to ``limit`` most-recent so a long-running thread doesn't + # blow the prompt budget. Keep the root if present so context is + # anchored even when the tail is large. + if len(thread_items) > limit: + head = [t for t in thread_items if t[2]][:1] # the root, if any + tail = [t for t in thread_items if not t[2]][-(limit - len(head)):] + thread_items = head + tail + + ids = [mid for mid, _, _ in thread_items] + try: + full = await self._api.get_messages_by_ids_v5(channel_id, ids) + except Exception as exc: + logger.debug( + "carbonvoice: get_messages_by_ids_v5 for thread context failed: %s", + exc, + ) + return "" + + # Index by id so we can preserve our chronological order. + full_by_id = { + first_str(m.get("id"), m.get("message_id"), m.get("_id")): m + for m in (full or []) + if isinstance(m, dict) + } + + parts = [] + for mid, item, is_root in thread_items: + msg = full_by_id.get(mid) + if not msg: + continue + text = (extract_transcript(msg) or "").strip() + if not text: + continue + creator = extract_creator_id(msg) or item.get("creator_id") or "" + # Skip our own prior bot replies — feeding them back as + # "[bot]: …" creates a circular context the LLM tends to echo. + # Keep the thread parent even when authored by the bot (e.g. + # the thread was opened by a cron post we're now replying to). + if ( + creator + and self._self_user_id + and creator == self._self_user_id + and not is_root + ): + continue + name = creator + if creator and self._channels is not None: + try: + name = await self._channels.resolve_name(channel_id, creator) or creator + except Exception: + name = creator + name = name or "unknown" + prefix = "[thread parent] " if is_root else "" + parts.append(f"{prefix}{name}: {text}") + + if not parts: + self._tracker.set_cached_thread_context(thread_id, "") + return "" + + content = ( + "[Thread context — prior messages in this thread " + "(not yet in conversation history):]\n" + + "\n".join(parts) + + "\n[End of thread context]\n\n" + ) + self._tracker.set_cached_thread_context(thread_id, content) + # INFO so it shows up in default gateway.log — operators need to + # see when context was injected to debug "why did the bot know + # that?" / "why did the bot miss that?" questions without flipping + # to DEBUG. Volume is bounded: fires at most once per thread per + # TTL window (subsequent mentions in the same thread hit the + # active-session guard and skip this method entirely). + logger.info( + "carbonvoice: thread context injected for %s — %d prior message(s), %d chars", + thread_id, len(parts), len(content), + ) + return content + + # ── Inbound processing ─────────────────────────────────────────────── + + async def _fetch_missed_messages(self) -> None: + if self._api is None: + return + + # Coalesce overlapping ticks to a TRAILING re-fetch, never a drop. + # A burst of WS events must not spawn parallel fetches over the same + # cursor window (duplicate-processing amplifier) — but the event that + # arrives mid-fetch may announce a write the in-flight HTTP query + # predates (observed: the tag-resolution PUT fired while the + # transcript-ready tick was still fetching; dropping that tick lost + # the only re-fire the message ever gets). So a tick that finds the + # lock held flags ``_tick_pending``; the lock holder loops one more + # fetch per flag before releasing. + if self._fetch_lock.locked(): + self._tick_pending = True + logger.debug( + "carbonvoice: fetch already in progress — queuing trailing re-fetch" + ) + return + async with self._fetch_lock: + await self._fetch_missed_messages_locked() + while self._tick_pending: + self._tick_pending = False + await self._fetch_missed_messages_locked() + + async def _fetch_missed_messages_locked(self) -> None: + request_started_at = now_iso() + + if not self._cursor.last_seen_at: + logger.info( + "carbonvoice: first run, starting from %s", request_started_at + ) + self._cursor.advance(request_started_at) + return + + try: + messages = await self._api.fetch_recent(self._cursor.last_seen_at) + except Exception as exc: + logger.warning("carbonvoice: /v3/messages/recent failed: %s", exc) + return # don't advance cursor — retry same window next tick + + # One-tap approval: resolve any owner 👍/👎 reactions on our pending + # prompts. Done first (and best-effort) so an approval lands even if + # the prompt isn't in this fetch window. Never blocks the main path. + try: + await self._check_pending_prompt_reactions(messages) + except Exception as exc: + logger.debug("carbonvoice: pending-prompt reaction check failed: %s", exc) + + messages.sort(key=lambda m: m.get("created_at") or "") + + # Track the first "stuck" message (transcript not ready yet — + # `_process_message` returns None). We hold the cursor just before + # it so the next poll re-fetches from there and retries, instead of + # advancing past and risking a skip. Mirrors the Claude Code + # Channel's stuck-message handling. + first_stuck_idx: Optional[int] = None + for i, msg in enumerate(messages): + try: + result = await self._process_message(msg) + except Exception as exc: + logger.error("carbonvoice: process_message error: %s", exc) + continue + if result is None and first_stuck_idx is None: + first_stuck_idx = i + + # Advance the cursor as far as is safe: + # - no stuck messages → advance to the request start time + # (clock-safe; avoids missing concurrent writes mid-call). + # - some stuck → advance only to just before the first stuck + # message, leaving it (and everything after) for the next poll. + # Earlier already-dispatched messages are deduped by SeenCache + # if the shrunk window re-fetches them. + # - first message stuck (idx 0) or no usable timestamp → leave + # the cursor unchanged so the stuck message is retried. + if first_stuck_idx is None: + self._cursor.advance(request_started_at) + elif first_stuck_idx > 0: + prev_created = messages[first_stuck_idx - 1].get("created_at") + if isinstance(prev_created, str) and prev_created: + self._cursor.advance(prev_created) + + # Something is held (no-transcript stuck or revisit-held): schedule a + # one-shot delayed re-tick so the retry doesn't depend on the next + # unrelated WS event arriving. In WS mode polling is stopped, so on a + # quiet workspace a held message would otherwise wait for the next + # reconnect cycle (observed: tens of minutes). One task at a time — + # each retry re-schedules itself via this same path while anything + # remains held. + if first_stuck_idx is not None: + self._schedule_stuck_retry() + + def _schedule_stuck_retry(self) -> None: + if self._stuck_retry_task is not None and not self._stuck_retry_task.done(): + return + + async def _retry() -> None: + await asyncio.sleep(STUCK_RETRY_DELAY_S) + try: + await self._fetch_missed_messages() + except Exception as exc: + logger.debug("carbonvoice: stuck-retry tick failed: %s", exc) + + self._stuck_retry_task = asyncio.create_task(_retry()) + + # ── Inbound multimodal (PR 7) ──────────────────────────────────────── + # + # CV inbound payloads carry ``attachments[]`` whose ``link`` is the + # canonical S3 URL — auth-gated, returns 403 to unauthenticated + # requests. To consume them we resolve a signed S3 GET URL via + # ``GET /attachments/signedurl/:_id`` (authenticated with our PAT), + # download the bytes to ``IMAGE_CACHE_DIR``, and return local + # filesystem paths for Hermes core to inject into the agent's + # multimodal context (Claude vision sees the bytes inline). + # + # Scope for v1: ``image/*`` only. Other mime types (PDFs, + # ``text/*``, binaries) are dropped with a WARNING because Hermes + # core has no native document-extraction pipeline today. Without + # one, the agent receives a ``file://...pdf`` path it can't + # natively read — it reaches for ``read_file`` (returns binary + # garbage), then ``terminal`` (asks the operator to approve + # ``pdftotext`` / similar), then ``execute_code`` (tries Python + # parsers that may not be installed). Net UX: the user gets a + # permission prompt instead of an answer. Better to skip cleanly + # and document the gap. + # + # Document support is queued for a follow-up PR that adds an + # extraction pass (likely via ``pypdf`` + ``markdown`` / ``html`` + # parsers) and prepends the extracted text into the agent's + # message context the same way thread context is prepended today. + # Audio attachments live in ``audio_models[]``, not + # ``attachments[]``; the transcript is already extracted via + # :func:`extract_transcript`. + + async def _collect_inbound_media( + self, msg: Dict[str, Any] + ) -> "tuple[list[str], list[str], list[str]]": + """Process inbound attachments and return three lists: + + - ``media_urls`` — local filesystem paths of downloaded image + files (bare paths, no ``file://`` scheme), ready for + ``MessageEvent.media_urls`` + - ``media_types`` — parallel list of mime types + - ``link_urls`` — bare URLs from ``type:"link"`` + attachments (CV's link-sharing UI flow), to be prepended + to the agent's message text so it sees them the same way + it would see a URL the user typed inline + + ``type:"link"`` entries are not downloaded — they don't + reference uploaded files, they're URLs to external resources. + Threading them into the text channel lets the agent reach for + its own browser / fetch tools the same way it does for URLs + embedded in the transcript directly. + """ + if self._api is None: + return [], [], [] + + attachments = extract_attachments(msg) + if not attachments: + return [], [], [] + + # Import the cache dir constant from core so downloaded files + # land in a root the media-delivery validator already allows. + # Local import keeps this module gateway-free at import time + # (CI imports the plugin without core). + from gateway.platforms.base import IMAGE_CACHE_DIR + + media_urls: list[str] = [] + media_types: list[str] = [] + link_urls: list[str] = [] + + for att in attachments: + aid = att.get("_id") or "" + mime = (att.get("mime_type") or "").lower() + att_type = (att.get("type") or "").lower() + link = att.get("link") or "" + filename = att.get("filename") or aid or "attachment.bin" + + # CV's link attachment: the user picked "share a URL" in + # the UI. ``link`` is the actual external URL (not an S3 + # path); ``mime_type`` is null. Surface the URL inline so + # the agent can reach for its existing web tools just like + # it would for a URL typed in the transcript directly. + if att_type == "link": + if link: + link_urls.append(link) + logger.info( + "carbonvoice: inbound link attachment surfaced " + "to agent — %s", link, + ) + else: + logger.warning( + "carbonvoice: skipping link attachment %s — " + "no link URL in payload", filename, + ) + continue + + if mime.startswith("image/"): + target_dir = IMAGE_CACHE_DIR + else: + logger.warning( + "carbonvoice: skipping inbound attachment %s (%s) — " + "only image/* is wired in this plugin version " + "(document pipeline pending — see DEVELOPMENT.md §4)", + filename, mime or "no-mime", + ) + continue + + if not aid: + logger.warning( + "carbonvoice: skipping inbound attachment %s — " + "no attachment_id to resolve a signed URL", + filename, + ) + continue + + try: + local_path = await self._api.download_attachment( + aid, + target_dir, + filename=filename, + max_bytes=self._max_attachment_bytes, + ) + except ValueError as exc: + # Size cap hit. + logger.warning( + "carbonvoice: skipping oversized inbound attachment %s: %s", + filename, exc, + ) + continue + except Exception as exc: + logger.warning( + "carbonvoice: failed to download inbound attachment " + "%s (%s): %s", filename, aid, exc, + ) + continue + + # Pass the bare local path (NOT a ``file://`` URI) — that's + # what every other built-in adapter puts in ``media_urls`` + # (Slack: ``media_urls.append(cached_path)``; Telegram: + # ``event.media_urls = [cached_path]``), and what Hermes core's + # native-image routing expects: ``build_native_content_parts`` + # (agent/image_routing.py) does ``Path(raw_path)`` directly, so a + # ``file://`` prefix makes the path non-existent and the image is + # silently dropped as "unreadable" (only the older text/vision + # path tolerated the scheme — once image_input_mode resolves to + # ``native`` for a vision model, the prefix breaks inbound images). + media_urls.append(str(local_path)) + media_types.append(mime) + logger.info( + "carbonvoice: inbound attachment downloaded — " + "att=%s mime=%s path=%s", + aid, mime, local_path, + ) + + return media_urls, media_types, link_urls + + async def _fetch_forwarded_content( + self, share_link_id: str, channel_id: str + ) -> Optional[Dict[str, Any]]: + """Resolve a forwarded message's original content via its share link. + + Returns ``{"text": , "media_urls": [...], + "media_types": [...]}`` on success, or ``None`` when the content + isn't retrievable *yet* — share-link fetch failed, the original's + attachments are still uploading, or an image download failed. The + caller treats None as the stuck signal (hold cursor, retry next + poll) while the message is young, and degrades to a placeholder + past the cutoff. Mirrors cv-claude-channels' share-link handling. + + The text block: + + [Forwarded message from ] + + [Attached link: ...] ← link attachments, inline + [Attachment foo.pdf — ...] ← non-image files, noted only + + Image attachments are downloaded through the share-link-scoped + signed-URL route (the bot may lack access to the original + message's channel; the link itself authorizes) into the same + IMAGE_CACHE_DIR as regular inbound images, and returned as bare + local paths for ``MessageEvent.media_urls``. + """ + try: + share_link = await self._api.get_share_link(share_link_id) + except Exception as exc: + logger.warning( + "carbonvoice: share-link fetch failed for %s: %s", + share_link_id, exc, + ) + return None + shared = (share_link or {}).get("shared_message") + if not isinstance(shared, dict): + logger.warning( + "carbonvoice: share link %s has no shared_message " + "(revoked / expired / no access?)", + share_link_id, + ) + return None + + # Original sender: resolve against this channel's roster (cache + # hit). The original author often isn't a member of the channel + # the forward landed in — fall back to the raw id. + sm_creator = extract_creator_id(shared) + sender = "" + if sm_creator and self._channels is not None: + sender = await self._channels.resolve_name( + channel_id, sm_creator + ) or "" + sender = sender or sm_creator or "unknown sender" + + from gateway.platforms.base import IMAGE_CACHE_DIR + + media_urls: list[str] = [] + media_types: list[str] = [] + att_lines: list[str] = [] + + for att in extract_attachments(shared): + aid = att.get("_id") or "" + mime = (att.get("mime_type") or "").lower() + att_type = (att.get("type") or "").lower() + link = att.get("link") or "" + filename = att.get("filename") or aid or "attachment.bin" + status = (att.get("status") or "").lower() + + if att_type == "link": + # Same inline-URL treatment as wrapper-level link + # attachments — the agent fetches it with its web tools. + if link: + att_lines.append(f"[Attached link: {link}]") + continue + if status == "failed": + att_lines.append( + f"[Attachment {filename} — upload failed on the " + "original message]" + ) + continue + if status and status != "uploaded": + # Initializing / Uploading — the original's file isn't on + # S3 yet. Retry the whole forward. + logger.info( + "carbonvoice: forwarded attachment %s still %s — " + "holding for retry", filename, status, + ) + return None + if not mime.startswith("image/"): + # Same image-only scope as _collect_inbound_media (no + # document pipeline in Hermes core yet) — but note the + # file in the text so the agent knows it exists. + att_lines.append( + f"[Attachment {filename} ({mime or 'unknown type'}) — " + "not imported: only image attachments are supported]" + ) + logger.warning( + "carbonvoice: skipping forwarded attachment %s (%s) — " + "only image/* is wired (see DEVELOPMENT.md §4)", + filename, mime or "no-mime", + ) + continue + if not aid: + continue + try: + local_path = await self._api.download_share_link_attachment( + share_link_id, + aid, + IMAGE_CACHE_DIR, + filename=filename, + max_bytes=self._max_attachment_bytes, + ) + except ValueError as exc: + # Size cap — permanent, don't hold the cursor for it. + att_lines.append( + f"[Attachment {filename} — skipped: too large]" + ) + logger.warning( + "carbonvoice: oversized forwarded attachment %s: %s", + filename, exc, + ) + continue + except Exception as exc: + logger.warning( + "carbonvoice: forwarded attachment download failed " + "%s (%s): %s — holding for retry", + filename, aid, exc, + ) + return None + media_urls.append(str(local_path)) + media_types.append(mime) + logger.info( + "carbonvoice: forwarded attachment downloaded — " + "att=%s mime=%s path=%s", aid, mime, local_path, + ) + + block = ( + f"[Forwarded message from {sender}]\n" + + (extract_transcript(shared) or "(no transcript)") + ) + if att_lines: + block += "\n" + "\n".join(att_lines) + return { + "text": block, + "media_urls": media_urls, + "media_types": media_types, + } + + async def _process_message(self, msg: Dict[str, Any]) -> Optional[bool]: + """Process one inbound message; return its disposition for the cursor. + + - ``True`` — dispatched to the agent. + - ``False`` — skipped for good (self-loop, single-user restrict, + not allowed, deduped, gate-rejected). Safe to advance past. + - ``None`` — *stuck*: the transcript isn't ready yet (CV is + still transcribing), or the message is a forward whose + share-link content couldn't be resolved yet. The caller holds + the cursor just *before* this message so the next poll + re-fetches and retries it, instead of advancing past and + risking a skip. Mirrors the Claude Code Channel's null-return + contract. + """ + message_id = extract_message_id(msg) + if not message_id: + return False + + channel_id = extract_channel_id(msg) + if not channel_id: + return False + + creator_id = extract_creator_id(msg) + + # Self-loop guard. + if creator_id and self._self_user_id and creator_id == self._self_user_id: + return False + + # Optional single-user restriction (acts before transcript check so + # we don't waste cycles on transcripts we'll drop anyway). + if self._creator_id and creator_id and creator_id != self._creator_id: + return False + + # Dedup FIRST — before the allowlist gate. The same message_id can + # arrive twice nearly simultaneously (socket event + poll fetch); if + # the gate's unauthorized branch ran before this check, BOTH copies + # would log "dropped", react, and prompt before either marked seen — + # the observed double-drop-per-message burst from a spamming sender. + # Marking happens at each terminal branch below; checking up front + # makes a redundant copy a no-op. (Revisitable gate rejections are + # deliberately NOT marked, so they still get re-evaluated — see the + # mention-gate branch.) + if self._seen.is_seen(message_id): + return False + + # Allowlist gate — default is allow-all (see AllowlistGate docstring). + # When the operator has configured a restriction, short-circuit + # rejected senders here so we can log them with a resolved username + # before Hermes core's parallel check drops them. + if not self._allowlist.is_allowed(creator_id): + logger.info( + "carbonvoice: dropped message from unauthorized sender %s", + creator_id, + ) + # Deny-by-default onboarding: react ⁉️ on the sender's message + # (silent "pending approval") and ask the owner in the home + # channel to approve them (rate-limited per pending user). + await self._maybe_notify_unauthorized( + creator_id, channel_id, message_id + ) + if self._ignored_log is not None and creator_id: + self._ignored_log.record(creator_id, channel_id) + # Mark THIS message seen so the poll loop doesn't re-process the + # exact same unauthorized message every tick. Without this, a + # not-yet-approved sender's message is re-evaluated on every poll + # (worsened by 502 retries re-fetching the same window) — the + # observed 2500×-"dropped unauthorized" burst. This does NOT lock + # the *user* out: once the owner approves them, their NEW messages + # pass the gate normally; only this specific already-reacted + # message is suppressed (SeenCache TTL is short, so even it + # re-evaluates later if still unapproved). + self._seen.mark(message_id) + return False + + # Two-phase transcript: empty means "not ready yet" (CV is still + # transcribing). Return None — the *stuck* signal — so the poll + # loop holds the cursor just before this message and retries it + # next tick, rather than advancing past it. Don't mark seen. + # + # BUT only while the message is young. A message with no transcript + # is "stuck" only transiently; some never get one (image-only, + # system events, failed STT). If we held the cursor for those + # forever, every poll/restart would re-fetch the whole window from + # the pinned timestamp and re-feed already-processed messages — the + # "cadena de mensajes" bug. Past CARBONVOICE_STUCK_MAX_AGE_S we stop + # waiting and let it advance the cursor (return False, not None). + transcript = extract_transcript(msg) + if not transcript: + # Forwards are the exception to the stuck-wait: a forward with + # no typed comment never gets a transcript of its own — the + # content lives behind the share link (fetched below). Only a + # *voice* comment (is_text_message False) still waits for STT + # like any voice message, and at the age cutoff it falls + # through to forward processing (comment lost) instead of + # being skipped (whole forward lost). + share_link_hint = extract_share_link_id(msg) + if not share_link_hint or msg.get("is_text_message") is False: + age = message_age_seconds(msg, now_utc()) + if age is None or age <= self._stuck_max_age_s: + return None + if not share_link_hint: + logger.info( + "carbonvoice: message %s has no transcript after %.0fs " + "(> %ss) — treating as permanently empty, advancing past it", + message_id, age, self._stuck_max_age_s, + ) + self._seen.mark(message_id) + return False + logger.info( + "carbonvoice: forward %s voice comment never transcribed " + "after %.0fs — proceeding with forwarded content only", + message_id, age, + ) + + # V5 source-of-truth enrichment. The socket / v3-poll push gives + # us a V2-shaped payload that trails the v5 GET on async fields: + # ``tagged_user_ids`` is empty here until a backend job resolves + # the tag picker selection, and attachment metadata can lag the + # same way. CV's v5 endpoint is the canonical post-resolution + # state — the Flutter client follows the same "socket = signal, + # REST = truth" pattern. + # + # We do the GET only here, after the cheap-reject gates above + # (self-loop, allowlist, dedupe, empty-transcript), so empty + # ``message:created`` events don't pay the HTTP. On fetch + # failure we keep the V2 payload — defensive, so a transient + # /v5 hiccup doesn't drop an otherwise-deliverable message. + # The parse helpers (``extract_*``) prefer V5 fields when + # present, so reassigning ``msg`` is enough — no further + # downstream changes needed. + if self._api is not None: + try: + enriched = await self._api.get_message_v5(message_id) + except Exception as exc: + logger.debug( + "carbonvoice: v5 enrichment failed for %s: %s — " + "continuing with v2 payload", + message_id, exc, + ) + enriched = None + if enriched: + # Staleness guard: the v5 GET can race a write the push + # payload already reflects (read-replica lag) — if the v2 + # copy has ``tagged_user_ids`` and the v5 copy doesn't, + # keep the populated array rather than letting the + # enrichment erase the mention. + if not enriched.get("tagged_user_ids") and msg.get("tagged_user_ids"): + enriched["tagged_user_ids"] = msg["tagged_user_ids"] + msg = enriched + # Re-pull transcript from the (canonical) v5 payload — + # usually the same string but keeps everything in one + # shape after this point. + transcript = extract_transcript(msg) or transcript + + # Server-side dedup (persistent, survives restarts). We put an ack + # reaction on every *accepted* message, so a message already + # carrying the bot's ack was already processed — skip it. This + # complements the in-memory SeenCache, which is lost on restart + # and expires after 5 min. Crucially it breaks the + # ``use_last_updated`` re-capture loop: the ack reaction and the + # bot's in-thread reply both bump ``updated_at``, so the poller + # keeps re-fetching the same message; without a durable marker the + # SeenCache eventually lapses and the agent re-answers the same + # message (observed: one message dispatched 5× across a day of + # restarts). We read ``reaction_summary`` from the canonical v5 + # payload above. Mark seen too so immediate re-polls skip without + # paying another v5 GET. Mirrors the Claude Code Channel's + # reaction-based ``isProcessed`` dedup. + if ( + self._reactions is not None + and self._reactions.enabled + and self._self_user_id + and bot_has_reacted( + msg, self._self_user_id, self._reactions.reaction_id + ) + ): + logger.debug( + "carbonvoice: skip %s — already acked by bot (server-side dedup)", + message_id, + ) + self._seen.mark(message_id) + return False + + # Admin allow-list commands (/cv-allow, /cv-deny, /cv-list). Only the + # OWNER may run these — a normally-approved user must not be able to + # escalate by approving others. Handled here and NOT forwarded to the + # agent. + if self._allowlist.is_owner(creator_id): + cmd = parse_admin_command(transcript) + if cmd is not None: + # Dedup BEFORE running the command. The command sends a reply + # ("✅ Allowed …") which bumps updated_at and re-fires the + # poll; if the durable ack isn't on the server yet (or the + # SeenCache was lost to a restart), the re-fetched command + # re-runs and re-replies — the observed 298×-spam bug. So we + # (1) mark the in-memory SeenCache and (2) put the durable + # server-side ack reaction *and wait for it* — BEFORE sending + # the reply. ``ack_sync`` blocks until the marker is on the + # server, so the re-fetch is guaranteed deduped by the + # ``bot_has_reacted`` check above. ``approve``/``revoke`` are + # idempotent too, so a stale in-flight copy is a harmless no-op. + self._seen.mark(message_id) + if self._reactions is not None: + await self._reactions.ack_sync(message_id) + await self._handle_admin_command(channel_id, cmd) + return False + + # Resolve chat_type before the mention gate so the gate can short- + # circuit group messages without spinning up the rest of the + # pipeline (visual ack, parent lookup, name resolution). The + # channel cache makes the first message in each channel pay one + # HTTP call; every subsequent message is free. + chat_type = "dm" + if self._channels is not None: + chat_type = await self._channels.resolve_chat_type(channel_id) + + # Mention gate: in group channels, only respond when the agent + # is @-mentioned (or the channel is configured to bypass). DMs + # always pass. Evaluated before the visual ack so users in + # non-mention scenarios don't see a phantom "I saw it" with no + # follow-up reply. + decision = self._gate.evaluate( + msg=msg, + chat_type=chat_type, + channel_id=channel_id, + self_user_id=self._self_user_id, + ) + if not decision.process: + logger.debug( + "carbonvoice: skip message %s in %s — %s", + message_id, channel_id, decision.reason, + ) + # Revisitable rejection ("group without @-mention") of a *voice* + # message: the verdict is provisional. Flutter applies picker + # tags via the batch ``PUT /messages/:id/tagged-users`` only + # after STT finishes (~10–30s post-create), so at this moment + # ``tagged_user_ids`` may simply not be populated yet — or our + # read raced the tag write (the tag-set ``message:updated`` tick + # can fetch within ~100ms of the PUT and see a stale copy). + # Returning False here advances the cursor past the message, and + # since the tag PUT emits the LAST update that message ever + # gets, the mention would be lost forever (observed live). So: + # hold the cursor (stuck signal) while the message is young + # enough for tags to still be in flight; the retry tick + # re-fetches and re-evaluates. Text messages carry their tags on + # the create body, so a missing mention there is final — no hold. + if ( + decision.revisitable + and msg.get("is_text_message") is False + ): + age = message_age_seconds(msg, now_utc()) + if age is not None and age <= self._revisit_max_age_s: + return None + # Leave revisitable rejections out of the dedup cache so a + # follow-up ``message:updated`` re-fire (e.g. cv-api emits + # one after the async tag-resolution job populates + # ``tagged_user_ids``) gets another shot at the gate. Stable + # rejections (ignored channel, etc.) mark seen so we don't + # re-evaluate them on every retry. See GateDecision docstring. + if not decision.revisitable: + self._seen.mark(message_id) + return False + + # Forwarded message (share link): resolve the original message's + # content BEFORE committing (mark-seen + ack) so a failed or + # not-ready fetch can return None — the stuck signal — and the + # cursor holds for a retry next poll. Mirrors cv-claude-channels' + # retry-don't-skip contract for share links. Past the stuck cutoff + # we degrade to a placeholder rather than pinning the cursor + # forever (revoked/expired links never resolve). + forwarded: Optional[Dict[str, Any]] = None + share_link_id = extract_share_link_id(msg) + if share_link_id and self._api is not None: + forwarded = await self._fetch_forwarded_content( + share_link_id, channel_id + ) + if forwarded is None: + age = message_age_seconds(msg, now_utc()) + if age is None or age <= self._stuck_max_age_s: + return None + logger.warning( + "carbonvoice: forwarded content for %s (share link %s) " + "unavailable after %.0fs — delivering placeholder", + message_id, share_link_id, age, + ) + forwarded = { + "text": "[Forwarded message — original content unavailable]", + "media_urls": [], + "media_types": [], + } + + # Decision is "process" — commit to it. Marking seen here (rather + # than before the gate) guarantees we only dedup messages we + # actually dispatch; a re-fire with new metadata still gets a + # fair gate evaluation up to this point. + self._seen.mark(message_id) + + # Fire the visual ack first so the user sees feedback in <100ms, + # well before the agent's reply (which can take 10+ seconds). + if self._reactions is not None: + self._reactions.ack(message_id) + + # Lane anchor: compute the thread root for this inbound message + # and record it in the tracker so the next outbound reply threads + # under the correct root. Carbon Voice enforces flat replies (see + # DEVELOPMENT.md §4), so ``parent_message_id`` is always the true + # root — no walking required. The tracker stores the anchor keyed + # by ``thread_id``, and ``send()`` reads ``metadata['thread_id']`` + # populated by Hermes core from ``SessionSource.thread_id`` — so + # concurrent threads in the same channel each resolve their own + # anchor (closes the §7.6 latent bug end-to-end). + parent = first_str( + msg.get("parent_message_id"), msg.get("parent_message_guid") + ) + thread_id = ConversationTracker.thread_id_of(msg) + if thread_id: + self._tracker.set_reply_anchor(thread_id, thread_id) + + # Resolve the sender's display name from the channel roster + # (json_collaborators on GET /channel/{id}, cached). The old + # GET /v3/users/{id} endpoint is dead (404), so the channel + # collaborator list is the source of truth — and it's the same + # payload we already fetched for chat_type above, so this is a + # cache hit. Falls back to the raw guid when the sender isn't in + # the list (shouldn't happen — you must be a collaborator to post). + user_name = "" + if creator_id and self._channels is not None: + user_name = await self._channels.resolve_name(channel_id, creator_id) or "" + if not user_name and creator_id: + user_name = creator_id + + reply_to_text = await self._tracker.get_parent_text(parent) + + # Mentions now arrive structured in ``tagged_user_ids`` (see + # parse.is_user_mentioned). The Flutter composer sends the + # transcript as plain text — ``@Name`` without the guid — so + # there is no inline ``@[name](guid)`` markup left to strip; pass + # the transcript through as-is. + clean_text = transcript + + # Forwarded message: the agent reads the original content first, + # then the forwarder's comment (when there is one) — same layout + # cv-claude-channels sends: + # + # [Forwarded message from ] + # + # + # [Forwarded by ] + # + if forwarded is not None: + if transcript: + clean_text = ( + f"{forwarded['text']}\n\n" + f"[Forwarded by {user_name}]\n{transcript}" + ) + else: + clean_text = forwarded["text"] + + # Session sharing in groups: pass the thread root as + # ``SessionSource.thread_id`` so Hermes core composes a shared + # session key (``agent:main:carbonvoice:group::``) + # and prefixes each user message with ``[sender name]`` for + # multi-user attribution. DMs intentionally keep ``thread_id=None``: + # a DM should remain one session per pair, not split per top-level + # message inside the conversation. + session_thread_id = thread_id if chat_type == "group" else None + + # Thread-context fetch (PR 4): when this is the first @mention in + # a group thread (no Hermes session yet), pull the prior messages + # so the agent has context from turn 1. Guard with the + # "no active session" check so subsequent turns ride on Hermes' + # SQLite session history without re-injecting the parent each + # time. DMs skip the fetch — their single session already covers + # the conversation, and there are no sibling participants whose + # context we'd be missing. + if ( + chat_type == "group" + and session_thread_id + and creator_id + and not self._has_active_session_for_thread( + channel_id, session_thread_id, creator_id, + ) + ): + context_prefix = await self._fetch_thread_context( + channel_id=channel_id, + thread_id=session_thread_id, + current_msg_id=message_id, + ) + if context_prefix: + clean_text = context_prefix + clean_text + + source = SessionSource( + platform=Platform("carbonvoice"), + chat_id=channel_id, + chat_name=f"cv:{channel_id[:8]}", + chat_type=chat_type, + user_id=creator_id or "", + user_name=user_name or creator_id or "", + message_id=message_id, + thread_id=session_thread_id, + ) + # Inbound multimodal (PR 7): pull any attached files into local + # caches so Hermes core's vision pipeline can consume them. CV's + # S3 URLs need auth, so we resolve a signed GET URL per file + # attachment, download via that, and hand Hermes core a local + # filesystem path in ``media_urls``. Image attachments are + # routed to vision; ``type:"link"`` attachments (CV's link- + # sharing UI) return their URLs in ``link_urls`` so we can + # prepend them to the visible text — the agent then sees them + # the same way it sees URLs typed inline, and uses its existing + # browser / fetch tools to consume them. Anything else (PDFs, + # binaries, …) is dropped with a WARNING. + media_urls, media_types, link_urls = await self._collect_inbound_media(msg) + + # Images attached to the *forwarded* (original) message ride the + # same vision pipeline as the wrapper's own attachments. Forwarded + # images first — they're what the text block describes. + if forwarded is not None and forwarded["media_urls"]: + media_urls = list(forwarded["media_urls"]) + media_urls + media_types = list(forwarded["media_types"]) + media_types + + # If CV's link-share UI was used, surface the URL(s) inline so + # the agent can fetch them naturally. Prepending preserves the + # user's own text right after, so the agent reads: + # + # [Attached link: https://...] + # + if link_urls: + link_prefix = "\n".join( + f"[Attached link: {u}]" for u in link_urls + ) + clean_text = f"{link_prefix}\n{clean_text}" if clean_text else link_prefix + + # Participant roster: give the agent the names of everyone in the + # conversation (not just whoever is speaking) so it can address + # people and attribute statements. Sourced from the channel + # collaborator list (cache hit — same payload as chat_type), with + # the bot itself excluded. Injected via ``channel_context``, which + # Hermes core prepends once after the sender prefix and keeps in + # history — unlike ``channel_prompt`` which resets per message and + # would bust the prompt cache. We only inject when there are ≥2 + # other humans: in a 1:1 DM the sender's name already rides in the + # system prompt (``SessionSource.user_name``), so a one-name roster + # would be redundant noise. + channel_context: Optional[str] = None + if self._channels is not None: + roster = await self._channels.get_roster(channel_id) + others = sorted( + n for g, n in roster.items() if g != self._self_user_id + ) + if len(others) >= 2: + channel_context = ( + "[Participants in this conversation: " + + ", ".join(others) + + "]" + ) + + # Mark VOICE when ``CARBONVOICE_VOICE_OUT=true`` so Hermes core's + # auto-TTS gate (``base.py:3493``) accepts this event for voice- + # mode dispatch. CV doesn't distinguish text-typed vs voice- + # transcribed at the outbound layer (everything ends up as + # either a text bubble or a voice memo bubble), so applying + # VOICE to every inbound is the right abstraction for a + # voice-first platform — the operator opts in once and gets a + # consistent symmetric experience. + msg_type = MessageType.VOICE if self._voice_out else MessageType.TEXT + event = MessageEvent( + text=clean_text, + message_type=msg_type, + source=source, + raw_message=msg, + message_id=message_id, + reply_to_message_id=parent, + reply_to_text=reply_to_text, + media_urls=media_urls, + media_types=media_types, + ) + # ``channel_context`` (participant roster) is a *newer* Hermes core + # field on MessageEvent (added with the Discord channel-history + # backfill). Set it only when this core supports it — passing it as a + # ctor kwarg on an older core raises "unexpected keyword argument + # 'channel_context'" and crashes every message. Setting the attribute + # post-construction degrades gracefully: the roster is dropped on old + # cores, everything else still works. + if channel_context and hasattr(event, "channel_context"): + event.channel_context = channel_context + + # Dispatch in a background task so processing one message can't block + # the poll/WS loop while the agent thinks. + asyncio.create_task(self._dispatch(event)) + return True + + # ── Interactive allow-list (deny-by-default onboarding) ────────────── + + async def _maybe_notify_unauthorized( + self, creator_id: str, channel_id: str, message_id: str = "" + ) -> None: + """React to an unknown sender's message + ask the owner to approve. + + The sender gets a silent "pending approval" reaction (⁉️) on their + message — NOT a text reply. A text reply clutters the channel and, + worse, spammed every old conversation when we switched to + deny-by-default (each re-flagged sender got a wall message). A + reaction is unobtrusive and self-evidently "seen but not answered". + + The owner prompt (in the home channel) is rate-limited to once per + ``approval_notify_cooldown_s`` per user so a persistent stranger + doesn't spam the owner — but the owner IS re-notified after the + cooldown (a single prompt could be missed). Always records the + channel they wrote in (for name resolution on approval). + """ + if not creator_id: + return + + # No reaction on the sender's message. We used to react ⁉️ here, but + # it was NOT cooldown-gated — a spamming stranger got one reaction per + # message, which buried the owner in CV notifications. Mirroring + # cv-claude-channels: an unknown sender's messages are dropped + # silently; the only feedback is the owner prompt below (rate-limited) + # and a one-time "you've been added" message to the sender once the + # owner approves them (see _handle_admin_command's allow branch). + now = time.monotonic() + entry = self._pending_approval.get(creator_id) + if entry is None: + # notified_at=None means "never prompted" — distinct from a real + # timestamp. (time.monotonic() can be small right after boot, so a + # 0.0 sentinel would silence the FIRST prompt if a stranger wrote + # within one cooldown of startup.) + entry = {"channel": channel_id, "notified_at": None} + self._pending_approval[creator_id] = entry + else: + # Keep the most recent channel for name resolution. + entry["channel"] = channel_id or entry.get("channel") or "" + # Cooldown gate: skip if we prompted recently (but always prompt the + # first time, when notified_at is None). + last = entry.get("notified_at") + if last is not None and now - float(last) < self._approval_cooldown_s: + return + entry["notified_at"] = now + + # (A) prompt the owner in the home channel. + if self._api is not None and self._home_channel: + name = "" + if self._channels is not None: + try: + name = await self._channels.resolve_name(channel_id, creator_id) or "" + except Exception: + name = "" + who = f"{name} ({creator_id})" if name else creator_id + text = ( + f"👤 {who} wants to talk to me but isn't authorized.\n" + f"React 💯 to allow · 👎 to block — " + f"or reply /cv-allow-user {creator_id}" + ) + try: + result = await self.send(self._home_channel, text) + # Map the prompt message → the user it's about, so an owner + # 👍/👎 reaction on it resolves the decision without typing + # the id. (cv-claude-channels' pendingPermissionMessages.) + prompt_id = getattr(result, "message_id", None) + if prompt_id: + self._pending_prompts[prompt_id] = creator_id + while len(self._pending_prompts) > self._MAX_PENDING_PROMPTS: + self._pending_prompts.popitem(last=False) + logger.info( + "carbonvoice: asked owner to approve %s in home channel " + "(prompt=%s, react 💯/👎)", + creator_id, prompt_id, + ) + except Exception as exc: + logger.warning( + "carbonvoice: failed to notify owner about %s: %s", + creator_id, exc, + ) + elif self._api is not None: + logger.info( + "carbonvoice: unauthorized sender %s — no CARBONVOICE_HOME_CHANNEL " + "configured, can't ask the owner to approve (set it to enable " + "interactive onboarding)", + creator_id, + ) + + async def _check_pending_prompt_reactions( + self, polled: "list[Dict[str, Any]]" + ) -> None: + """Resolve owner 👍/👎 reactions on pending approval prompts. + + For each tracked prompt (prompt msg_id → creator_id), read the + reactions on that prompt message and, if the OWNER reacted with the + approve or reject reaction, apply the decision — no typed command. + Mirrors cv-claude-channels' ``checkPendingPermissions``. + + Prompts already in the polled batch are read from it (free); any + others are fetched by id (the owner's reaction won't necessarily + bring the bot's own prompt into ``fetch_recent``). Only the owner's + reaction counts — a stranger reacting 👍 on their own prompt must + not self-approve. + """ + if not self._pending_prompts or self._api is None: + return + owner = self._allowlist.owner_id + if not owner: + return # without a known owner, nobody can authorize via reaction + wanted = {self._approve_reaction_id, self._reject_reaction_id} + + by_id = { + mid: m + for m in polled + if isinstance(m, dict) and (mid := extract_message_id(m)) + } + # Snapshot keys — we mutate _pending_prompts as we resolve. + for prompt_id in list(self._pending_prompts.keys()): + creator_id = self._pending_prompts.get(prompt_id) + if not creator_id: + continue + msg = by_id.get(prompt_id) + if msg is None: + try: + msg = await self._api.get_message_v5(prompt_id) + except Exception: + msg = None + if not isinstance(msg, dict): + continue + reactors = reactors_for(msg, wanted) + if owner not in reactors: + continue + # Owner reacted. Approve takes precedence if both are present. + approvers = reactors_for(msg, {self._approve_reaction_id}) + cmd = "allow" if owner in approvers else "deny" + self._pending_prompts.pop(prompt_id, None) + logger.info( + "carbonvoice: owner reacted %s on prompt %s → %s %s", + "💯" if cmd == "allow" else "👎", prompt_id, cmd, creator_id, + ) + try: + await self._handle_admin_command( + self._home_channel or "", (cmd, creator_id) + ) + except Exception as exc: + logger.warning( + "carbonvoice: failed to apply reaction verdict for %s: %s", + creator_id, exc, + ) + + def _drop_pending_prompts_for(self, creator_id: str) -> None: + """Forget any pending approval prompts about *creator_id* (after a + decision via either reaction or command), so a stale reaction on an + old prompt can't re-trigger.""" + for pid in [ + p for p, c in self._pending_prompts.items() if c == creator_id + ]: + self._pending_prompts.pop(pid, None) + + async def _resolve_pending_name(self, user_id: str) -> str: + """Display name of a pending user, from the channel they wrote in.""" + entry = self._pending_approval.get(user_id) or {} + origin = entry.get("channel") + if not origin or self._channels is None: + return "" + try: + return await self._channels.resolve_name(origin, user_id) or "" + except Exception: + return "" + + async def _handle_admin_command( + self, channel_id: str, cmd: "tuple[str, Optional[str]]" + ) -> None: + """Run an owner allow-list command and reply in *channel_id*.""" + action, arg = cmd + reply: Optional[str] = None + + if action == "list": + rows = self._approvals.list_approved() + if not rows: + reply = ( + "No allowed users yet. " + "(The owner and CARBONVOICE_ALLOWED_USERS still apply.)" + ) + else: + lines = [] + for r in rows: + uid = r.get("user_id") or "" + nm = r.get("user_name") or "" + lines.append(f"• {nm} ({uid})" if nm else f"• {uid}") + reply = "Allowed users:\n" + "\n".join(lines) + + elif action == "allow": + if not arg: + reply = "Usage: /cv-allow-user " + else: + # Resolve the name from the channel they originally wrote in + # (saved in _pending_approval) — they're a stranger in the + # home channel, so resolving there yields nothing. + name = await self._resolve_pending_name(arg) + # Grab their origin channel BEFORE popping the pending entry, + # so we can tell them (in the channel they wrote in) that + # they've been added. + origin = (self._pending_approval.get(arg) or {}).get("channel") + ok = self._approvals.approve(arg, name) + self._pending_approval.pop(arg, None) + self._drop_pending_prompts_for(arg) + reply = ( + f"✅ Allowed {name or arg}. They can talk to me now." + if ok + else f"⚠️ Couldn't approve {arg} — allow-list store unavailable." + ) + # Tell the now-approved user (once) in the channel they wrote + # in, so they know they can start talking. Best-effort. + if ok and origin and self._api is not None: + # Greet them by first name when we resolved one; fall back + # to a plain greeting so we never send a dangling "Hey !". + greeting = ( + f"Hey {name.split()[0]}! " if name and name.split() else "Hey! " + ) + try: + await self.send( + origin, + f"✅ {greeting}You've been added to the allow-list — " + "you can talk to me now. Go ahead!", + ) + except Exception as exc: + logger.debug( + "carbonvoice: failed to notify approved user %s: %s", + arg, exc, + ) + + elif action == "deny": + if not arg: + reply = "Usage: /cv-deny-user " + else: + self._approvals.revoke(arg) # drop if previously approved + # Keep the pending entry but ARM its cooldown (notified_at=now) + # instead of deleting it. Deleting reset the cooldown, so a + # sender who spams messages got a NEW prompt within a second of + # being denied — an endless deny→message→prompt→deny loop. By + # arming the cooldown the add/remove cycle stays open (they can + # ask again after the cooldown) without instant re-prompting. + self._drop_pending_prompts_for(arg) + ent = self._pending_approval.get(arg) + if ent is None: + ent = {"channel": "", "notified_at": None} + self._pending_approval[arg] = ent + ent["notified_at"] = time.monotonic() + reply = ( + f"🚫 {arg} denied — removed from the allow-list. " + "They can request access again later." + ) + + if reply and self._api is not None: + try: + await self.send(channel_id, reply) + except Exception as exc: + logger.warning("carbonvoice: failed to send admin reply: %s", exc) + + async def _dispatch(self, event: MessageEvent) -> None: + try: + await self.handle_message(event) + except Exception as exc: + logger.exception("carbonvoice: dispatch failed: %s", exc) + finally: + # Clear the unread badge once we've at least attempted handling. + # On failure we still mark read — the operator sees the error in + # logs; leaving the notification doesn't trigger a retry. + if self._mark_read_enabled and self._api is not None: + channel_id = event.source.chat_id + msg_id = event.message_id + if channel_id and msg_id: + try: + await self._api.mark_read(channel_id, msg_id) + except Exception as exc: + logger.debug( + "carbonvoice: mark_read(%s, %s) failed: %s", + channel_id, msg_id, exc, + ) diff --git a/plugins/platforms/carbonvoice/api.py b/plugins/platforms/carbonvoice/api.py new file mode 100644 index 000000000000..f15702fb4f16 --- /dev/null +++ b/plugins/platforms/carbonvoice/api.py @@ -0,0 +1,811 @@ +"""Thin async wrapper around the Carbon Voice REST endpoints we use. + +Methods raise on HTTP/network errors so callers can map them to their own +result types (the adapter wraps them into ``SendResult``; ``standalone_send`` +catches everything and returns a dict). +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from typing import Any, Dict, List, Optional + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from .constants import ( + AGENT_ID_HEADER, + DEFAULT_BASE_URL, + HTTP_TIMEOUT, + TRANSIENT_RETRY_ATTEMPTS, + TRANSIENT_RETRY_BACKOFF_S, + TRANSIENT_STATUS, + USER_AGENT, +) +from .parse import client_headers, first_str + +logger = logging.getLogger(__name__) + + +class CarbonVoiceAPI: + """Stateless REST client. Open with ``await api.open()`` before use.""" + + def __init__(self, pat: str, base_url: str = DEFAULT_BASE_URL): + if not HTTPX_AVAILABLE: + raise RuntimeError("httpx not installed") + self._pat = pat + self._base_url = base_url.rstrip("/") + self._client: Optional["httpx.AsyncClient"] = None + + @property + def base_url(self) -> str: + return self._base_url + + async def open(self) -> None: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self._base_url, + headers=client_headers(self._pat), + timeout=HTTP_TIMEOUT, + ) + + def set_agent_id(self, user_guid: str) -> None: + """Tag all subsequent requests with the bot's own id (from /whoami). + + Goes into both ``agent-id`` and the User-Agent — the backend's + request logger only captures the ua field today, so that's where + the id must live for per-agent grouping. Only the few bootstrap + calls made before whoami resolves go out without it. + """ + if self._client is not None and user_guid: + self._client.headers[AGENT_ID_HEADER] = user_guid + self._client.headers["user-agent"] = ( + f"{USER_AGENT} (agent-id: {user_guid})" + ) + + async def close(self) -> None: + if self._client is not None: + try: + await self._client.aclose() + except Exception: + pass + self._client = None + + def _require_client(self) -> "httpx.AsyncClient": + if self._client is None: + raise RuntimeError("CarbonVoiceAPI used before open()") + return self._client + + async def _request_retrying(self, method: str, url: str, **kwargs): + """Issue a request, retrying ONLY on transient 5xx (502/503/504) and + network errors, with short backoff. For idempotent calls only — never + wrap a send/POST that creates a message (a retry could duplicate it). + + CV's gateway returns 502s in bursts; without this a transient hiccup + on a latency-critical read (e.g. v5 enrichment, a reaction) stalls + until the next ~5s poll tick. Retries recover in <1s. Returns the + final response (the caller still inspects status); raises the last + network error if every attempt failed to connect. + """ + client = self._require_client() + last_exc: Optional[Exception] = None + for attempt in range(TRANSIENT_RETRY_ATTEMPTS + 1): + try: + resp = await client.request(method, url, **kwargs) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + last_exc = exc + if attempt >= TRANSIENT_RETRY_ATTEMPTS: + raise + else: + if ( + resp.status_code in TRANSIENT_STATUS + and attempt < TRANSIENT_RETRY_ATTEMPTS + ): + logger.debug( + "carbonvoice: %s %s → %s, retry %d/%d", + method, url, resp.status_code, + attempt + 1, TRANSIENT_RETRY_ATTEMPTS, + ) + else: + return resp + await asyncio.sleep(TRANSIENT_RETRY_BACKOFF_S * (attempt + 1)) + # Exhausted retries on repeated network errors. + if last_exc is not None: + raise last_exc + return resp # pragma: no cover - loop always returns or raises + + async def whoami(self) -> "tuple[Optional[str], Optional[str]]": + """Return ``(user_guid, owner_id)`` for the bot account. + + - ``user_guid`` — the agent's own id (for the self-loop guard). + - ``owner_id`` — ``user.created_by``, the user who *created* the bot + account. That's the deny-by-default owner: always authorized, and + auto-detected here so no manual setup is needed. Either may be + None when not parseable. + """ + client = self._require_client() + resp = await client.get("/whoami") + resp.raise_for_status() + data = resp.json() or {} + user = data.get("user") or {} + return ( + first_str(user.get("user_guid"), user.get("_id"), user.get("id")), + first_str(user.get("created_by")), + ) + + async def fetch_recent( + self, + since_iso: str, + direction: str = "newer", + limit: int = 100, + ) -> List[Dict[str, Any]]: + client = self._require_client() + # ``use_last_updated: True`` filters by ``updated_at`` instead + # of ``created_at``. Required for voice messages with picker + # tags: ``created_at`` fires when the audio bytes land (no + # transcript, no tagged_user_ids), but the backend updates the + # message ~10–15 s later when STT and the tag-resolution job + # finish, bumping ``updated_at`` and emitting cv-api's + # ``message:updated`` socket event. With the old + # ``created_at`` filter, the polling/catch-up after that socket + # event missed the message entirely — its ``created_at`` was + # already older than the cursor that advanced past the empty + # ``message:created`` window. SeenCache (TTL 5 min) handles the + # extra fan-out from messages that update multiple times in + # the lookback window. + body = { + "date": since_iso, + "direction": direction, + "limit": limit, + "use_last_updated": True, + } + resp = await client.post("/v3/messages/recent", json=body) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else [] + + async def send_message( + self, + channel_id: str, + content: str, + reply_to: Optional[str] = None, + ) -> Dict[str, Any]: + """POST /v3/messages/start — legacy. Prefer ``send_text_v5``. + + Kept for compatibility with code paths that still pass through the v3 + contract. New code should use ``send_text_v5`` which accepts + ``reply_to_message_id`` directly (the server resolves the thread + root, no reply-anchor resolution required) and uses + ``idempotency_key`` instead of the deprecated ``unique_client_id``. + """ + client = self._require_client() + body: Dict[str, Any] = { + "unique_client_id": str(uuid.uuid4()), + "transcript": content, + "is_text_message": True, + "is_streaming": False, + "channel_id": channel_id.strip(), + } + if reply_to: + body["reply_to_message_id"] = str(reply_to) + resp = await client.post("/v3/messages/start", json=body) + resp.raise_for_status() + return resp.json() if resp.content else {} + + # ── v5 transport ──────────────────────────────────────────────────── + # + # The v5 endpoints replace the v3 contract with cleaner naming + # (``reply_to_message_id`` as the reply field, ``idempotency_key`` + # in place of ``unique_client_id``) and split create paths by media + # kind: ``/text``, ``/audio`` (multipart), and ``/attachment`` (URLs). + # + # Threading contract (cv-api PR #277 / CV-13155, cv-contracts 4.0.1): + # the v5 *conversation* create routes accept ``reply_to_message_id`` + # — the id of the message being replied to. The backend resolves the + # thread *root* automatically (``resolveRootParentMessageId``): pass + # a root and it stays the root; pass a reply and the server attaches + # to that reply's root instead of rejecting it (the old + # "You cannot reply to a message that is a reply" 400 is gone). The + # only remaining reply error is cross-conversation (400). + # + # NOTE: the earlier ``thread_id`` input field was *renamed* to + # ``reply_to_message_id`` and ``thread_id`` is now in the v5 + # reject-deprecated-keys pipe — sending it returns a 400. Callers + # pass the thread root from the inbound message + # (``ConversationTracker.thread_id_of(msg)``) as + # ``reply_to_message_id``; root-resolves-to-itself keeps threading + # correct with no reply-anchor lookup. + + async def send_text_v5( + self, + conversation_id: str, + transcript: str, + reply_to_message_id: Optional[str] = None, + idempotency_key: Optional[str] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """POST /v5/messages/text — create a text message in a conversation. + + Returns the created MessageV5 dict on 2xx. ``reply_to_message_id`` + threads the message (see module docstring — the backend resolves + the thread root automatically); pass ``None`` for a new top-level + post. Sending the old ``thread_id`` key is rejected with 400 by + the v5 deprecated-fields pipe (cv-api PR #277). + + ``attachments`` is an optional list of + ``V5RequestAttachmentPayload`` dicts (same shape used by + :meth:`send_attachment_v5`). When the agent wants text + an + attached file in a single bubble (e.g. "here's the report" + a + .md file), pass both fields together — the server enforces a + non-empty ``transcript`` on this route, so use + :meth:`send_attachment_v5` for the attachment-only case. + """ + client = self._require_client() + body: Dict[str, Any] = { + "conversation_id": conversation_id.strip(), + "transcript": transcript, + "idempotency_key": idempotency_key or str(uuid.uuid4()), + } + if reply_to_message_id: + body["reply_to_message_id"] = str(reply_to_message_id) + if attachments: + body["attachments"] = attachments + resp = await client.post("/v5/messages/text", json=body) + resp.raise_for_status() + return resp.json() if resp.content else {} + + async def send_audio_v5( + self, + conversation_id: str, + audio_path: str, + reply_to_message_id: Optional[str] = None, + idempotency_key: Optional[str] = None, + duration_ms: Optional[int] = None, + ) -> Dict[str, Any]: + """POST /v5/messages/audio — multipart upload of an audio file. + + Sends two parts: ``payload`` (JSON with conversation_id / + reply_to_message_id / idempotency_key / duration) and + ``audio_file`` (the raw bytes of the file at ``audio_path``). The + server transcribes and threads the resulting message; returns the + created MessageV5 dict on 2xx. This is the *conversation* audio + route (``messages/audio``), which accepts ``reply_to_message_id`` + — only the ``voicememos/audio`` route forbids it (cv-api PR #277). + + For Hermes' ``send_voice`` adapter override. + """ + import json as _json + from pathlib import Path as _Path + + client = self._require_client() + path = _Path(audio_path).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"audio file not found: {path}") + + payload: Dict[str, Any] = { + "conversation_id": conversation_id.strip(), + "idempotency_key": idempotency_key or str(uuid.uuid4()), + } + if reply_to_message_id: + payload["reply_to_message_id"] = str(reply_to_message_id) + if duration_ms is not None: + payload["duration"] = int(duration_ms) + + with path.open("rb") as fh: + files = { + "payload": (None, _json.dumps(payload), "application/json"), + "audio_file": (path.name, fh.read(), "application/octet-stream"), + } + resp = await client.post("/v5/messages/audio", files=files) + resp.raise_for_status() + return resp.json() if resp.content else {} + + async def send_attachment_v5( + self, + conversation_id: str, + attachments: List[Dict[str, Any]], + reply_to_message_id: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> Dict[str, Any]: + """POST /v5/messages/attachment — create a message with link attachments. + + ``attachments`` is a list of ``V5RequestAttachmentPayload`` dicts + (``{type, link, idempotency_key?, ...}``). The CV API expects + each attachment to reference an already-hosted resource by URL; + binary uploads via this endpoint are not supported (use + ``send_audio_v5`` for audio, or host the file elsewhere first and + pass the URL here). + + For Hermes' ``send_image`` / ``send_document`` adapter overrides + when the caller passes a URL. + """ + client = self._require_client() + body: Dict[str, Any] = { + "conversation_id": conversation_id.strip(), + "attachments": attachments, + "idempotency_key": idempotency_key or str(uuid.uuid4()), + } + if reply_to_message_id: + body["reply_to_message_id"] = str(reply_to_message_id) + resp = await client.post("/v5/messages/attachment", json=body) + resp.raise_for_status() + return resp.json() if resp.content else {} + + # ── Local-file attachment flow (v3 signed-URL + S3 + status) ──────── + # + # CV's v5 attachment endpoint is URL-based — it expects ``link`` to + # point to an already-hosted file. To send a *local* file (the + # agent's generated .md, an audio clip, a PDF) we follow the same + # four-step pattern the Flutter client uses: + # + # 1. ``get_signed_upload_urls`` → pre-signed S3 PUT URLs + # 2. ``upload_to_s3`` → PUT the raw bytes (no Bearer) + # 3. ``send_text_v5`` / + # ``send_attachment_v5`` → create the message with + # ``type: "file"`` referencing the + # canonical S3 URL (the signed URL + # minus its query string) + # 4. ``update_attachment`` → flip status from ``Initializing`` + # to ``Uploaded`` / ``Failed`` so + # the recipient's UI reflects + # completion + # + # The PR #251 backend change (commit d209c472) exposes ``status`` and + # ``percent_complete`` on the attachment response, which is what + # makes step 4 visible to clients. + + async def get_signed_upload_urls( + self, + files: List[Dict[str, str]], + ) -> List[Dict[str, str]]: + """POST /v3/attachments/signedurl — get pre-signed S3 upload URLs. + + ``files`` is a list of ``{"filename": ..., "mimetype": ...}`` + dicts (the server's ``CreateAttachmentUrls`` DTO). Returns the + ``AttachmentUrl`` list ``[{"url", "filename", "mimetype"}, ...]`` + in the same order, where each ``url`` is a short-lived S3 + pre-signed PUT URL. The canonical attachment ``link`` we hand + back to CV is this URL with the query string stripped (the + bucket's ACL is public-read for the rendered path). + """ + if not files: + return [] + client = self._require_client() + resp = await client.post( + "/v3/attachments/signedurl", + json={"files": files}, + headers={"x-api-version": "3"}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else [] + + async def upload_to_s3( + self, + signed_url: str, + file_path: str, + mime_type: str, + ) -> None: + """PUT the file at *file_path* to *signed_url* directly. + + The signed URL embeds its own AWS credentials in the query + string, so we must NOT send our ``Authorization: Bearer …`` + header on this request — that's why we go via a one-shot + ``httpx.AsyncClient`` instead of ``self._client``. Raises on + non-2xx so the caller can flip the attachment status to + ``Failed``. + """ + from pathlib import Path as _Path + + path = _Path(file_path).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"file not found: {path}") + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as plain: + with path.open("rb") as fh: + resp = await plain.put( + signed_url, + content=fh.read(), + headers={"Content-Type": mime_type}, + ) + resp.raise_for_status() + + async def update_attachment( + self, + message_id: str, + attachment_id: str, + body: Dict[str, Any], + ) -> None: + """PUT /messages/{message_id}/attachment/{attachment_id}. + + Used to flip ``status`` (``Initializing`` → ``Uploading`` → + ``Uploaded`` / ``Failed``) and ``percent_complete`` on an + attachment after the S3 upload settles. ``body`` should carry + the full attachment row the server expects — ``type``, ``link``, + ``filename``, ``mime_type``, ``status``, ``percent_complete``. + """ + client = self._require_client() + resp = await client.put( + f"/messages/{message_id}/attachment/{attachment_id}", + json=body, + ) + resp.raise_for_status() + + # ── Inbound attachment download (PR 7) ────────────────────────────── + # + # CV's inbound messages carry ``attachments[]`` entries whose + # ``link`` is the canonical S3 URL — but that URL requires AWS + # auth (returns 403 to unauthenticated requests). To consume the + # file we ask CV for a short-lived pre-signed GET URL via + # ``GET /attachments/signedurl/:attachment_id`` (authenticated with + # our Bearer), then download the bytes from S3 with no auth header + # (the signature lives in the query string). + + async def get_attachment_download_url(self, attachment_id: str) -> str: + """GET /attachments/signedurl/:attachment_id — pre-signed S3 GET URL. + + Returns the URL as a plain string (CV's controller returns the + URL as the bare response body, no JSON envelope). The signature + in the query string makes the URL self-authenticating for the + S3 GET that follows; do NOT send our Bearer header on that + request (S3 would 400 on the unexpected auth). + """ + client = self._require_client() + resp = await client.get(f"/attachments/signedurl/{attachment_id}") + resp.raise_for_status() + return self._unquote_url(resp.text) + + @staticmethod + def _unquote_url(text: str) -> str: + # CV returns signed URLs either as a plain string or wrapped in + # quotes (JSON string). Strip leading/trailing quotes either way. + url = text.strip() + if len(url) >= 2 and url[0] == url[-1] and url[0] in ('"', "'"): + url = url[1:-1] + return url + + async def download_attachment( + self, + attachment_id: str, + dest_dir: "Path", + *, + filename: Optional[str] = None, + max_bytes: Optional[int] = None, + ) -> "Path": + """Resolve the attachment's signed URL and stream bytes to disk. + + ``dest_dir`` is created if missing. ``filename`` overrides the + on-disk name (default: the attachment_id with no extension — + callers that know the filename should pass it). ``max_bytes`` + rejects responses whose ``Content-Length`` exceeds the cap so + we don't bloat the agent context with multi-MB uploads. + + Raises: + ValueError: if ``max_bytes`` is set and the response is + larger. + httpx.HTTPStatusError: on the signed-URL fetch or the S3 + download. + """ + signed_url = await self.get_attachment_download_url(attachment_id) + return await self._download_from_signed_url( + signed_url, + attachment_id, + dest_dir, + filename=filename, + max_bytes=max_bytes, + ) + + async def _download_from_signed_url( + self, + signed_url: str, + attachment_id: str, + dest_dir: "Path", + *, + filename: Optional[str] = None, + max_bytes: Optional[int] = None, + ) -> "Path": + """Stream a pre-signed S3 GET URL to ``dest_dir``; shared by the + regular and share-link attachment download paths.""" + from pathlib import Path as _Path + + dest_dir = _Path(dest_dir).expanduser() + dest_dir.mkdir(parents=True, exist_ok=True) + out_name = filename or f"{attachment_id}.bin" + out_path = dest_dir / out_name + + # S3 GET — use a fresh client without our Bearer header, since + # the signed URL carries its own credentials in the query. + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as plain: + async with plain.stream("GET", signed_url) as resp: + resp.raise_for_status() + if max_bytes is not None: + cl = resp.headers.get("content-length") + if cl and int(cl) > max_bytes: + raise ValueError( + f"attachment {attachment_id} too large: " + f"{cl} bytes > limit {max_bytes}" + ) + with out_path.open("wb") as fh: + written = 0 + async for chunk in resp.aiter_bytes(): + fh.write(chunk) + written += len(chunk) + if max_bytes is not None and written > max_bytes: + # Truncate + raise — partial file gets + # cleaned up by the caller's exception + # handler when it falls out of scope. + fh.close() + out_path.unlink(missing_ok=True) + raise ValueError( + f"attachment {attachment_id} exceeded " + f"limit {max_bytes} mid-stream" + ) + return out_path + + # ── Forwarded messages (share links) ──────────────────────────────── + # + # Forwarding a message in CV creates a *share link* and stamps its id + # on the new wrapper message as ``share_link_id``. The wrapper carries + # only the forwarder's optional comment — the original content + # (transcript + attachments) lives behind the share link. Same flow + # cv-claude-channels uses. Note the attachment signed-URL route is + # share-link-scoped (NOT the regular ``/attachments/signedurl/:id``): + # the bot may have access to the forward without having access to the + # original message's channel, and the share-link route authorizes via + # the link itself. + + async def get_share_link( + self, share_link_id: str + ) -> Optional[Dict[str, Any]]: + """GET /v3/message-sharelinks/{id} — share-link + shared_message. + + Returns the share-link dict (with ``shared_message`` carrying the + original message's ``creator_id`` / ``text_models`` / + ``attachments``) or None on 4xx (revoked, expired, no access). + Retries transient 5xx — this read sits on the latency-critical + inbound path. + """ + resp = await self._request_retrying( + "GET", f"/v3/message-sharelinks/{share_link_id}" + ) + if resp.status_code >= 400 or not resp.content: + return None + data = resp.json() + return data if isinstance(data, dict) else None + + async def get_share_link_attachment_download_url( + self, share_link_id: str, attachment_id: str + ) -> str: + """GET /message-sharelinks/{sl}/attachments/signedurl/{att}. + + Pre-signed S3 GET URL for an attachment on the *shared* (original) + message, authorized through the share link. Plain-string response, + same contract as :meth:`get_attachment_download_url`. + """ + client = self._require_client() + resp = await client.get( + f"/message-sharelinks/{share_link_id}" + f"/attachments/signedurl/{attachment_id}" + ) + resp.raise_for_status() + return self._unquote_url(resp.text) + + async def download_share_link_attachment( + self, + share_link_id: str, + attachment_id: str, + dest_dir: "Path", + *, + filename: Optional[str] = None, + max_bytes: Optional[int] = None, + ) -> "Path": + """Download an attachment of a forwarded message to ``dest_dir``. + + Same semantics as :meth:`download_attachment` (size cap, + ValueError on overflow) but resolves the signed URL through the + share-link-scoped route. + """ + signed_url = await self.get_share_link_attachment_download_url( + share_link_id, attachment_id + ) + return await self._download_from_signed_url( + signed_url, + attachment_id, + dest_dir, + filename=filename, + max_bytes=max_bytes, + ) + + async def get_message_v5(self, message_id: str) -> Optional[Dict[str, Any]]: + """GET /v5/messages/{id} — returns the flat MessageV5 dict or None. + + The v5 single-GET wraps its payload in a ``{"message": {...}}`` + envelope (unlike ``GET /v3/messages/{id}``, which is flat). We + unwrap it here so callers receive the flat shape that the + ``extract_*`` helpers and the mention gate expect: + ``tagged_user_ids``, ``parent_message_id``, and ``transcript`` + live on the message object, not the envelope. + + Returning the envelope unchanged hid every field behind the + ``message`` key — that was the bug that silently dropped + @-mentions in group channels: the enriched payload's + ``tagged_user_ids`` was invisible to ``is_user_mentioned`` + (DMs masked it, since the gate passes them regardless). + + ``parent_message_id`` is the canonical public thread field + (cv-contracts 4.0.1 / cv-api PR #277 removed the short-lived + ``thread_id`` field). The unwrap is defensive: if the endpoint + ever returns a flat body, that is passed through unchanged. + """ + resp = await self._request_retrying("GET", f"/v5/messages/{message_id}") + if resp.status_code >= 400 or not resp.content: + return None + data = resp.json() + if not isinstance(data, dict): + return None + inner = data.get("message") + return inner if isinstance(inner, dict) else data + + async def get_messages_by_ids_v5( + self, conversation_id: str, message_ids: List[str] + ) -> List[Dict[str, Any]]: + """POST /v5/messages/by-ids — batch fetch of multiple MessageV5s. + + Used by the thread-context fetch path (see + ``adapter._fetch_thread_context``) to pull the transcripts for the + message ids that ``list_channel_message_index`` identified as + belonging to the thread, in a single round-trip. + + The endpoint requires BOTH ``conversation_id`` and ``message_ids`` + — it rejects the message-id list alone with a 400 + ("conversation_id should not be empty"). The returned items are + flat MessageV5 dicts (no ``{"message": …}`` envelope, unlike the + single ``GET /v5/messages/{id}``), so ``extract_*`` helpers work on + them directly. + """ + if not message_ids: + return [] + client = self._require_client() + resp = await client.post( + "/v5/messages/by-ids", + json={"conversation_id": conversation_id.strip(), "message_ids": message_ids}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else [] + + async def list_channel_message_index( + self, + channel_id: str, + *, + limit: int = 200, + direction: str = "older", + ) -> List[Dict[str, Any]]: + """GET /messages//index — lightweight list-by-channel. + + Returns ``MessageIndex`` items (``message_id`` / + ``parent_message_id`` / ``status`` / ``created_at`` / ...) without + transcripts. Used by the thread-context path: we list the channel's + recent messages, filter client-side by ``parent_message_id == + thread_id`` (CV is flat — every reply's ``parent_message_id`` is + the true root, see DEVELOPMENT.md §4), then batch-fetch the + identified ids via :meth:`get_messages_by_ids_v5`. + + ``direction='older'`` defaults to "the last messages in the + channel" — what we want for thread context. The caller passes + ``limit`` sized for typical active-channel volume (200 is a + reasonable upper bound for a 30-message thread cap). + + This is a v3-only endpoint today; the cv-api roadmap may add a + more direct ``GET /v5/channels/:id/threads/:thread_id/messages`` + in the future, at which point the workaround here collapses to a + single call. + """ + client = self._require_client() + params: Dict[str, Any] = { + "limit": int(limit), + "direction": direction, + } + resp = await client.get(f"/messages/{channel_id}/index", params=params) + resp.raise_for_status() + data = resp.json() or {} + results = data.get("results") + if isinstance(results, list): + return results + return data if isinstance(data, list) else [] + + async def fetch_reactions(self) -> List[Dict[str, Any]]: + """GET /reactions — returns the workspace's available reactions.""" + client = self._require_client() + resp = await client.get("/reactions") + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, list) else [] + + async def react(self, reaction_id: str, message_id: str) -> None: + """POST /reactions/{reaction_id}/{message_id} — empty body. + + Retries transient 5xx: re-reacting is idempotent server-side (the same + reaction by the same user is a no-op), so a retry can't duplicate, and + the visual ack shouldn't be lost to a one-off CV 502. + """ + resp = await self._request_retrying( + "POST", f"/reactions/{reaction_id}/{message_id}" + ) + resp.raise_for_status() + + async def mark_read(self, channel_id: str, message_id: str) -> None: + """DELETE /notifications/{channel}/{message} — clears the unread badge.""" + client = self._require_client() + resp = await client.delete( + f"/notifications/{channel_id}/{message_id}", + params={"type": "message", "notification_removal_mode": "hard"}, + ) + resp.raise_for_status() + + async def get_message(self, message_id: str) -> Optional[Dict[str, Any]]: + """GET /v3/messages/{message_id} — returns the message dict or None on 4xx. + + Same payload shape as inbound Socket.IO / fetch_recent messages, so + parse helpers (``extract_transcript``, ``extract_creator_id``, etc.) + work unchanged. Used to resolve the text of a parent message when an + inbound reply carries ``parent_message_id`` — gives the agent the + thread context it would otherwise have to guess at. + """ + client = self._require_client() + resp = await client.get(f"/v3/messages/{message_id}") + if resp.status_code >= 400: + return None + return resp.json() if resp.content else None + + async def get_channel(self, channel_id: str) -> Optional[Dict[str, Any]]: + """GET /channel/{id} — returns the PersonalizedChannel dict or None on 4xx. + + Carbon Voice's response exposes ``type`` (directMessage | + customerConversation | namedConversation | asyncMeeting) and + ``dm_hash`` (null for non-DMs) — both usable to discriminate DM + vs group conversation when gating the agent's behavior. + """ + client = self._require_client() + resp = await client.get(f"/channel/{channel_id}") + if resp.status_code >= 400: + return None + return resp.json() if resp.content else None + + +async def standalone_send( + pat: str, + base_url: str, + channel_id: str, + content: str, +) -> Dict[str, Any]: + """One-shot send for out-of-process delivery (cron). No persistent client.""" + if not HTTPX_AVAILABLE: + return {"success": False, "error": "httpx not installed"} + body = { + "unique_client_id": str(uuid.uuid4()), + "transcript": content, + "is_text_message": True, + "is_streaming": False, + "channel_id": channel_id.strip(), + } + async with httpx.AsyncClient( + base_url=base_url.rstrip("/"), + headers=client_headers(pat), + timeout=HTTP_TIMEOUT, + ) as client: + try: + resp = await client.post("/v3/messages/start", json=body) + resp.raise_for_status() + data = resp.json() if resp.content else {} + return { + "success": True, + "message_id": first_str(data.get("message_id"), data.get("id")), + } + except Exception as exc: + return {"success": False, "error": str(exc)} diff --git a/plugins/platforms/carbonvoice/audit.py b/plugins/platforms/carbonvoice/audit.py new file mode 100644 index 000000000000..2f74fc84a5b6 --- /dev/null +++ b/plugins/platforms/carbonvoice/audit.py @@ -0,0 +1,161 @@ +"""Allowlist gating + ignored-sender audit log. + +Hermes core already enforces ``CARBONVOICE_ALLOWED_USERS`` / +``CARBONVOICE_ALLOW_ALL_USERS`` *after* the adapter dispatches. We +replicate the check inside the adapter so we can: + + 1. Short-circuit before the agent ever sees the message (cheaper). + 2. Record the rejection in an append-only audit log with the resolved + username, so the operator can see who's trying to reach the bot. + +Log path defaults to ``$HERMES_HOME/logs/carbonvoice-ignored-senders.log`` +and is one JSON object per line: ``{"time", "user_id", "username", "channel_id"}``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Set + +if TYPE_CHECKING: + from .channels import ChannelCache + from .permits import ApprovalStore + +logger = logging.getLogger(__name__) + + +class AllowlistGate: + """**Deny-by-default** access control for inbound Carbon Voice messages. + + A user may reach the agent if ANY of these holds: + + 1. **allow-all** is explicitly enabled (``CARBONVOICE_ALLOW_ALL_USERS=true``) + — the escape hatch back to the old open behavior. + 2. they are the **owner** — ``whoami.created_by``, the user who created + the bot account. Auto-detected at connect; always allowed. This is + what makes deny-by-default usable without any manual setup. + 3. they are in ``CARBONVOICE_ALLOWED_USERS`` (static env list). + 4. they were **approved at runtime** via ``/cv-allow`` — the + :class:`~permits.ApprovalStore` (Hermes core's ``PairingStore``). + + Default (no config) → **only the owner**. This closes the security hole + where anyone on a shared channel could ask the agent to read or run + things on the host. The owner grows the list interactively from the + home channel (``/cv-allow ``) without restarting. + + History: the default used to be allow-all (``CARBONVOICE_ALLOW_ALL_USERS`` + defaulted true / was an opt-out). It is now an opt-IN. Existing + deployments with an empty allow-list will, after this change, only + answer the owner until they approve others — see README/CHANGELOG. + """ + + def __init__( + self, + allow_all: bool, + allowed_ids: Set[str], + approvals: Optional["ApprovalStore"] = None, + ): + self._allow_all = allow_all + self._allowed_ids = allowed_ids + self._approvals = approvals + self._owner_id: Optional[str] = None # set at connect via set_owner() + + @classmethod + def from_env( + cls, approvals: Optional["ApprovalStore"] = None + ) -> "AllowlistGate": + raw = os.getenv("CARBONVOICE_ALLOWED_USERS", "") + allowed = {u.strip() for u in raw.split(",") if u.strip()} + # allow-all is now an explicit opt-IN (truthy enables it); deny is + # the default. + allow_all = os.getenv("CARBONVOICE_ALLOW_ALL_USERS", "").strip().lower() in ( + "true", "1", "yes", "on", + ) + return cls(allow_all=allow_all, allowed_ids=allowed, approvals=approvals) + + def set_owner(self, owner_id: Optional[str]) -> None: + """Record the bot owner (``whoami.created_by``). Always allowed.""" + self._owner_id = (owner_id or "").strip() or None + + @property + def owner_id(self) -> Optional[str]: + return self._owner_id + + def is_owner(self, user_id: Optional[str]) -> bool: + return bool(self._owner_id and user_id and user_id == self._owner_id) + + @property + def has_any_authorizer(self) -> bool: + """True if *anyone* can be allowed (owner / env list / allow-all). + + When this is False after connect, deny-by-default would mute the bot + for everyone — the adapter logs a loud bootstrap warning. + """ + return bool(self._allow_all or self._owner_id or self._allowed_ids) + + def is_allowed(self, user_id: Optional[str]) -> bool: + if self._allow_all: + return True + if not user_id: + return False + if self._owner_id and user_id == self._owner_id: + return True + if user_id in self._allowed_ids: + return True + if self._approvals is not None and self._approvals.is_approved(user_id): + return True + return False + + +class IgnoredSenderLog: + """Append-only JSON-lines log of rejected inbound senders.""" + + def __init__(self, path: Path, channels: "ChannelCache"): + self._path = path + self._channels = channels + + @property + def path(self) -> Path: + return self._path + + def record(self, user_id: str, channel_id: Optional[str] = None) -> None: + """Fire-and-forget — never blocks the inbound path.""" + asyncio.create_task(self._record(user_id, channel_id)) + + async def _record(self, user_id: str, channel_id: Optional[str]) -> None: + try: + # Resolve the name from the channel roster when we have a + # channel; an unauthorized sender may not be a collaborator, + # in which case this is None and we log just the guid. + username = "" + if user_id and channel_id: + username = ( + await self._channels.resolve_name(channel_id, user_id) or "" + ) + entry = { + "time": datetime.now(timezone.utc).isoformat(), + "user_id": user_id, + "username": username, + } + if channel_id: + entry["channel_id"] = channel_id + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except Exception as exc: + logger.debug("carbonvoice: ignored-sender log failed: %s", exc) + + +def default_ignored_log_path() -> Path: + """``$HERMES_HOME/logs/carbonvoice-ignored-senders.log`` with safe fallback.""" + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + except Exception: + home = Path.home() / ".hermes" + return home / "logs" / "carbonvoice-ignored-senders.log" diff --git a/plugins/platforms/carbonvoice/channels.py b/plugins/platforms/carbonvoice/channels.py new file mode 100644 index 000000000000..d4a720c49261 --- /dev/null +++ b/plugins/platforms/carbonvoice/channels.py @@ -0,0 +1,117 @@ +"""In-memory cache for Carbon Voice channel metadata. + +One ``GET /channel/{id}`` per channel populates two things, both keyed by +``channel_id`` and cached for the process lifetime: + + - **chat_type** ("dm" | "group") — channel kind almost never changes + after creation (a DM stays a DM forever). + - **roster** (``{user_guid → display name}``) — derived from the + channel's ``json_collaborators``. This is the canonical way to + resolve participant names: the standalone ``GET /v3/users/{id}`` + endpoint is dead (404), and the collaborator list rides on the same + payload we already fetch for chat-type, so names cost zero extra + calls. + +The first message in a new channel pays one API call; within the TTL +window every message after is free for both axes. A failed *initial* +lookup caches ``"dm"`` + an empty roster so the adapter degrades +gracefully (keeps responding, falls back to the raw guid for names) +rather than re-hitting the API per message. + +TTL: the payload is refreshed after ``ttl_s`` (default 30 min) so a +participant who joins mid-conversation shows up in the roster without a +gateway restart. ``chat_type`` is immutable so re-fetching it is wasted +but harmless — keeping one cache policy is simpler than two. A failed +*refresh* keeps the prior good values (we don't blow a known roster away +with an empty one on a transient hiccup). +""" + +from __future__ import annotations + +import logging +import time +from typing import Dict, Optional + +from .api import CarbonVoiceAPI +from .parse import chat_type_from_channel, extract_roster + +logger = logging.getLogger(__name__) + +# 30 min — long enough that a busy channel stays cache-warm, short enough +# that a new joiner is picked up within a reasonable window. Mirrors the +# thread-context TTL in conversations.py. +DEFAULT_CHANNEL_TTL_S = 1800 + + +class ChannelCache: + def __init__( + self, api: CarbonVoiceAPI, *, ttl_s: int = DEFAULT_CHANNEL_TTL_S + ): + self._api = api + self._type_cache: Dict[str, str] = {} + self._roster_cache: Dict[str, Dict[str, str]] = {} + self._loaded_at: Dict[str, float] = {} + self._ttl_s = ttl_s + + async def _ensure_loaded(self, channel_id: str) -> None: + """Fetch the channel and populate both caches, honoring the TTL. + + Returns early when a cached entry is still within ``ttl_s``. On a + refresh that fails, the prior cached values are kept (and the + timestamp bumped so we don't hammer the API on repeated failures). + """ + now = time.monotonic() + loaded = self._loaded_at.get(channel_id) + if loaded is not None and (now - loaded) <= self._ttl_s: + return + try: + data = await self._api.get_channel(channel_id) + except Exception as exc: + logger.debug( + "carbonvoice: get_channel(%s) failed: %s", channel_id, exc + ) + data = None + if data is None and channel_id in self._type_cache: + # Refresh failed but we have prior good values — keep them. + self._loaded_at[channel_id] = now + return + self._type_cache[channel_id] = chat_type_from_channel(data) + self._roster_cache[channel_id] = extract_roster(data) + self._loaded_at[channel_id] = now + + async def resolve_chat_type(self, channel_id: str) -> str: + """Return ``"dm"`` or ``"group"`` for *channel_id*. + + Defaults to ``"dm"`` on any lookup failure so the agent keeps + responding (previous behavior) rather than going silent because of + a transient channel-API hiccup. + """ + if not channel_id: + return "dm" + await self._ensure_loaded(channel_id) + return self._type_cache.get(channel_id, "dm") + + async def get_roster(self, channel_id: str) -> Dict[str, str]: + """Return ``{user_guid → display name}`` for *channel_id*. + + Empty dict on lookup failure. Shares the cached channel payload + with :meth:`resolve_chat_type`, so calling both for one message is + a single HTTP call. + """ + if not channel_id: + return {} + await self._ensure_loaded(channel_id) + return self._roster_cache.get(channel_id, {}) + + async def resolve_name( + self, channel_id: str, user_guid: str + ) -> Optional[str]: + """Display name for *user_guid* in *channel_id*, or ``None``. + + ``None`` means "not in this channel's collaborator list" — callers + fall back to the raw guid. + """ + if not channel_id or not user_guid: + return None + roster = await self.get_roster(channel_id) + return roster.get(user_guid) diff --git a/plugins/platforms/carbonvoice/constants.py b/plugins/platforms/carbonvoice/constants.py new file mode 100644 index 000000000000..95d305b18390 --- /dev/null +++ b/plugins/platforms/carbonvoice/constants.py @@ -0,0 +1,95 @@ +"""Carbon Voice plugin defaults shared across modules.""" + +from __future__ import annotations + +import re +from pathlib import Path + +DEFAULT_BASE_URL = "https://api.carbonvoice.app" +DEFAULT_POLL_INTERVAL_MS = 5_000 +DEFAULT_WS_RETRY_INITIAL_MS = 1_000 +DEFAULT_WS_RETRY_MAX_MS = 30_000 +DEFAULT_SEEN_TTL_S = 5 * 60 +DEFAULT_FLUSH_DEBOUNCE_S = 5.0 + +# How long a gate-rejected *voice* message in a group may stay revisit-held +# (cursor pinned, re-evaluated each tick) waiting for its picker tags. +# Flutter applies tags via the batch PUT only after STT (~10–30s after +# create); within this window a "no mention" verdict is provisional. Text +# messages carry tags on the create body and never hold. Override with +# CARBONVOICE_REVISIT_MAX_AGE_S. +DEFAULT_REVISIT_MAX_AGE_S = 90 + +# Delay before the one-shot self-scheduled re-tick that retries stuck / +# revisit-held messages. Keeps retries flowing in WS mode (where polling is +# stopped) without waiting for the next unrelated socket event. +STUCK_RETRY_DELAY_S = 6.0 + +# How long a message may stay "stuck" (no transcript yet) before we stop +# holding the cursor for it. CV usually finishes transcribing within +# seconds; a message with no transcript after this window almost certainly +# never will (image-only / system / failed STT). Past the cutoff we let it +# pass so it can't pin the cursor forever and re-feed the whole window on +# every poll/restart. Override with CARBONVOICE_STUCK_MAX_AGE_S. +DEFAULT_STUCK_MAX_AGE_S = 5 * 60 +HTTP_TIMEOUT = 30.0 +MAX_MESSAGE_LENGTH = 8000 + +# Request-source headers so the backend can categorize traffic per client +# (mirrors the Flutter app's lowercase-hyphenated headers like ``platform`` +# and ``mobile-app-version``). ``agent-name`` is static — it identifies the +# integration type (hermes vs openclaw vs cloud-channel vs the apps). +# ``agent-id`` is dynamic — the bot account's user_guid from /whoami, +# injected once known so traffic can also be grouped per agent account. +AGENT_NAME_HEADER = "agent-name" +AGENT_NAME_VALUE = "hermes" +AGENT_ID_HEADER = "agent-id" + + +def _plugin_version() -> str: + try: + text = (Path(__file__).parent / "plugin.yaml").read_text() + match = re.search(r"^version:\s*(\S+)", text, re.MULTILINE) + if match: + return match.group(1) + except OSError: + pass + return "unknown" + + +# The backend's request logger only captures a fixed header set (ua, +# mobile-app-version, platform), so the User-Agent is what actually lets it +# categorize Hermes traffic today — agent-name/agent-id above are sent for +# when the backend starts logging them. After /whoami the api client appends +# " (agent-id: )" so the ua field also distinguishes agent accounts. +USER_AGENT = f"hermes-plugin/{_plugin_version()}" + +# Carbon Voice's API gateway intermittently returns 502/503/504 (observed in +# bursts). A transient 5xx on a latency-critical GET/reaction would otherwise +# wait for the next poll tick (~5s) to recover; a couple of fast retries with +# short backoff recover in well under a second. Only idempotent reads/reactions +# retry — sends do NOT (a retried send could duplicate a delivered message). +TRANSIENT_RETRY_ATTEMPTS = 2 +TRANSIENT_RETRY_BACKOFF_S = 0.4 +TRANSIENT_STATUS = (502, 503, 504) + +# "acknowledged" is a built-in Carbon Voice reaction id — works out of the +# box without operator config. Override with CARBONVOICE_REACTION_ID after +# inspecting the available reactions logged on startup. +DEFAULT_REACTION_ID = "acknowledged" + +# "confused" (⁉️) is a built-in CV reaction. We put it on an unauthorized +# sender's first message as a silent "we saw you, you're pending approval" +# signal — instead of posting a text reply that clutters the channel and +# spams every old conversation when deny-by-default re-flags them. Override +# with CARBONVOICE_PENDING_REACTION_ID. +DEFAULT_PENDING_REACTION_ID = "confused" + +# One-tap owner approval: instead of copying "/cv-allow-user ", the owner +# just reacts on the bot's "X wants to talk to me" prompt — 💯 to allow, 👎 to +# block. Mirrors cv-claude-channels' reaction-based permission relay. These +# are CV built-in reaction *ids* (the id is what counts; CV stores the +# "negative" reaction with code ⛔ but clients render it as a thumbs-down 👎); +# override via CARBONVOICE_APPROVE_REACTION_ID / CARBONVOICE_REJECT_REACTION_ID. +DEFAULT_APPROVE_REACTION_ID = "affirmative" # 💯 +DEFAULT_REJECT_REACTION_ID = "negative" # 👎 (stored code ⛔) diff --git a/plugins/platforms/carbonvoice/conversations.py b/plugins/platforms/carbonvoice/conversations.py new file mode 100644 index 000000000000..9ee6913cd1ee --- /dev/null +++ b/plugins/platforms/carbonvoice/conversations.py @@ -0,0 +1,256 @@ +"""Per-conversation state for the Carbon Voice adapter. + +`ConversationTracker` consolidates the mutable per-thread state that +otherwise leaks across `adapter.py`. PR 1 introduces only the pieces +that replace existing adapter state: + +- **Reply anchors** — outbound threading targets, *keyed by thread_id* + (fixes the latent bug from `DEVELOPMENT.md §7.6`: the old + `adapter._last_inbound_msg` was keyed by `channel_id`, so two + concurrent threads in the same channel trampled each other). +- **Parent text cache** — small LRU around `get_message()` so the + adapter does not re-fetch the same parent transcript every time a + star-shaped thread receives a new reply. + +Later PRs grow this module without changing the public surface PR 1 +introduces: + +- PR 2 wires `thread_id_of(msg)` into `SessionSource.thread_id` to + enable shared sessions in groups. +- PR 3 adds `mark_engaged` / `is_engaged` / `record_outbound` / + `is_bot_message` for the thread-memory mention gate shortcuts. + +Thread root resolution is a synchronous one-liner (`thread_id_of`) +because Carbon Voice enforces flat replies — the Flutter client's +`Message.getTopLevelGuid()` returns `parent_message_id` or self +without walking, the send queue redirects any reply targeting a +non-top-level message back to its parent, and the backend rejects +depth-2+ replies with HTTP 400. See `DEVELOPMENT.md §4` and §7.4 for +the full chain of evidence. +""" + +from __future__ import annotations + +import logging +import time +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from .api import CarbonVoiceAPI +from .parse import extract_transcript, first_str + +logger = logging.getLogger(__name__) + +# Defaults sized for typical per-process working sets: +# - ~1000 reply anchors covers very active workspaces without +# bounded growth (one entry per active thread per process). +# - ~128 parent texts is enough for star-shaped threads where many +# replies share the same root; smaller because each entry carries +# a full transcript string. +DEFAULT_MAX_REPLY_ANCHORS = 1000 +DEFAULT_MAX_PARENT_TEXT = 128 +# Thread-context cache (PR 4): per-thread formatted prefix that the adapter +# prepends on the first @mention so the agent has the prior thread history. +# 30-minute TTL is the working balance — long enough that a quick follow-up +# reuses the cached context (avoiding a second index + by-ids fetch), short +# enough that a long-quiet thread refetches before injecting stale state. +DEFAULT_MAX_THREAD_CONTEXT = 200 +DEFAULT_THREAD_CONTEXT_TTL_S = 1800 + + +@dataclass +class _ThreadContextEntry: + """One cached thread-context fetch. + + ``content`` is the formatted prefix string the adapter will prepend to + the inbound message text; ``fetched_at`` is monotonic seconds so the + TTL check is robust against wall-clock jumps. + """ + + content: str + fetched_at: float + + +class _LRUDict: + """Tiny in-process LRU around ``collections.OrderedDict``. + + Insertion / access bump the entry to the most-recently-used slot; + when ``max_size`` is exceeded the oldest entry is evicted. Used by + `ConversationTracker` for bounded reply-anchor and parent-text + caches. + """ + + __slots__ = ("_max_size", "_data") + + def __init__(self, max_size: int): + if max_size <= 0: + raise ValueError("max_size must be positive") + self._max_size = max_size + self._data: "OrderedDict[str, Any]" = OrderedDict() + + def get(self, key: str) -> Any: + if key in self._data: + self._data.move_to_end(key) + return self._data[key] + return None + + def set(self, key: str, value: Any) -> None: + if key in self._data: + self._data.move_to_end(key) + self._data[key] = value + if len(self._data) > self._max_size: + self._data.popitem(last=False) + + def __contains__(self, key: str) -> bool: + return key in self._data + + def __len__(self) -> int: + return len(self._data) + + +class ConversationTracker: + """Per-process per-conversation memory. + + The adapter delegates two state axes to this tracker in PR 1: + + 1. **Reply anchors** — the message id we thread our outbound reply + under, looked up by ``thread_id``. Without rekeying away from + ``channel_id``, two parallel threads in the same channel + overwrite each other's anchor. + 2. **Parent text cache** — `get_parent_text` mirrors the old + ``adapter._resolve_parent_text`` but caches each fetched + parent transcript in a small LRU. + + All sizes are tunable via constructor kwargs so tests can exercise + eviction without thousands of inserts. + """ + + def __init__( + self, + api: Optional[CarbonVoiceAPI] = None, + *, + max_reply_anchors: int = DEFAULT_MAX_REPLY_ANCHORS, + max_parent_text: int = DEFAULT_MAX_PARENT_TEXT, + max_thread_context: int = DEFAULT_MAX_THREAD_CONTEXT, + thread_context_ttl_s: int = DEFAULT_THREAD_CONTEXT_TTL_S, + ): + self._api = api + self._reply_anchors = _LRUDict(max_reply_anchors) + self._parent_text = _LRUDict(max_parent_text) + self._thread_context = _LRUDict(max_thread_context) + self._thread_context_ttl_s = thread_context_ttl_s + + # ── Thread resolution ─────────────────────────────────────────── + + @staticmethod + def thread_id_of(msg: Dict[str, Any]) -> Optional[str]: + """Return the canonical thread root id for *msg*. + + For top-level messages this is the message's own id. For replies + it is ``parent_message_id`` (which CV guarantees is the true + root — see module docstring). Returns ``None`` only when the + payload is malformed enough that neither field is present. + """ + parent = first_str( + msg.get("parent_message_id"), + msg.get("parent_message_guid"), + ) + if parent: + return parent + return first_str(msg.get("message_id"), msg.get("_id")) + + # ── Reply anchors (outbound threading) ────────────────────────── + + def get_reply_anchor(self, thread_id: Optional[str]) -> Optional[str]: + """Return the message id we should thread the next reply under.""" + if not thread_id: + return None + return self._reply_anchors.get(thread_id) + + def set_reply_anchor(self, thread_id: str, message_id: str) -> None: + """Record *message_id* as the next reply-target for *thread_id*.""" + if not thread_id or not message_id: + return + self._reply_anchors.set(thread_id, message_id) + + def clear_reply_anchor(self, thread_id: str) -> None: + """Drop the anchor for *thread_id* (used on stale-anchor recovery).""" + if not thread_id: + return + self._reply_anchors._data.pop(thread_id, None) + + # ── Parent transcript cache ───────────────────────────────────── + + # ── Thread context cache (PR 4) ───────────────────────────────── + + def get_cached_thread_context( + self, thread_id: Optional[str] + ) -> Optional[str]: + """Return the cached thread-context prefix for *thread_id*. + + Returns ``None`` if not cached, or if the entry has aged past + ``thread_context_ttl_s``. The TTL check uses monotonic time so + wall-clock jumps don't make entries spuriously valid/invalid. + """ + if not thread_id: + return None + entry = self._thread_context.get(thread_id) + if entry is None: + return None + if (time.monotonic() - entry.fetched_at) > self._thread_context_ttl_s: + # Expired — drop the entry so the LRU slot frees up next eviction + self._thread_context._data.pop(thread_id, None) + return None + return entry.content + + def set_cached_thread_context( + self, thread_id: str, content: str + ) -> None: + """Store the formatted thread-context prefix for *thread_id*.""" + if not thread_id: + return + self._thread_context.set( + thread_id, + _ThreadContextEntry(content=content, fetched_at=time.monotonic()), + ) + + def clear_cached_thread_context(self, thread_id: str) -> None: + """Drop the cached entry for *thread_id* (forces a refetch next time).""" + if not thread_id: + return + self._thread_context._data.pop(thread_id, None) + + # ── Parent transcript cache ───────────────────────────────────── + + async def get_parent_text(self, parent_id: Optional[str]) -> Optional[str]: + """Return the cached or freshly fetched transcript of *parent_id*. + + Mirrors the prior ``adapter._resolve_parent_text`` behavior + (failures degrade to ``None`` so threading still works without + injecting parent context). Adds an LRU cache so star-shaped + threads — many replies sharing a single parent — pay one fetch + instead of N. + """ + if not parent_id or self._api is None: + return None + cached = self._parent_text.get(parent_id) + if cached is not None: + # Cache hit. ``cached`` is "" when an earlier fetch found + # the parent but its transcript was empty; we treat that as + # "no useful context" and return None to the caller, but + # keep the empty value cached so we don't re-fetch. + return cached or None + try: + parent_msg = await self._api.get_message(parent_id) + except Exception as exc: + logger.debug( + "carbonvoice: get_parent_text(%s) failed: %s", parent_id, exc + ) + return None + if not parent_msg: + self._parent_text.set(parent_id, "") + return None + text = extract_transcript(parent_msg) or "" + self._parent_text.set(parent_id, text) + return text or None diff --git a/plugins/platforms/carbonvoice/dedupe.py b/plugins/platforms/carbonvoice/dedupe.py new file mode 100644 index 000000000000..4a6acc2e2bc1 --- /dev/null +++ b/plugins/platforms/carbonvoice/dedupe.py @@ -0,0 +1,40 @@ +"""In-memory TTL cache for inbound message deduplication. + +Both the WebSocket and the polling fallback can deliver the same message, +and ``message:updated`` re-fires when transcription completes. We dedupe +on ``message_id`` with a short TTL so the same id doesn't dispatch twice +inside the window but old ids don't grow the map unboundedly. +""" + +from __future__ import annotations + +import time +from typing import Dict + +from .constants import DEFAULT_SEEN_TTL_S + + +class SeenCache: + def __init__(self, ttl_s: float = DEFAULT_SEEN_TTL_S): + self._ttl_s = ttl_s + self._seen: Dict[str, float] = {} + + def is_seen(self, message_id: str) -> bool: + exp = self._seen.get(message_id) + if exp is None: + return False + if time.time() > exp: + self._seen.pop(message_id, None) + return False + return True + + def mark(self, message_id: str) -> None: + self._seen[message_id] = time.time() + self._ttl_s + if len(self._seen) % 100 == 0: + self._sweep() + + def _sweep(self) -> None: + now = time.time() + for mid, exp in list(self._seen.items()): + if now > exp: + self._seen.pop(mid, None) diff --git a/plugins/platforms/carbonvoice/gate.py b/plugins/platforms/carbonvoice/gate.py new file mode 100644 index 000000000000..5edac503aa13 --- /dev/null +++ b/plugins/platforms/carbonvoice/gate.py @@ -0,0 +1,143 @@ +"""Mention-aware message gate for the Carbon Voice adapter. + +Decides whether an inbound message should reach the agent. DMs always +pass; group channels require an @-mention of the agent unless the +channel is on a free-response allowlist (or the global mention +requirement is disabled). + +Configuration (all optional, read from env at adapter startup): + + CARBONVOICE_REQUIRE_MENTION + ``true`` (default) — in group channels, only process messages + that @-mention the agent. + ``false`` — process every message in every channel (preserves + the pre-gate "bot responds to everything" behavior, useful for + personal-bot setups). + + CARBONVOICE_FREE_RESPONSE_CHANNELS + Comma-separated channel_guids where the agent always responds, + regardless of mention. Useful for channels dedicated to the + agent (e.g., a "bot-chat" room). + + CARBONVOICE_IGNORED_CHANNELS + Comma-separated channel_guids where the agent NEVER responds, + even when mentioned. Hard veto — applied before every other + rule. Useful for muting channels the bot was added to by + accident or for maintenance windows. + +DMs are never affected by these toggles. ``IGNORED_CHANNELS`` is the +one exception: it also vetoes DMs if the channel_guid is listed (so +operators can mute even a 1:1 channel without revoking the user's +allowlist entry). +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional, Set + +from .parse import is_user_mentioned + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class GateDecision: + """Result of a single gate evaluation. + + ``process`` is the field the adapter acts on; ``reason`` is + surfaced in debug logs so operators can audit why a particular + message was accepted or skipped without rebuilding the gate's + decision tree in their head. + + ``revisitable`` distinguishes rejections whose verdict can flip on + a later re-fire of the same message_id (currently only "group + channel without @-mention" — because cv-api's tag-resolution job + runs async and emits a ``message:updated`` once ``tagged_user_ids`` + is populated, which can turn an earlier "no mention" into a + "mentioned"). The adapter uses this to decide whether to mark the + message as seen in the dedup cache or leave the door open for a + follow-up update to re-evaluate. All other rejections are stable + (ignored channel, allowlist) and don't benefit from re-evaluation. + """ + + process: bool + reason: str + revisitable: bool = False + + +class MentionGate: + """Stateless gate that decides whether to dispatch a message to the agent. + + Stateless by design: all of its inputs come from the message + payload, the resolved chat_type, and env-driven config snapshotted + at construction. No per-channel memory yet — thread continuity is + a follow-up (see roadmap in README). + """ + + def __init__( + self, + *, + require_mention: bool, + free_response_channels: Set[str], + ignored_channels: Set[str], + ): + self._require_mention = require_mention + self._free_response_channels = free_response_channels + self._ignored_channels = ignored_channels + + @classmethod + def from_env(cls) -> "MentionGate": + return cls( + require_mention=_env_bool("CARBONVOICE_REQUIRE_MENTION", default=True), + free_response_channels=_env_set("CARBONVOICE_FREE_RESPONSE_CHANNELS"), + ignored_channels=_env_set("CARBONVOICE_IGNORED_CHANNELS"), + ) + + def evaluate( + self, + *, + msg: Dict[str, Any], + chat_type: str, + channel_id: str, + self_user_id: Optional[str], + ) -> GateDecision: + """Return a GateDecision for this message. + + Order of checks (most specific veto first): + 1. Ignored channels — hard veto, applies to DMs too. + 2. DMs — always process (the canonical "talk to the bot" path). + 3. Free-response channels — explicit opt-out from the gate. + 4. ``require_mention`` disabled globally — explicit opt-out. + 5. Group channel + @-mention of the agent — process. + 6. Group channel without mention — skip. + """ + if channel_id in self._ignored_channels: + return GateDecision(False, f"channel {channel_id} on ignored list") + if chat_type == "dm": + return GateDecision(True, "dm always processes") + if channel_id in self._free_response_channels: + return GateDecision(True, "free-response channel") + if not self._require_mention: + return GateDecision(True, "require_mention disabled") + if is_user_mentioned(msg, self_user_id): + return GateDecision(True, "agent @-mentioned") + return GateDecision( + False, "group channel without @-mention", revisitable=True, + ) + + +def _env_bool(name: str, *, default: bool) -> bool: + val = os.getenv(name) + if val is None: + return default + return val.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_set(name: str) -> Set[str]: + val = os.getenv(name) + if not val: + return set() + return {c.strip() for c in val.split(",") if c.strip()} diff --git a/plugins/platforms/carbonvoice/parse.py b/plugins/platforms/carbonvoice/parse.py new file mode 100644 index 000000000000..1107fe550f51 --- /dev/null +++ b/plugins/platforms/carbonvoice/parse.py @@ -0,0 +1,388 @@ +"""Pure parsing helpers for Carbon Voice payloads. + +No I/O, no state, no async — everything here is a deterministic function +of the input dict. Keeps the rest of the plugin free of payload-shape knowledge. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .constants import AGENT_NAME_HEADER, AGENT_NAME_VALUE, USER_AGENT + + +def auth_headers(pat: str) -> Dict[str, str]: + """Carbon Voice accepts PATs via Bearer auth and other keys via x-api-key.""" + trimmed = pat.strip() + if trimmed.lower().startswith("cv_pat_"): + return {"Authorization": f"Bearer {trimmed}"} + return {"x-api-key": trimmed} + + +def client_headers(pat: str) -> Dict[str, str]: + """Default headers for every Carbon Voice API client: auth plus the + source tags the backend uses to attribute traffic to Hermes (the + User-Agent is the one its request logger captures today).""" + return { + **auth_headers(pat), + AGENT_NAME_HEADER: AGENT_NAME_VALUE, + "user-agent": USER_AGENT, + } + + +def first_str(*vals: Any) -> Optional[str]: + """Return the first non-empty string in *vals*, or None.""" + for v in vals: + if isinstance(v, str) and v.strip(): + return v.strip() + return None + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def now_utc() -> "datetime": + """Timezone-aware current UTC time (for :func:`message_age_seconds`).""" + return datetime.now(timezone.utc) + + +def extract_transcript(msg: Dict[str, Any]) -> str: + """Pull the human-readable transcript from a CV message payload. + + Shape compatibility — checked in order so the V5 source-of-truth + payload wins, with the older shapes kept as fallback for the brief + window between socket signal and the v5 GET enrichment (and for + webhook callers that haven't migrated yet): + + - **V5 / GET ``/v5/messages/:id``**: top-level ``transcript`` string. + - **V2 (socket push, ``/v3/messages/recent``)**: ``text_models[]`` + with one entry of ``type == "transcript"`` carrying either a + joined ``timecodes[].t`` walk or a ``value`` string. + - **Webhook**: ``transcript_txt`` or ``ai_summary_txt`` flat + strings. + + When the message is still being transcribed all of these are empty; + callers must treat an empty return as "not ready yet" and retry. + """ + # V5 — preferred. Single source of truth per cv-api design. + v5_transcript = msg.get("transcript") + if isinstance(v5_transcript, str) and v5_transcript.strip(): + return v5_transcript.strip() + # V2 — socket / v3-poll fallback. + text_models = msg.get("text_models") or [] + if isinstance(text_models, list): + for m in text_models: + if not isinstance(m, dict): + continue + if m.get("type") in ("transcript_with_timecode", "transcript"): + timecodes = m.get("timecodes") or [] + if isinstance(timecodes, list): + joined = " ".join( + tc.get("t", "") + for tc in timecodes + if isinstance(tc, dict) and isinstance(tc.get("t"), str) + ).strip() + if joined: + return joined + value = m.get("value") + if isinstance(value, str) and value.strip(): + return value.strip() + # Webhook-style payloads use different field names — accept those too. + fallback = first_str(msg.get("transcript_txt"), msg.get("ai_summary_txt")) + return fallback or "" + + +def extract_message_id(msg: Dict[str, Any]) -> Optional[str]: + # V5 uses ``id``; V2 uses ``message_id``; legacy uses ``_id``. + return first_str(msg.get("id"), msg.get("message_id"), msg.get("_id")) + + +def extract_channel_id(msg: Dict[str, Any]) -> Optional[str]: + # V5 uses ``conversation_id`` (singular); V2 uses ``channel_ids[0]``; + # webhook payloads use ``channel_id`` / ``channel_guid``. + v5 = first_str(msg.get("conversation_id")) + if v5: + return v5 + channel_ids = msg.get("channel_ids") + if isinstance(channel_ids, list) and channel_ids: + first = channel_ids[0] + if isinstance(first, str) and first.strip(): + return first.strip() + return first_str(msg.get("channel_id"), msg.get("channel_guid")) + + +def extract_creator_id(msg: Dict[str, Any]) -> Optional[str]: + # Same field across V2 and V5. + return first_str(msg.get("creator_id"), msg.get("creator_guid")) + + +def extract_share_link_id(msg: Dict[str, Any]) -> Optional[str]: + """Share-link id marking a *forwarded* message, or None. + + When a user forwards a message, cv-api creates a MessageForward record + and stamps its id onto the new (wrapper) message as ``share_link_id`` + (and the deprecated alias ``forward_id`` — both are set to the same + value by ``addForwardToMessage``). Present on V2 and V5 payloads. The + original message's content is NOT on the wrapper; it must be fetched + via ``GET /v3/message-sharelinks/{share_link_id}`` (the same flow + cv-claude-channels uses). + """ + return first_str(msg.get("share_link_id"), msg.get("forward_id")) + + +def message_age_seconds(msg: Dict[str, Any], now: "datetime") -> Optional[float]: + """Seconds between a message's ``created_at`` and ``now``. + + Returns ``None`` when the payload carries no parseable timestamp (so + callers can fall back to age-agnostic behavior). ``created_at`` is an + ISO-8601 string across V2/V5 (``2026-06-05T19:36:44.437Z``); the + trailing ``Z`` is normalized to ``+00:00`` for :meth:`fromisoformat`. + """ + raw = first_str(msg.get("created_at"), msg.get("created")) + if not raw: + return None + try: + ts = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return (now - ts).total_seconds() + + +def extract_attachments(msg: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return normalized inbound attachments as a list of dicts. + + Walks ``msg['attachments']`` and returns one dict per attachment + with these keys (mirrors the field names CV uses on the wire): + + - ``_id``: server-assigned attachment id (used by + :meth:`CarbonVoiceAPI.get_attachment_download_url` to resolve + a pre-signed S3 GET URL) + - ``link``: canonical S3 URL (auth-gated — don't try to GET it + without going through the signedurl endpoint first) + - ``filename``: as uploaded; often a UUID, not a friendly name + - ``mime_type``: e.g. ``"image/png"``, ``"application/pdf"`` + - ``length_in_bytes``: int or None (CV sometimes leaves it null) + - ``type``: typically ``"file"``; other AttachmentType values + (``link``, ``location``, ...) are rare on inbound + - ``status``: upload state (``Uploaded`` / ``Uploading`` / + ``Initializing`` / ``Failed``) or ``""`` when absent — callers + should treat a missing status as Uploaded (older payloads + predate the status field) + + Entries missing both ``_id`` and ``link`` are dropped (defensive — + CV's responses occasionally include legacy/null rows). Voice memos + are NOT included here; their audio + transcript live in + ``audio_models[]`` and ``text_models[]`` respectively, surfaced via + :func:`extract_transcript` and inbound media handling on the audio + side (separate path, future PR). + """ + out: List[Dict[str, Any]] = [] + for att in (msg.get("attachments") or []): + if not isinstance(att, dict): + continue + aid = first_str(att.get("_id"), att.get("id")) + link = first_str(att.get("link"), att.get("url")) + if not aid and not link: + continue + out.append({ + "_id": aid or "", + "link": link or "", + "filename": att.get("filename") or "", + "mime_type": att.get("mime_type") or "", + "length_in_bytes": att.get("length_in_bytes"), + "type": att.get("type") or "file", + "status": att.get("status") or "", + }) + return out + + +def is_user_mentioned(msg: Dict[str, Any], user_id: Optional[str]) -> bool: + """Return True when *user_id* is tagged in *msg*. + + Detection is **exclusively** via the structured ``tagged_user_ids`` + field. cv-api #243 exposes it on the message DTOs; the Flutter client + populates it on the POST body for text messages and via the batch + ``PUT /messages/:id/tagged-users`` for voice (cv-api #271 / #278). + The transcript no longer carries the old ``@[name](guid)`` inline + markup — the Flutter composer strips mentions to plain ``@Name`` + before send, so there is nothing to parse out of the text. + + Voice is the reason the field is authoritative: a voice memo tags + Hermes *after* the audio is recorded, so the tag lands on a later + ``message:updated`` rather than at create time. The gate's + ``revisitable`` rejection (leaves the message out of the dedup cache) + plus the ``get_message_v5`` enrichment guarantee that updated payload + is re-evaluated with the now-populated array. + """ + if not user_id: + return False + tagged = msg.get("tagged_user_ids") + return isinstance(tagged, list) and user_id in tagged + + +def bot_has_reacted( + msg: Dict[str, Any], bot_id: Optional[str], reaction_id: Optional[str] +) -> bool: + """True when *bot_id* already reacted to *msg* with *reaction_id*. + + Reads ``msg['reaction_summary']['top_user_reactions']`` — the + server-side record of who reacted with what. The adapter uses this as + a **persistent "already processed" marker**: it puts an ack reaction + on every accepted message, so a message that already carries the bot's + ack was already handled and must not be re-dispatched. + + Unlike the in-memory ``SeenCache`` (lost on restart, 5-min TTL), the + reaction lives in Carbon Voice, so this dedup survives gateway + restarts and breaks the ``use_last_updated`` re-capture loop — the ack + (and the bot's in-thread reply) bump ``updated_at``, which would + otherwise make the poller re-fetch and re-process the same message + indefinitely. + + Defensive against field-name variants and missing/oddly-shaped + summaries; returns ``False`` on anything it can't positively match. + """ + if not bot_id or not reaction_id: + return False + summary = msg.get("reaction_summary") + if not isinstance(summary, dict): + return False + entries = summary.get("top_user_reactions") + if not isinstance(entries, list): + return False + for e in entries: + if not isinstance(e, dict): + continue + uid = first_str(e.get("user_id"), e.get("user_guid"), e.get("creator_id")) + rid = first_str(e.get("reaction_id"), e.get("id")) + if uid == bot_id and rid == reaction_id: + return True + return False + + +def reactors_for( + msg: Dict[str, Any], reaction_ids: "set[str]" +) -> "set[str]": + """Return the set of user_ids who reacted to *msg* with any id in + *reaction_ids*. + + Reads the same ``reaction_summary.top_user_reactions`` shape as + :func:`bot_has_reacted`, but generalized: instead of asking "did THIS + user react with THIS id", it returns "who reacted with one of these + ids". Used for one-tap owner approval — the adapter checks whether the + owner is among the reactors with the approve/reject reaction on its + pending prompt. Returns an empty set on anything it can't parse. + """ + out: "set[str]" = set() + if not reaction_ids: + return out + summary = msg.get("reaction_summary") + if not isinstance(summary, dict): + return out + entries = summary.get("top_user_reactions") + if not isinstance(entries, list): + return out + for e in entries: + if not isinstance(e, dict): + continue + rid = first_str(e.get("reaction_id"), e.get("id")) + if rid in reaction_ids: + uid = first_str( + e.get("user_id"), e.get("user_guid"), e.get("creator_id") + ) + if uid: + out.add(uid) + return out + + +def chat_type_from_channel(channel: Optional[Dict[str, Any]]) -> str: + """Map a Carbon Voice channel payload to Hermes ``chat_type``. + + Returns ``"dm"`` for one-to-one direct messages, ``"group"`` for every + other channel kind (workspace channels, customer conversations, async + meetings). Defaults to ``"dm"`` when the payload is missing so the + adapter degrades to the prior single-tier behavior rather than dropping + messages on a transient channel-lookup failure. + + Discriminator priority: + 1. ``type == "directMessage"`` — explicit type from PersonalizedChannel. + 2. ``dm_hash`` non-null — present only on DM channels (1:1 fingerprint + used by the merge service); a reliable fallback if ``type`` is + absent from older payloads. + """ + if not channel: + return "dm" + ch_type = channel.get("type") + if isinstance(ch_type, str) and ch_type.strip(): + return "dm" if ch_type == "directMessage" else "group" + if channel.get("dm_hash"): + return "dm" + # Unknown/partial payload — preserve the prior "bot responds always" + # behavior by defaulting to DM until we gain a positive signal. + return "dm" + + +def _collaborator_name(p: Dict[str, Any]) -> str: + """Best display name for one ``json_collaborators`` entry. + + Prefers ``first_name [last_name]``; falls back to the flat + ``display_name`` / ``name`` / ``username`` shapes some payloads use. + Returns ``""`` when nothing usable is present. + """ + first = str(p.get("first_name") or "").strip() + last = str(p.get("last_name") or "").strip() + full = (first + " " + last).strip() + if full: + return full + return first_str(p.get("display_name"), p.get("name"), p.get("username")) or "" + + +def extract_roster(channel: Optional[Dict[str, Any]]) -> Dict[str, str]: + """Map ``user_guid`` → display name from a channel's collaborators. + + Carbon Voice's ``GET /channel/{id}`` returns ``json_collaborators`` — + one entry per participant with ``user_guid`` + ``first_name`` / + ``last_name``. This is the canonical place to resolve names: the + standalone ``GET /v3/users/{id}`` endpoint is dead (404), and the + collaborator list is already on the channel payload the adapter + fetches for chat-type resolution, so names cost zero extra calls. + + Returns ``{}`` on a missing/partial payload. Entries without a guid or + a usable name are skipped. + """ + out: Dict[str, str] = {} + if not channel: + return out + for p in (channel.get("json_collaborators") or []): + if not isinstance(p, dict): + continue + guid = first_str(p.get("user_guid"), p.get("guid"), p.get("id")) + if not guid: + continue + name = _collaborator_name(p) + if name: + out[guid] = name + return out + + +def extract_reply_anchor(msg: Dict[str, Any]) -> Optional[str]: + """The message_id to thread *next* replies under. + + Resolves to ``parent_message_id`` (the thread root) when the inbound + message is a reply, else the message's own id. Mirrors the + ``parent_message_id ?? message_id`` pattern in the TypeScript client. + + As of cv-api PR #277 (CV-13155) the backend resolves the thread root + server-side (``resolveRootParentMessageId``): sending a non-root id as + ``reply_to_message_id`` no longer returns ``400 You cannot reply to a + message that is a reply`` — it is normalized to the root. The only + remaining reply error is cross-conversation. Anchoring to the root + here is therefore belt-and-suspenders, not a hard requirement. + """ + parent = first_str( + msg.get("parent_message_id"), msg.get("parent_message_guid") + ) + return parent or extract_message_id(msg) diff --git a/plugins/platforms/carbonvoice/permits.py b/plugins/platforms/carbonvoice/permits.py new file mode 100644 index 000000000000..7fb3a7819dba --- /dev/null +++ b/plugins/platforms/carbonvoice/permits.py @@ -0,0 +1,146 @@ +"""Interactive allow-list: the dynamic, owner-approved sender list. + +Deny-by-default access control needs a way to grow the allow-list at +runtime without restarting the gateway. Rather than invent our own store, +we reuse Hermes core's :class:`PairingStore` — the same persisted, +per-platform approved-user file the core authorization check already +consults for *every* platform (``gateway/run.py``: +``pairing_store.is_approved(platform, user_id)`` is "always checked, +regardless of allowlists"). So approving a user here authorizes them in +Hermes core automatically, with no core changes. + +This module is import-safe without Hermes core (CI imports the plugin +standalone): the ``PairingStore`` import is lazy and every method degrades +to a safe default when core isn't present. + +Two pieces live here: + + - :class:`ApprovalStore` — thin wrapper over ``PairingStore`` scoped to + the ``carbonvoice`` platform (is_approved / approve / revoke / list). + - :func:`parse_admin_command` — parses the operator's ``/cv-allow`` / + ``/cv-deny`` / ``/cv-list`` replies in the home channel. +""" + +from __future__ import annotations + +import logging +import re +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Platform key under which approved users are stored +# (``~/.hermes/platforms/pairing/carbonvoice-approved.json``). Must match +# the ``Platform("carbonvoice")`` value the adapter sends on SessionSource, +# so a user approved here is authorized by Hermes core's own check too. +PLATFORM = "carbonvoice" + + +class ApprovalStore: + """Dynamic allow-list backed by Hermes core's ``PairingStore``. + + All methods are no-ops returning safe defaults when ``PairingStore`` + can't be imported (e.g. CI, or a core too old to have it) — the plugin + still runs, just without the dynamic list. + """ + + def __init__(self) -> None: + self._store = None # None = not tried, False = unavailable + + def _pairing(self): + if self._store is None: + try: + from gateway.pairing import PairingStore + + self._store = PairingStore() + except Exception as exc: # core absent / incompatible + logger.debug("carbonvoice: PairingStore unavailable: %s", exc) + self._store = False + return self._store or None + + @property + def available(self) -> bool: + return self._pairing() is not None + + def is_approved(self, user_id: Optional[str]) -> bool: + store = self._pairing() + if not store or not user_id: + return False + try: + return bool(store.is_approved(PLATFORM, user_id)) + except Exception as exc: + logger.debug("carbonvoice: is_approved(%s) failed: %s", user_id, exc) + return False + + def approve(self, user_id: str, user_name: str = "") -> bool: + """Add *user_id* to the dynamic allow-list. Returns success.""" + store = self._pairing() + if not store or not user_id: + return False + try: + # ``_approve_user`` is documented "must be called under + # self._lock"; it doesn't take the lock itself, so we do. + with store._lock: + store._approve_user(PLATFORM, user_id, user_name) + return True + except Exception as exc: + logger.warning("carbonvoice: approve(%s) failed: %s", user_id, exc) + return False + + def revoke(self, user_id: str) -> bool: + """Remove *user_id* from the dynamic allow-list. Returns whether a + row was actually removed.""" + store = self._pairing() + if not store or not user_id: + return False + try: + return bool(store.revoke(PLATFORM, user_id)) + except Exception as exc: + logger.warning("carbonvoice: revoke(%s) failed: %s", user_id, exc) + return False + + def list_approved(self) -> List[dict]: + """Return ``[{user_id, user_name, approved_at, ...}, ...]``.""" + store = self._pairing() + if not store: + return [] + try: + return list(store.list_approved(PLATFORM) or []) + except Exception as exc: + logger.debug("carbonvoice: list_approved failed: %s", exc) + return [] + + +# Operator commands (owner-only, in the home channel). Explicit verb-object +# names so they're self-documenting: +# /cv-allow-user /cv-deny-user /cv-list-allow-users +# Case-insensitive; leading/trailing space ok. Order the longest alternative +# first so ``list-allow-users`` isn't shadowed by ``allow-user``. +_CMD_RE = re.compile( + r"^\s*/cv-(list-allow-users|allow-user|deny-user)\b\s*(\S+)?\s*$", + re.IGNORECASE, +) +# Map the spoken command to the canonical action the handler switches on. +_ACTION = { + "allow-user": "allow", + "deny-user": "deny", + "list-allow-users": "list", +} + + +def parse_admin_command(text: Optional[str]) -> Optional[Tuple[str, Optional[str]]]: + """Parse an operator allow-list command. + + Returns ``(action, arg)`` — ``action`` in ``{"allow","deny","list"}``, + ``arg`` the target user_guid (or ``None`` for ``list``) — or ``None`` + when *text* is not one of our commands. ``allow``/``deny`` with no arg + return ``(action, None)`` so the caller can reply with usage help. + """ + if not text: + return None + m = _CMD_RE.match(text) + if not m: + return None + action = _ACTION[m.group(1).lower()] + arg = m.group(2) + return (action, arg) diff --git a/plugins/platforms/carbonvoice/plugin.yaml b/plugins/platforms/carbonvoice/plugin.yaml new file mode 100644 index 000000000000..0f7a5d196952 --- /dev/null +++ b/plugins/platforms/carbonvoice/plugin.yaml @@ -0,0 +1,122 @@ +name: carbonvoice-platform +label: Carbon Voice +kind: platform +version: 0.3.6 +description: > + Carbon Voice gateway adapter for Hermes Agent. + Connects via Socket.IO (primary) with REST polling fallback — no public + webhook or tunnel required. Persists a cursor to disk so messages received + while Hermes is offline are processed on the next startup. Replies via + POST /v3/messages/start, so the Hermes agent appears as a bot user inside + Carbon Voice. Text-only — Carbon Voice transcribes voice messages to text + before delivering them. +author: PhononX + +requires_env: + - name: CARBONVOICE_PAT + description: "Carbon Voice Personal Access Token (cv_pat_...) for the agent identity" + prompt: "Carbon Voice PAT" + url: "https://www.developer.carbonvoice.app/" + password: true + +optional_env: + - name: CARBONVOICE_BASE_URL + description: "Carbon Voice API base URL (default: https://api.carbonvoice.app)" + prompt: "API base URL" + password: false + - name: CARBONVOICE_POLL_INTERVAL_MS + description: "Polling interval when running in fallback mode (default: 5000)" + prompt: "Polling interval (ms)" + password: false + - name: CARBONVOICE_WS_RETRY_MAX_MS + description: "Maximum WebSocket reconnect backoff (default: 30000)" + prompt: "Max WS retry backoff (ms)" + password: false + - name: CARBONVOICE_STATE_PATH + description: "Path to the cursor state file (default: $HERMES_HOME/state/carbonvoice.json)" + prompt: "State file path" + password: false + - name: CARBONVOICE_CREATOR_ID + description: "Restrict inbound messages to this Carbon Voice user_guid (optional personal-bot mode)" + prompt: "Allowed creator id (or empty)" + password: false + - name: CARBONVOICE_ALLOWED_USERS + description: "Extra Carbon Voice user_guids allowed, beyond the auto-detected owner and users approved via /cv-allow-user. Access is deny-by-default." + prompt: "Extra allowed user_guids (comma-separated, optional)" + password: false + - name: CARBONVOICE_ALLOW_ALL_USERS + description: "Deny-by-default (false). Set true to disable gating and let anyone talk to the bot (old open behavior)." + prompt: "Allow ALL users? (true/false, default false)" + password: false + - name: CARBONVOICE_APPROVAL_COOLDOWN_S + description: "Min seconds between owner-approval prompts (and the sender's 'request sent' notice) for the same unknown user (default: 1800). Owner is re-prompted after the window if the first ask was missed." + prompt: "Approval re-prompt cooldown (seconds)" + password: false + - name: CARBONVOICE_HOME_CHANNEL + description: "Carbon Voice channel_guid for cron/notifications AND where the bot asks the owner to approve unknown senders (/cv-allow-user)" + prompt: "Home channel guid" + password: false + - name: CARBONVOICE_HOME_CHANNEL_NAME + description: "Display name for the Carbon Voice home channel" + prompt: "Home channel display name" + password: false + - name: CARBONVOICE_REACTION_ID + description: "Reaction id used to acknowledge inbound messages (default: 'acknowledged'). Available ids are logged on startup." + prompt: "Ack reaction id" + password: false + - name: CARBONVOICE_PENDING_REACTION_ID + description: "(Reserved, not used by default.) Reaction id for a 'pending approval' marker on an unauthorized sender's message (default: 'confused' ⁉️). Per-message reactions flooded the owner with notifications, so unauthorized messages are now dropped silently; kept for operators who wire it back in." + prompt: "Pending-approval reaction id" + password: false + - name: CARBONVOICE_APPROVE_REACTION_ID + description: "Reaction the owner taps on a 'wants to talk' prompt to ALLOW that user — one-tap approval, no typed command (default: 'affirmative' 💯). Available ids are logged on startup." + prompt: "Owner approve reaction id" + password: false + - name: CARBONVOICE_REJECT_REACTION_ID + description: "Reaction the owner taps on a 'wants to talk' prompt to BLOCK that user (default: 'negative' 👎). Available ids are logged on startup." + prompt: "Owner reject reaction id" + password: false + - name: CARBONVOICE_STUCK_MAX_AGE_S + description: "How long (seconds) a message with no transcript is retried before being treated as permanently empty (image-only / system / failed STT) and skipped, so it can't pin the polling cursor (default: 300)." + prompt: "Stuck-message max age (seconds)" + password: false + - name: CARBONVOICE_DISABLE_ACK_REACTION + description: "Disable the visual ack reaction on inbound messages (default: false)" + prompt: "Disable ack reaction? (true/false)" + password: false + - name: CARBONVOICE_DISABLE_MARK_READ + description: "Disable clearing the unread notification after the agent replies (default: false)" + prompt: "Disable mark-as-read? (true/false)" + password: false + - name: CARBONVOICE_IGNORED_SENDERS_LOG + description: "Path to the audit log of rejected senders (default: $HERMES_HOME/logs/carbonvoice-ignored-senders.log)" + prompt: "Ignored senders log path" + password: false + - name: CARBONVOICE_REQUIRE_MENTION + description: "In group channels, only respond when the agent is @-mentioned (default: true). DMs always pass. Set to false for the pre-gate 'respond to every message' behavior." + prompt: "Require @mention in group channels? (true/false)" + password: false + - name: CARBONVOICE_FREE_RESPONSE_CHANNELS + description: "Comma-separated channel_guids where the agent always responds, regardless of mention." + prompt: "Free-response channel_guids (comma-separated, blank to skip)" + password: false + - name: CARBONVOICE_IGNORED_CHANNELS + description: "Comma-separated channel_guids where the agent NEVER responds, even when mentioned (hard veto, applies to DMs too)." + prompt: "Ignored channel_guids (comma-separated, blank to skip)" + password: false + - name: CARBONVOICE_SHARED_GROUP_SESSIONS + description: "When true, all participants in a group channel share one session with the agent even outside of threads (flips group_sessions_per_user=false). Default false — sessions are already shared per thread (Hermes adds [sender name] prefixes automatically). Set true for bot-room channels where strict per-user isolation isn't wanted." + prompt: "Share group sessions across all participants? (true/false)" + password: false + - name: CARBONVOICE_VOICE_OUT + description: "When true, the agent's text replies are auto-converted to voice memos via Hermes' TTS pipeline and shipped to Carbon Voice's /v5/messages/audio endpoint (server-side STT then re-displays them with transcript). Default false (text replies). Requires voice.auto_tts: true in ~/.hermes/config.yaml plus a TTS provider configured (edge works key-less)." + prompt: "Always reply with voice memos? (true/false)" + password: false + - name: CARBONVOICE_MAX_ATTACHMENT_MB + description: "Maximum size (MB) for inbound attachments the plugin will download and forward to the agent's multimodal pipeline. Images, PDFs, and text files under the cap are downloaded to ~/.hermes/{image,document}_cache and exposed via MessageEvent.media_urls so the agent can see/read them. Anything larger is logged as a WARNING and skipped (the text part of the message still reaches the agent). Default 10 MB." + prompt: "Max inbound attachment size in MB" + password: false + - name: CARBONVOICE_SEND_DEDUP_WINDOW_S + description: "Drop an identical outbound reply to the same channel within this many seconds (default: 90). Defends against the gateway re-sending the same response once per queued follow-up when delivery confirmation is lost (e.g. CV 502 mid-stream). Set 0 to disable." + prompt: "Outbound dedup window (seconds)" + password: false diff --git a/plugins/platforms/carbonvoice/reactions.py b/plugins/platforms/carbonvoice/reactions.py new file mode 100644 index 000000000000..ad2d3d381316 --- /dev/null +++ b/plugins/platforms/carbonvoice/reactions.py @@ -0,0 +1,126 @@ +"""Visual ack via Carbon Voice reactions. + +The agent reacts to every accepted inbound message immediately, so the +user sees feedback in <100 ms even when the agent itself takes 10 s to +think. Reaction id defaults to the literal ``"acknowledged"`` (a CV +built-in); override via ``CARBONVOICE_REACTION_ID``. + +On startup the service logs the workspace's available reactions so the +operator can pick one and pin it via env. Failures are non-fatal — if the +workspace has no reactions or the POST 4xxs, we log and continue. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from .api import CarbonVoiceAPI +from .constants import DEFAULT_PENDING_REACTION_ID, DEFAULT_REACTION_ID + +logger = logging.getLogger(__name__) + + +class ReactionService: + def __init__( + self, + api: CarbonVoiceAPI, + reaction_id: Optional[str] = None, + enabled: bool = True, + pending_reaction_id: Optional[str] = None, + ): + self._api = api + self._reaction_id = reaction_id or DEFAULT_REACTION_ID + # Reaction used to silently acknowledge an *unauthorized* sender's + # first message ("we saw you, you're pending approval") instead of + # spamming the channel with a text reply. Defaults to the CV + # built-in "confused" (⁉️). + self._pending_reaction_id = ( + pending_reaction_id or DEFAULT_PENDING_REACTION_ID + ) + self._enabled = enabled + + @property + def enabled(self) -> bool: + return self._enabled and bool(self._reaction_id) + + @property + def reaction_id(self) -> str: + """The reaction id used for the ack (also the server-side + processed-marker the adapter checks for inbound dedup).""" + return self._reaction_id + + @property + def pending_reaction_id(self) -> str: + """Reaction used to flag an unauthorized sender's first message.""" + return self._pending_reaction_id + + async def discover(self) -> None: + """List the workspace's available reactions to the log. + + Informational only — we don't change ``self._reaction_id`` based on + the result. The operator sees the list and can pin an id via the + ``CARBONVOICE_REACTION_ID`` env var on next startup. + """ + if not self._enabled: + return + try: + reactions = await self._api.fetch_reactions() + except Exception as exc: + logger.warning("carbonvoice: GET /reactions failed: %s", exc) + return + if not reactions: + logger.info("carbonvoice: no reactions available — ack disabled") + self._enabled = False + return + logger.info("carbonvoice: %d reactions available:", len(reactions)) + for r in reactions: + logger.info( + " id=%s name=%r code=%r", + r.get("id"), r.get("name"), r.get("code"), + ) + logger.info("carbonvoice: using reaction id=%s", self._reaction_id) + + def ack(self, message_id: str) -> None: + """Fire-and-forget visual ack. Errors logged at debug, never raised.""" + if not self.enabled or not message_id: + return + asyncio.create_task(self._react(self._reaction_id, message_id)) + + async def ack_sync(self, message_id: str) -> bool: + """Blocking ack: await the reaction POST so the server-side dedup + marker is GUARANTEED present before the caller proceeds. + + Used for owner allow-list commands, whose reply bumps ``updated_at`` + and re-fires the poll: without a durable ack already on the server, + the re-fetched command re-runs and re-replies (the 298×-spam bug). + Returns True on success. Never raises. + """ + if not self.enabled or not message_id: + return False + try: + await self._api.react(self._reaction_id, message_id) + return True + except Exception as exc: + logger.debug( + "carbonvoice: ack_sync(%s, %s) failed: %s", + self._reaction_id, message_id, exc, + ) + return False + + def pending(self, message_id: str) -> None: + """Fire-and-forget "pending approval" reaction on an unauthorized + sender's message (⁉️). Same fire-and-forget contract as :meth:`ack`.""" + if not self.enabled or not message_id: + return + asyncio.create_task(self._react(self._pending_reaction_id, message_id)) + + async def _react(self, reaction_id: str, message_id: str) -> None: + try: + await self._api.react(reaction_id, message_id) + except Exception as exc: + logger.debug( + "carbonvoice: react(%s, %s) failed: %s", + reaction_id, message_id, exc, + ) diff --git a/plugins/platforms/carbonvoice/setup.py b/plugins/platforms/carbonvoice/setup.py new file mode 100644 index 000000000000..b4d01cf506d2 --- /dev/null +++ b/plugins/platforms/carbonvoice/setup.py @@ -0,0 +1,289 @@ +"""Hermes plugin registration entry points. + +Everything Hermes calls during plugin discovery lives here: + + check_requirements — verify import-time deps (httpx mandatory, socketio optional) + validate_config — runtime check that PAT is present + is_connected — quick "is this plugin usable?" probe + _env_enablement — seed PlatformConfig.extra from environment + interactive_setup — terminal wizard for ``hermes setup`` + register — wire everything into the gateway plugin registry +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Optional + +from gateway.config import PlatformConfig + +from .adapter import CarbonVoiceAdapter +from .api import standalone_send +from .constants import DEFAULT_BASE_URL, DEFAULT_POLL_INTERVAL_MS, DEFAULT_WS_RETRY_MAX_MS + +logger = logging.getLogger(__name__) + +try: + import httpx # noqa: F401 + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + +try: + import socketio # noqa: F401 + SOCKETIO_AVAILABLE = True +except ImportError: + SOCKETIO_AVAILABLE = False + + +def check_requirements() -> bool: + if not HTTPX_AVAILABLE: + logger.error("carbonvoice: httpx not installed") + return False + if not SOCKETIO_AVAILABLE: + logger.warning( + "carbonvoice: python-socketio not installed — running in polling-only mode " + "(install with: pip install 'python-socketio[asyncio_client]')" + ) + return True + + +def validate_config(config: PlatformConfig) -> bool: + extra = getattr(config, "extra", {}) or {} + pat = os.getenv("CARBONVOICE_PAT") or config.token or extra.get("pat", "") + return bool(pat) + + +def is_connected(config: PlatformConfig) -> bool: + extra = getattr(config, "extra", {}) or {} + return bool(config.token or extra.get("pat")) + + +def _bool_env(name: str) -> bool: + return os.getenv(name, "").strip().lower() in ("true", "1", "yes", "on") + + +def _env_enablement() -> Optional[Dict[str, Any]]: + """Seed PlatformConfig.extra from env vars before adapter construction. + + Returns a *flat* dict — Hermes core merges this into ``extra`` via + ``config.platforms[platform].extra.update(seed)``, so nesting here would + end up as ``extra["extra"]`` and the keys would never reach the adapter. + """ + pat = os.getenv("CARBONVOICE_PAT") + if not pat: + return None + + seed: Dict[str, Any] = { + "pat": pat, + "base_url": (os.getenv("CARBONVOICE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"), + "poll_interval_ms": int( + os.getenv("CARBONVOICE_POLL_INTERVAL_MS") or DEFAULT_POLL_INTERVAL_MS + ), + "ws_retry_max_ms": int( + os.getenv("CARBONVOICE_WS_RETRY_MAX_MS") or DEFAULT_WS_RETRY_MAX_MS + ), + "creator_id": os.getenv("CARBONVOICE_CREATOR_ID") or None, + "state_path": os.getenv("CARBONVOICE_STATE_PATH") or None, + "reaction_id": os.getenv("CARBONVOICE_REACTION_ID") or None, + "disable_ack_reaction": _bool_env("CARBONVOICE_DISABLE_ACK_REACTION"), + "disable_mark_read": _bool_env("CARBONVOICE_DISABLE_MARK_READ"), + "ignored_senders_log": os.getenv("CARBONVOICE_IGNORED_SENDERS_LOG") or None, + # When true, every inbound MessageEvent is marked as + # ``MessageType.VOICE`` regardless of whether the user typed or + # spoke. This unlocks Hermes core's auto-TTS path + # (``base.py:3493``) and the ``voice_mode`` dispatch + # (``run.py:11142``), so the agent's text reply is auto- + # converted to audio and sent via :meth:`send_voice` → + # ``/v5/messages/audio``. Carbon Voice transcribes the audio + # server-side, so the recipient sees a voice memo bubble with + # transcript — the symmetric voice-first experience expected on + # this platform. + # + # Required companion config on the operator side: + # - ``voice.auto_tts: true`` in ``config.yaml`` (or use + # ``/voice on`` per chat if Hermes core ever wires slash + # commands for CV) + # - A configured TTS provider in ``config.yaml`` under + # ``tts.provider`` (``edge`` works with no API key) + "voice_out": _bool_env("CARBONVOICE_VOICE_OUT"), + # PR 7 — inbound multimodal: max attachment size (MB) the + # adapter will download from CV and forward to Hermes core's + # multimodal pipeline. Anything larger is logged and skipped + # (the agent still sees the text part of the message; only the + # attachment is dropped). Default 10 MB balances Claude / + # OpenAI vision recommendations against typical CV usage. + "max_attachment_mb": int( + os.getenv("CARBONVOICE_MAX_ATTACHMENT_MB") or "10" + ), + } + + # CARBONVOICE_SHARED_GROUP_SESSIONS=true → flip + # ``group_sessions_per_user`` to False so every participant in a + # group channel shares one session, *regardless of thread_id*. Use + # for bot-room channels where strict per-user isolation isn't + # wanted. Default behavior already shares sessions within a thread + # (because SessionSource.thread_id is now populated for groups); + # this knob extends sharing to non-threaded conversations too. + # See DEVELOPMENT.md §7.4 / §7.9 for the design rationale. + if _bool_env("CARBONVOICE_SHARED_GROUP_SESSIONS"): + seed["group_sessions_per_user"] = False + + home_channel_id = os.getenv("CARBONVOICE_HOME_CHANNEL") + if home_channel_id: + seed["home_channel"] = { + "chat_id": home_channel_id, + "name": os.getenv("CARBONVOICE_HOME_CHANNEL_NAME") or home_channel_id, + } + return seed + + +def interactive_setup() -> Optional[Dict[str, str]]: + """Lightweight wizard: gather PAT.""" + try: + pat = input("Carbon Voice PAT (cv_pat_...): ").strip() + except (EOFError, KeyboardInterrupt): + return None + if not pat: + return None + return {"CARBONVOICE_PAT": pat} + + +async def _standalone_send( + pconfig: PlatformConfig, + chat_id: str, + content: str, + **_kwargs: Any, +) -> Dict[str, Any]: + """Adapter for Hermes' cron delivery hook — unwraps PlatformConfig and calls api.standalone_send.""" + extra = pconfig.extra or {} + pat = pconfig.token or extra.get("pat") + base_url = (extra.get("base_url") or DEFAULT_BASE_URL).rstrip("/") + if not pat: + return {"success": False, "error": "missing CARBONVOICE_PAT"} + return await standalone_send(pat, base_url, chat_id, content) + + +def register(ctx) -> None: + """Called by the Hermes plugin system on discovery.""" + # DENY-BY-DEFAULT (security): we no longer force + # CARBONVOICE_ALLOW_ALL_USERS=true. Previously this opened both our + # AllowlistGate and Hermes core's parallel check to everyone — the hole + # this whole feature closes. Now: the adapter authorizes the owner + # (whoami.created_by) and mirrors them + any /cv-allow approvals into + # core's PairingStore (which core's own check always consults), so the + # gate stays closed without forcing allow-all. The operator opts back + # into open access explicitly with CARBONVOICE_ALLOW_ALL_USERS=true. + + ctx.register_platform( + name="carbonvoice", + label="Carbon Voice", + adapter_factory=lambda cfg: CarbonVoiceAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["CARBONVOICE_PAT"], + install_hint=( + "pip install httpx 'python-socketio[asyncio_client]' " + "(python-socketio is optional — polling-only without it)" + ), + setup_fn=interactive_setup, + env_enablement_fn=_env_enablement, + cron_deliver_env_var="CARBONVOICE_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, + allowed_users_env="CARBONVOICE_ALLOWED_USERS", + allow_all_env="CARBONVOICE_ALLOW_ALL_USERS", + max_message_length=8000, + emoji="🎙️", + allow_update_command=True, + platform_hint=( + "You are chatting via Carbon Voice.\n\n" + "## Sending file attachments — ALWAYS use the MEDIA: directive\n" + "To attach ANY file (.md, .pdf, .png, audio, etc.) to your reply, " + "include this exact line in your response text:\n\n" + " MEDIA:/absolute/path/to/file.ext\n\n" + "Example reply that sends a markdown report:\n\n" + " Aquí está el resumen.\n" + " MEDIA:/tmp/report.md\n\n" + "The plugin parses that line out, uploads the file to Carbon " + "Voice's S3 storage, and attaches it to the message — the user " + "sees your text + a downloadable file in one bubble.\n\n" + "Routing by extension:\n" + "- .wav, .mp3, .opus, .m4a, .ogg, .flac → sent as a voice memo " + "(Carbon Voice transcribes server-side, user gets a play button " + "with transcript)\n" + "- Everything else (.md, .pdf, .png, .jpg, .zip, ...) → sent as a " + "downloadable native attachment\n\n" + "Allowed paths: the operator's HERMES_MEDIA_ALLOW_DIRS plus the " + "default ~/.hermes/{document,audio,image,video}_cache roots. If " + "unsure where to write, use ~/.hermes/document_cache/ for text " + "and PDFs, ~/.hermes/audio_cache/ for audio.\n\n" + "## Anti-patterns — DO NOT do these\n" + "- DO NOT call the send_message tool to attach files in THIS " + "conversation. send_message is for cross-channel proactive " + "sends to OTHER platforms / channels. For attachments in the " + "current Carbon Voice chat, the MEDIA: directive is the only " + "correct path.\n" + "- DO NOT just describe the file's path in prose ('the file is " + "at /tmp/foo.md'); that ships as plain text, not an attachment. " + "You must emit the literal `MEDIA:/tmp/foo.md` line.\n\n" + "## Voice-out (auto-TTS)\n" + "If the operator has enabled voice-out for this conversation " + "(env ``CARBONVOICE_VOICE_OUT=true`` + ``voice.auto_tts: true`` " + "in config), Hermes core automatically converts your text " + "reply into a voice memo via a TTS provider and ships it via " + "the audio endpoint — Carbon Voice then transcribes the audio " + "server-side so the user sees a voice-memo bubble with the " + "transcript inline. You don't need to call any TTS tool or " + "emit MEDIA: for an audio path — just write your reply as " + "text and Hermes handles the conversion.\n\n" + "When voice-out is active, optimize the reply for spoken " + "delivery:\n" + "- Conversational tone, short sentences\n" + "- Avoid markdown that doesn't translate to speech (no " + "bullet lists with bare ``-``, no tables, no code fences — " + "they'll be read aloud verbatim and sound awful)\n" + "- Spell out symbols you'd expect read literally (use " + "\"and\" instead of ``&``, \"percent\" instead of ``%``)\n" + "- Keep under ~30 seconds of audio (~120 words) unless the " + "user explicitly asked for a long answer; long voice memos " + "are skimmed, not listened to\n\n" + "If you specifically need to send code, JSON, a table, or " + "any structured artifact in this conversation, attach it as " + "a file via the MEDIA: directive instead — that keeps the " + "voice-memo bubble short and the artifact downloadable.\n\n" + "## Inbound attachments (multimodal)\n" + "Two attachment types are wired through to you:\n" + "- **Images** (.jpg/.png/.webp/...): downloaded and " + "surfaced via Claude vision — reference what you see " + "naturally (\"in the screenshot you sent\", \"the photo " + "shows...\") without asking the user to describe it.\n" + "- **Links** (CV's link-share UI): the URL is prepended " + "to the user's message text as a line like ``[Attached " + "link: https://...]``. Use your existing browser / fetch " + "tools (``browser_navigate``, ``fetch_url``, etc.) to " + "open it the same way you would for any URL the user " + "types inline.\n\n" + "Other attachment types are NOT delivered to you in this " + "plugin version: PDFs, text files, code files, archives, " + "and audio attachments arrive only as a notice in the " + "logs — the text part of the user's message still reaches " + "you, but the file contents do not. If the user attaches " + "something other than an image / link and asks about its " + "contents, tell them honestly that you only received the " + "image / link / text and don't have the file body. Do NOT " + "reach for ``terminal``, ``execute_code``, or ``read_file`` " + "to try to extract the file yourself — those tools require " + "operator approval and produce a worse UX than just " + "being upfront about the limitation.\n\n" + "Voice memos arrive as transcript (server-side STT by CV) " + "in the text channel as usual.\n\n" + "## Other notes\n" + "Carbon Voice transcribes inbound voice → text before " + "delivery. Plain text and lightweight markdown render best " + "for text-mode replies — avoid complex tables, multi-column " + "layouts, or raw HTML. Keep responses conversational and " + "concise either way." + ), + ) diff --git a/plugins/platforms/carbonvoice/state.py b/plugins/platforms/carbonvoice/state.py new file mode 100644 index 000000000000..9eb501834db2 --- /dev/null +++ b/plugins/platforms/carbonvoice/state.py @@ -0,0 +1,110 @@ +"""Cursor persistence for catch-up after Hermes restarts. + +A single ``lastSeenAt`` ISO timestamp is written to ``$HERMES_HOME/state/carbonvoice.json``. +Writes are debounced so a burst of messages doesn't fsync per message; on +shutdown the adapter calls ``stop()`` which forces a final flush. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Optional + +from .constants import DEFAULT_FLUSH_DEBOUNCE_S + +logger = logging.getLogger(__name__) + + +def default_state_path() -> Path: + """Resolve ``$HERMES_HOME/state/carbonvoice.json``. + + Falls back to ``~/.hermes/state/carbonvoice.json`` if the Hermes + constants module isn't importable (e.g. running outside the gateway). + """ + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + except Exception: + home = Path.home() / ".hermes" + return home / "state" / "carbonvoice.json" + + +class Cursor: + """Tracks ``lastSeenAt`` with debounced disk persistence.""" + + def __init__(self, path: Path, flush_debounce_s: float = DEFAULT_FLUSH_DEBOUNCE_S): + self._path = path + self._flush_debounce_s = flush_debounce_s + self._last_seen_at: Optional[str] = None + self._dirty = False + self._flush_task: Optional[asyncio.Task] = None + + @property + def path(self) -> Path: + return self._path + + @property + def last_seen_at(self) -> Optional[str]: + return self._last_seen_at + + async def load(self) -> None: + try: + raw = self._path.read_text(encoding="utf-8") + data = json.loads(raw) + last = data.get("lastSeenAt") + if isinstance(last, str) and last: + self._last_seen_at = last + logger.info("carbonvoice: resuming from %s", last) + except FileNotFoundError: + pass + except Exception as exc: + logger.warning("carbonvoice: failed to load state: %s", exc) + + def advance(self, iso_ts: str) -> None: + self._last_seen_at = iso_ts + self._dirty = True + self._schedule_flush() + + def _schedule_flush(self) -> None: + if self._flush_task and not self._flush_task.done(): + return + + async def _delayed(): + try: + await asyncio.sleep(self._flush_debounce_s) + await self.flush() + except asyncio.CancelledError: + pass + + self._flush_task = asyncio.create_task(_delayed()) + + async def flush(self) -> None: + if not self._dirty or self._last_seen_at is None: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text( + json.dumps({"lastSeenAt": self._last_seen_at}), + encoding="utf-8", + ) + self._dirty = False + except Exception as exc: + logger.warning("carbonvoice: failed to flush state: %s", exc) + + async def stop(self) -> None: + """Cancel any pending debounced flush, then force a final write.""" + if self._flush_task and not self._flush_task.done(): + self._flush_task.cancel() + try: + await self._flush_task + except (asyncio.CancelledError, Exception): + pass + self._flush_task = None + # Force-write whatever we have, even if the dirty flag was cleared + # mid-flight — losing a cursor advance on shutdown is worse than a + # redundant write. + self._dirty = True + await self.flush() diff --git a/plugins/platforms/carbonvoice/transport.py b/plugins/platforms/carbonvoice/transport.py new file mode 100644 index 000000000000..4eb1e9ac39ee --- /dev/null +++ b/plugins/platforms/carbonvoice/transport.py @@ -0,0 +1,229 @@ +"""Connection management: Socket.IO primary, REST polling fallback. + +The transport doesn't know about message payloads — it just calls +``on_tick`` whenever something *might* have happened (a WS event fired, a +poll interval elapsed, a reconnect attempt is about to be made). The +caller is responsible for actually fetching messages in response. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable, Optional + +try: + import socketio # python-socketio[asyncio_client] + SOCKETIO_AVAILABLE = True +except ImportError: + SOCKETIO_AVAILABLE = False + socketio = None # type: ignore[assignment] + +from .constants import ( + AGENT_NAME_HEADER, + AGENT_NAME_VALUE, + DEFAULT_WS_RETRY_INITIAL_MS, + USER_AGENT, +) + +logger = logging.getLogger(__name__) + +OnTick = Callable[[], Awaitable[None]] + + +class Transport: + """Owns the WS client + polling loop + reconnect scheduler. + + ``mode`` transitions: ``connecting`` → (``websocket`` | ``polling``) → + ``shutdown``. When the WS drops it falls back to ``polling`` and a + reconnect task spins until WS comes back, then ``_stop_polling()`` is + called and we're back to ``websocket``. + """ + + def __init__( + self, + base_url: str, + pat: str, + poll_interval_s: float, + ws_retry_max_s: float, + on_tick: OnTick, + ): + self._base_url = base_url.rstrip("/") + self._pat = pat + self._poll_interval_s = poll_interval_s + self._ws_retry_max_s = ws_retry_max_s + self._on_tick = on_tick + + self._mode: str = "connecting" + self._sio: Optional["socketio.AsyncClient"] = None + self._poll_task: Optional[asyncio.Task] = None + self._ws_reconnect_task: Optional[asyncio.Task] = None + self._ws_retry_backoff_s: float = DEFAULT_WS_RETRY_INITIAL_MS / 1000.0 + + @property + def mode(self) -> str: + return self._mode + + async def start(self) -> None: + """Bring the transport up. Tries WS first; falls back to polling. + + When ``python-socketio`` is missing entirely (not just a transient + WS failure), the adapter logs a prominent warning with the exact + install command so operators understand the cause of the degraded + mode — Hermes does not auto-install plugin dependencies (security + boundary), so the user must install the dep manually. + """ + if SOCKETIO_AVAILABLE: + try: + await self._connect_websocket() + return + except Exception as exc: + logger.warning( + "carbonvoice: WS initial connect failed (%s) — using polling", + exc, + ) + else: + logger.warning( + "carbonvoice: Carbon Voice realtime websocket support is " + "unavailable because python-socketio is not installed. " + "Falling back to REST polling. To enable websocket mode, " + "install python-socketio[asyncio_client] in the Hermes venv: " + "python -m pip install 'python-socketio[asyncio_client]>=5'" + ) + self._mode = "polling" + self._start_polling() + if SOCKETIO_AVAILABLE: + self._schedule_ws_reconnect() + + async def stop(self) -> None: + self._mode = "shutdown" + tasks = [ + t for t in (self._poll_task, self._ws_reconnect_task) + if t is not None and not t.done() + ] + for t in tasks: + t.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._poll_task = None + self._ws_reconnect_task = None + + if self._sio is not None: + try: + await self._sio.disconnect() + except Exception: + pass + self._sio = None + + # ── WebSocket ──────────────────────────────────────────────────────── + + async def _connect_websocket(self) -> None: + if not SOCKETIO_AVAILABLE: + raise RuntimeError("python-socketio not installed") + + sio = socketio.AsyncClient(reconnection=False) + self._sio = sio + + @sio.on("connect") + async def _on_connect(): # noqa: F811 + logger.info("carbonvoice: Socket.IO connected") + self._mode = "websocket" + self._ws_retry_backoff_s = DEFAULT_WS_RETRY_INITIAL_MS / 1000.0 + self._stop_polling() + + async def _on_message_event(payload=None): + if not isinstance(payload, dict): + return + # message:created fires before transcription completes; + # message:updated fires once the transcript is ready. + if payload.get("status") != "active": + return + try: + await self._on_tick() + except Exception as exc: + logger.warning("carbonvoice: tick after WS event failed: %s", exc) + + sio.on("message:created", _on_message_event) + sio.on("message:updated", _on_message_event) + + @sio.on("disconnect") + async def _on_disconnect(): # noqa: F811 + if self._mode == "shutdown": + return + logger.warning( + "carbonvoice: Socket.IO disconnected — falling back to polling" + ) + self._mode = "polling" + try: + await self._on_tick() + except Exception: + pass + self._start_polling() + self._schedule_ws_reconnect() + + await sio.connect( + self._base_url, + auth={"authorization": f"Bearer {self._pat}"}, + headers={ + AGENT_NAME_HEADER: AGENT_NAME_VALUE, + "user-agent": USER_AGENT, + }, + transports=["websocket"], + ) + + def _schedule_ws_reconnect(self) -> None: + if self._ws_reconnect_task and not self._ws_reconnect_task.done(): + return + if self._mode == "shutdown" or not SOCKETIO_AVAILABLE: + return + + async def _reconnect(): + try: + while self._mode not in ("shutdown", "websocket"): + await asyncio.sleep(self._ws_retry_backoff_s) + if self._mode == "shutdown": + return + logger.info( + "carbonvoice: attempting WS reconnect (backoff %.1fs)", + self._ws_retry_backoff_s, + ) + try: + await self._on_tick() + await self._connect_websocket() + return + except Exception as exc: + logger.debug("carbonvoice: WS reconnect failed: %s", exc) + self._ws_retry_backoff_s = min( + self._ws_retry_backoff_s * 2, + self._ws_retry_max_s, + ) + except asyncio.CancelledError: + pass + + self._ws_reconnect_task = asyncio.create_task(_reconnect()) + + # ── Polling ────────────────────────────────────────────────────────── + + def _start_polling(self) -> None: + if self._poll_task and not self._poll_task.done(): + return + logger.info("carbonvoice: polling every %.1fs", self._poll_interval_s) + + async def _tick(): + try: + while self._mode not in ("shutdown", "websocket"): + try: + await self._on_tick() + except Exception as exc: + logger.warning("carbonvoice: poll tick failed: %s", exc) + await asyncio.sleep(self._poll_interval_s) + except asyncio.CancelledError: + pass + + self._poll_task = asyncio.create_task(_tick()) + + def _stop_polling(self) -> None: + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + logger.info("carbonvoice: polling stopped (WS active)") + self._poll_task = None diff --git a/tests/gateway/test_carbonvoice_plugin.py b/tests/gateway/test_carbonvoice_plugin.py new file mode 100644 index 000000000000..8ac4ee7ed688 --- /dev/null +++ b/tests/gateway/test_carbonvoice_plugin.py @@ -0,0 +1,189 @@ +"""Tests for the Carbon Voice platform plugin registration and wiring. + +Covers the three review items on the original (native) submission: + +1. Registration — ``register(ctx)`` produces a valid ``PlatformEntry`` with + the hooks the gateway relies on (env enablement, cron delivery, + standalone sending, auth env vars). +2. Voice-out configuration wiring — ``CARBONVOICE_VOICE_OUT=true`` flows + env → ``_env_enablement()`` seed → ``PlatformConfig.extra`` → + ``adapter._voice_out``, and the adapter declares + ``voice_out_carries_text`` so core suppresses the duplicate text send + (the core contract itself lands in PR #32655, tested there). +3. Credential lock — ``connect()`` refuses to start when another gateway + already holds the PAT lock, and ``disconnect()`` releases it. +""" + +import asyncio + +import pytest + +from gateway.config import PlatformConfig +from gateway.platform_registry import PlatformEntry +from plugins.platforms.carbonvoice import setup as cv_setup +from plugins.platforms.carbonvoice.adapter import CarbonVoiceAdapter + + +class _FakeCtx: + """Captures ``register_platform`` kwargs like the plugin manager would.""" + + def __init__(self): + self.kwargs: dict = {} + + def register_platform(self, **kwargs): + self.kwargs.update(kwargs) + + +_CV_ENV_VARS = ( + "CARBONVOICE_PAT", + "CARBONVOICE_VOICE_OUT", + "CARBONVOICE_BASE_URL", + "CARBONVOICE_HOME_CHANNEL", + "CARBONVOICE_HOME_CHANNEL_NAME", +) + + +@pytest.fixture +def clean_cv_env(monkeypatch): + for var in _CV_ENV_VARS: + monkeypatch.delenv(var, raising=False) + return monkeypatch + + +# --------------------------------------------------------------------------- +# 1. Registration +# --------------------------------------------------------------------------- + +class TestRegistration: + + def _entry(self) -> PlatformEntry: + ctx = _FakeCtx() + cv_setup.register(ctx) + return PlatformEntry(source="plugin", **ctx.kwargs) + + def test_register_builds_valid_platform_entry(self): + entry = self._entry() + assert entry.name == "carbonvoice" + assert entry.label == "Carbon Voice" + assert "CARBONVOICE_PAT" in entry.required_env + + def test_register_provides_gateway_hooks(self): + entry = self._entry() + assert callable(entry.env_enablement_fn) + assert callable(entry.standalone_sender_fn) + assert callable(entry.setup_fn) + assert entry.cron_deliver_env_var == "CARBONVOICE_HOME_CHANNEL" + + def test_register_provides_auth_env_vars(self): + entry = self._entry() + assert entry.allowed_users_env == "CARBONVOICE_ALLOWED_USERS" + assert entry.allow_all_env == "CARBONVOICE_ALLOW_ALL_USERS" + + def test_adapter_factory_builds_adapter(self): + entry = self._entry() + adapter = entry.adapter_factory( + PlatformConfig(enabled=True, token="cv_pat_test") + ) + assert isinstance(adapter, CarbonVoiceAdapter) + + +# --------------------------------------------------------------------------- +# 2. Voice-out configuration wiring (env → seed → adapter) +# --------------------------------------------------------------------------- + +class TestVoiceOutWiring: + + def test_no_pat_returns_none(self, clean_cv_env): + assert cv_setup._env_enablement() is None + + def test_voice_out_defaults_false(self, clean_cv_env): + clean_cv_env.setenv("CARBONVOICE_PAT", "cv_pat_test") + seed = cv_setup._env_enablement() + assert seed["voice_out"] is False + + @pytest.mark.parametrize("value", ["true", "1", "yes", "TRUE"]) + def test_voice_out_env_seeds_extra(self, clean_cv_env, value): + clean_cv_env.setenv("CARBONVOICE_PAT", "cv_pat_test") + clean_cv_env.setenv("CARBONVOICE_VOICE_OUT", value) + seed = cv_setup._env_enablement() + assert seed["voice_out"] is True + + def test_seed_reaches_adapter_flag(self, clean_cv_env): + """The full chain: env → seed → PlatformConfig.extra → adapter.""" + clean_cv_env.setenv("CARBONVOICE_PAT", "cv_pat_test") + clean_cv_env.setenv("CARBONVOICE_VOICE_OUT", "true") + seed = cv_setup._env_enablement() + adapter = CarbonVoiceAdapter( + PlatformConfig(enabled=True, token="cv_pat_test", extra=seed) + ) + assert adapter._voice_out is True + + def test_voice_out_off_by_default_on_adapter(self, clean_cv_env): + adapter = CarbonVoiceAdapter( + PlatformConfig(enabled=True, token="cv_pat_test") + ) + assert adapter._voice_out is False + + def test_adapter_declares_carries_text_contract(self): + # Core (PR #32655) suppresses the follow-up text send only for + # adapters that opt in — Carbon Voice does (the server-side + # transcript IS the text). + assert CarbonVoiceAdapter.voice_out_carries_text is True + + +# --------------------------------------------------------------------------- +# 3. Credential-scoped lock +# --------------------------------------------------------------------------- + +class _StubAPI: + def __init__(self): + self.opened = False + self.closed = False + + async def open(self): + self.opened = True + + async def close(self): + self.closed = True + + +class TestCredentialLock: + + def _adapter(self) -> CarbonVoiceAdapter: + return CarbonVoiceAdapter( + PlatformConfig(enabled=True, token="cv_pat_locktest") + ) + + def test_connect_bails_when_lock_denied(self, monkeypatch): + adapter = self._adapter() + stub = _StubAPI() + adapter._api = stub + calls = [] + monkeypatch.setattr( + adapter, + "_acquire_platform_lock", + lambda scope, identity, desc: calls.append((scope, identity)) or False, + ) + ok = asyncio.run(adapter.connect()) + assert ok is False + assert calls == [("carbonvoice-pat", "cv_pat_locktest")] + # Bailed BEFORE opening the API client. + assert stub.opened is False + + def test_disconnect_releases_lock(self, monkeypatch): + adapter = self._adapter() + adapter._api = _StubAPI() + + released = [] + monkeypatch.setattr( + adapter, "_release_platform_lock", lambda: released.append(True) + ) + + async def _noop(): + return None + + monkeypatch.setattr(adapter._transport, "stop", _noop) + monkeypatch.setattr(adapter._cursor, "stop", _noop) + + asyncio.run(adapter.disconnect()) + assert released == [True] diff --git a/website/docs/user-guide/messaging/carbonvoice.md b/website/docs/user-guide/messaging/carbonvoice.md new file mode 100644 index 000000000000..31f0f2b5a947 --- /dev/null +++ b/website/docs/user-guide/messaging/carbonvoice.md @@ -0,0 +1,122 @@ +--- +sidebar_position: 24 +title: "Carbon Voice" +description: "Set up Hermes Agent as a Carbon Voice bot (voice-first messaging via Socket.IO)" +--- + +# Carbon Voice Setup + +Hermes connects to [Carbon Voice](https://carbonvoice.app/), a voice-first +messaging platform. Carbon Voice transcribes voice messages to text (STT) +before delivery and can re-synthesize the agent's replies as voice memos +(TTS), so from Hermes' side it is a **text-in / text-out** platform. + +The adapter connects over **Socket.IO** (primary) with a **REST polling +fallback** — no public webhook or tunnel required. It persists a cursor to +disk, so messages received while Hermes was offline are processed on the next +startup. + +:::info Dependencies +The adapter uses `httpx` (already a core Hermes dependency). For real-time +Socket.IO delivery, install `python-socketio`: + +```bash +pip install 'python-socketio[asyncio_client]' +``` + +Without it, the adapter still works in polling-only mode. +::: + +--- + +## Prerequisites + +- A **Carbon Voice account** and a **Personal Access Token** (`cv_pat_...`), + created at [developer.carbonvoice.app](https://www.developer.carbonvoice.app/). + +--- + +## Setup + +Run the gateway setup wizard and pick **Carbon Voice**: + +```bash +hermes gateway setup +``` + +Or set the environment variable directly in `~/.hermes/.env`: + +```bash +CARBONVOICE_PAT=cv_pat_xxxxxxxxxxxxxxxx +``` + +The platform auto-enables whenever `CARBONVOICE_PAT` is present. Start the +gateway: + +```bash +hermes gateway run +``` + +You should see `✓ carbonvoice connected` in the logs. + +--- + +## Access control (deny-by-default) + +Access is **deny-by-default**. A user may reach the agent only if **any** of: + +1. They are the **owner** — the Carbon Voice user who created the bot account + (`whoami.created_by`). Auto-detected at startup; always allowed, no setup + needed. +2. They are listed in `CARBONVOICE_ALLOWED_USERS` (comma-separated `user_guid`s). +3. The owner approved them at runtime. + +### Interactive onboarding + +When an unauthorized user messages the bot, it asks the **owner** in the home +channel (`CARBONVOICE_HOME_CHANNEL`): + +> 👤 *Teammate (Abc123…) wants to talk to me but isn't authorized.* +> *React 💯 to allow · 👎 to block — or reply* `/cv-allow-user Abc123…` + +**One-tap approval:** just **react 💯** on that prompt to allow, or **👎** to +block — no typing. Only the owner's reaction counts, so a stranger can't +self-approve. Text commands work too (owner-only, in the home channel): +`/cv-allow-user `, `/cv-deny-user `, `/cv-list-allow-users`. + +To open access entirely (not recommended), set +`CARBONVOICE_ALLOW_ALL_USERS=true`. + +--- + +## Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `CARBONVOICE_PAT` | _(required)_ | Personal Access Token (`cv_pat_...`). | +| `CARBONVOICE_BASE_URL` | `https://api.carbonvoice.app` | API base URL. | +| `CARBONVOICE_ALLOWED_USERS` | _(unset)_ | Extra allowed `user_guid`s, beyond the owner. | +| `CARBONVOICE_ALLOW_ALL_USERS` | `false` | Disable gating (open access). | +| `CARBONVOICE_HOME_CHANNEL` | _(unset)_ | Channel for cron delivery + approving unknown senders. | +| `CARBONVOICE_APPROVAL_COOLDOWN_S` | `1800` | Min seconds between owner-approval prompts per unknown user. | +| `CARBONVOICE_APPROVE_REACTION_ID` | `affirmative` | Reaction the owner taps to allow (💯). | +| `CARBONVOICE_REJECT_REACTION_ID` | `negative` | Reaction the owner taps to block (👎). | +| `CARBONVOICE_STUCK_MAX_AGE_S` | `300` | How long a transcript-less message is retried before being skipped. | +| `CARBONVOICE_SEND_DEDUP_WINDOW_S` | `90` | Drop an identical outbound reply to the same channel within this window. | +| `CARBONVOICE_REQUIRE_MENTION` | `true` | In group channels, only respond when @-mentioned (DMs always pass). | +| `CARBONVOICE_VOICE_OUT` | `false` | Auto-convert text replies to voice memos via Hermes' TTS pipeline. | + +--- + +## Notes + +- **Voice in/out:** inbound voice is transcribed by Carbon Voice before Hermes + sees it. To reply with voice memos, set `CARBONVOICE_VOICE_OUT=true` and + configure a TTS provider (`voice.auto_tts: true` in `config.yaml`). Carbon + Voice transcribes the outgoing audio server-side and renders the transcript + inline with the voice memo, so the user gets **one bubble** — audio plus + text together, never a duplicate text message. +- **Images:** inbound image attachments are downloaded and forwarded to the + agent's vision pipeline. +- **Cron delivery:** set `CARBONVOICE_HOME_CHANNEL` and deliver cron results + with `carbonvoice:` (or `all`). diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index ac69b9ffd048..85ab6ac8b21f 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, # Messaging Gateway -Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. +Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, Carbon Voice, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes). @@ -39,6 +39,7 @@ Bots need both a model provider and tool providers (TTS, web). A [Nous Portal](/ | Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — | | LINE | — | ✅ | ✅ | — | — | ✅ | — | +| Carbon Voice | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | | ntfy | — | — | — | — | — | — | — | | Raft | — | — | — | — | — | — | — | | IRC | — | — | — | — | — | — | — | @@ -682,4 +683,5 @@ Defaults to `false`. Only platforms whose adapter implements `delete_message` ho - [Open WebUI + API Server](open-webui.md) - [Raft Setup](raft.md) - [IRC Setup](irc.md) +- [Carbon Voice Setup](carbonvoice.md) - [Webhooks](webhooks.md)