From 111b09d90b8996a3b4e88b12fd2355aab2519aa1 Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 18:09:53 -0500 Subject: [PATCH 1/7] feat(platforms): add Carbon Voice as a bundled platform plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the external PhononX/hermes-plugin-carbonvoice plugin (v0.3.6) into plugins/platforms/, following the bundled-plugin convention used by the other 21 platform plugins. Registers via ctx.register_platform() with env_enablement_fn, standalone_sender_fn, and cron_deliver_env_var=CARBONVOICE_HOME_CHANNEL — zero core changes. Co-Authored-By: Claude Fable 5 --- plugins/platforms/carbonvoice/__init__.py | 3 + plugins/platforms/carbonvoice/adapter.py | 2289 +++++++++++++++++ plugins/platforms/carbonvoice/api.py | 811 ++++++ plugins/platforms/carbonvoice/audit.py | 161 ++ plugins/platforms/carbonvoice/channels.py | 117 + plugins/platforms/carbonvoice/constants.py | 95 + .../platforms/carbonvoice/conversations.py | 256 ++ plugins/platforms/carbonvoice/dedupe.py | 40 + plugins/platforms/carbonvoice/gate.py | 143 + plugins/platforms/carbonvoice/parse.py | 388 +++ plugins/platforms/carbonvoice/permits.py | 146 ++ plugins/platforms/carbonvoice/plugin.yaml | 122 + plugins/platforms/carbonvoice/reactions.py | 126 + plugins/platforms/carbonvoice/setup.py | 289 +++ plugins/platforms/carbonvoice/state.py | 110 + plugins/platforms/carbonvoice/transport.py | 229 ++ 16 files changed, 5325 insertions(+) create mode 100644 plugins/platforms/carbonvoice/__init__.py create mode 100644 plugins/platforms/carbonvoice/adapter.py create mode 100644 plugins/platforms/carbonvoice/api.py create mode 100644 plugins/platforms/carbonvoice/audit.py create mode 100644 plugins/platforms/carbonvoice/channels.py create mode 100644 plugins/platforms/carbonvoice/constants.py create mode 100644 plugins/platforms/carbonvoice/conversations.py create mode 100644 plugins/platforms/carbonvoice/dedupe.py create mode 100644 plugins/platforms/carbonvoice/gate.py create mode 100644 plugins/platforms/carbonvoice/parse.py create mode 100644 plugins/platforms/carbonvoice/permits.py create mode 100644 plugins/platforms/carbonvoice/plugin.yaml create mode 100644 plugins/platforms/carbonvoice/reactions.py create mode 100644 plugins/platforms/carbonvoice/setup.py create mode 100644 plugins/platforms/carbonvoice/state.py create mode 100644 plugins/platforms/carbonvoice/transport.py 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..482cc217da97 --- /dev/null +++ b/plugins/platforms/carbonvoice/adapter.py @@ -0,0 +1,2289 @@ +"""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 base class default is False, so adapters that don't override + # this are unaffected. Requires the patched base.py from PR 6 (and + # the parallel upstream PR) — without it the attribute is read but + # ignored, and we ship a duplicate text bubble. + 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) -> bool: + if not self._pat or self._api is None: + logger.error("carbonvoice: CARBONVOICE_PAT not set") + 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._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 From 2c171daf8683af943007614b8b11f48270c1580f Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 18:11:15 -0500 Subject: [PATCH 2/7] fix(carbonvoice): acquire credential-scoped lock on connect Prevents two gateways from claiming the same Carbon Voice PAT, matching the telegram/discord/slack/whatsapp adapters (_acquire_platform_lock in connect, _release_platform_lock in disconnect). Addresses review feedback on the PAT having no credential lock. Co-Authored-By: Claude Fable 5 --- plugins/platforms/carbonvoice/adapter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/platforms/carbonvoice/adapter.py b/plugins/platforms/carbonvoice/adapter.py index 482cc217da97..b86f4ed96fd6 100644 --- a/plugins/platforms/carbonvoice/adapter.py +++ b/plugins/platforms/carbonvoice/adapter.py @@ -320,6 +320,14 @@ async def connect(self) -> bool: 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: @@ -392,6 +400,7 @@ async def disconnect(self) -> None: 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) ───────────────────────────────── From 186646fcfccaef670ebbc5b13398f6fe28768435 Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 18:16:58 -0500 Subject: [PATCH 3/7] feat(gateway): general voice_out_carries_text delivery contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a capability flag on BasePlatformAdapter: adapters whose successful play_tts() already delivers the reply text to the user (e.g. Carbon Voice's server-side transcript rendered inline with the voice memo) declare voice_out_carries_text = True, and the auto-TTS response flow suppresses the follow-up text send — no more duplicate audio + text. - Default False: existing platforms keep sending audio and text. - A failed play_tts still falls back to the text send (reply never lost). - Telegram's length-conditional caption suppression is unchanged. - Carbon Voice opts in (its /v5/messages/audio endpoint transcribes server-side, so the transcript IS the text). Addresses review feedback: the flag was previously read by the adapter but ignored by core, delivering both the audio memo and the text. Co-Authored-By: Claude Fable 5 --- gateway/platforms/base.py | 21 ++- plugins/platforms/carbonvoice/adapter.py | 4 +- tests/gateway/test_voice_out_carries_text.py | 156 +++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_voice_out_carries_text.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d3c935733e6b..ae84b7adca8a 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2318,6 +2318,18 @@ class BasePlatformAdapter(ABC): # set this to False to stay correct-by-default. supports_async_delivery: bool = True + # Whether a successful ``play_tts()`` on this platform already delivers + # the reply TEXT to the user — e.g. the platform transcribes the audio + # server-side and renders the transcript inline (Carbon Voice), or the + # adapter attaches the full text alongside the audio natively. When + # True, the auto-TTS response flow suppresses the follow-up text send + # after a successful ``play_tts()``, so the user doesn't get the same + # reply twice (audio memo + duplicate text bubble). Telegram's + # caption-based variant is length-conditional and handled separately in + # the response flow. Default False: audio and text are independent, and + # the text portion is always sent. + voice_out_carries_text: bool = False + # Whether this adapter's ``send()`` splits long content into multiple # messages via ``truncate_message()``. When True, the delivery router # (gateway/delivery.py) skips gateway-level truncation and lets the @@ -5057,8 +5069,15 @@ async def _stop_typing_task() -> None: caption=telegram_tts_caption, metadata=_final_thread_metadata, ) + # Text is carried by the audio when the Telegram + # caption was attached OR the adapter declares that a + # successful play_tts delivers the text natively + # (``voice_out_carries_text``, e.g. Carbon Voice's + # server-side transcript) — either way, skip the + # follow-up text send so the reply isn't duplicated. _tts_caption_delivered = bool( - telegram_tts_caption and getattr(tts_result, "success", False) + (telegram_tts_caption or self.voice_out_carries_text) + and getattr(tts_result, "success", False) ) finally: try: diff --git a/plugins/platforms/carbonvoice/adapter.py b/plugins/platforms/carbonvoice/adapter.py index b86f4ed96fd6..7a5d44d9ce6f 100644 --- a/plugins/platforms/carbonvoice/adapter.py +++ b/plugins/platforms/carbonvoice/adapter.py @@ -128,9 +128,7 @@ class CarbonVoiceAdapter(BasePlatformAdapter): # contract (one bubble, text + audio together). # # The base class default is False, so adapters that don't override - # this are unaffected. Requires the patched base.py from PR 6 (and - # the parallel upstream PR) — without it the attribute is read but - # ignored, and we ship a duplicate text bubble. + # this are unaffected. voice_out_carries_text = True def __init__(self, config: PlatformConfig): diff --git a/tests/gateway/test_voice_out_carries_text.py b/tests/gateway/test_voice_out_carries_text.py new file mode 100644 index 000000000000..6033a1d2d9ec --- /dev/null +++ b/tests/gateway/test_voice_out_carries_text.py @@ -0,0 +1,156 @@ +"""Contract tests for ``voice_out_carries_text`` (audio/text suppression). + +Some platforms deliver the reply TEXT as part of a successful ``play_tts()`` +send — e.g. Carbon Voice transcribes the audio server-side and renders the +transcript inline with the voice memo. On those platforms the follow-up text +send in ``_process_message_background`` is pure duplication: the user gets +the same reply twice (audio memo + text bubble). + +Adapters declare this with the ``voice_out_carries_text`` class attribute +(default False on ``BasePlatformAdapter``). The response flow suppresses the +text send only when the flag is set AND ``play_tts()`` reported success — a +failed audio send must still fall back to text so the reply is never lost. + +Telegram's caption-based suppression (length-conditional, caption attached to +the voice message itself) is a separate mechanism and remains unchanged. +""" + +import asyncio +import json +import logging + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.session import SessionSource, build_session_key + + +class _VoiceDummy(BasePlatformAdapter): + """Minimal adapter recording text sends and play_tts calls.""" + + def __init__(self, platform: Platform, *, tts_success: bool = True): + super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform) + self.sent: list[dict] = [] + self.tts_calls: list[dict] = [] + self._tts_success = tts_success + + async def connect(self, *, is_reconnect: bool = False) -> bool: + return True + + async def disconnect(self) -> None: + return None + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append({"chat_id": chat_id, "content": content}) + return SendResult(success=True, message_id="msg-1") + + async def send_typing(self, chat_id: str, metadata=None) -> None: + return None + + async def get_chat_info(self, chat_id: str): + return {"id": chat_id} + + async def play_tts(self, chat_id, audio_path, caption=None, metadata=None, **kwargs): + self.tts_calls.append({"chat_id": chat_id, "audio_path": audio_path}) + if self._tts_success: + return SendResult(success=True, message_id="voice-1") + return SendResult(success=False, error="upload failed") + + +class _CarriesTextDummy(_VoiceDummy): + voice_out_carries_text = True + + +async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None): + if stop_event is not None: + await stop_event.wait() + else: + await asyncio.Event().wait() + + +def _make_voice_event(platform: Platform, chat_id: str = "chan-1") -> MessageEvent: + return MessageEvent( + text="hola, ¿cómo va todo?", + message_type=MessageType.VOICE, + source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"), + message_id="m1", + ) + + +def _wire_tts(adapter, monkeypatch, tmp_path): + """Make the auto-TTS gate fire and the TTS tool produce a real file.""" + adapter._keep_typing = _hold_typing + adapter._auto_tts_default = True # _should_auto_tts_for_chat → True + + def _fake_tts(text: str): + audio = tmp_path / "reply.mp3" + audio.write_bytes(b"fake-audio") + return json.dumps({"file_path": str(audio)}) + + import tools.tts_tool as tts_tool + + monkeypatch.setattr(tts_tool, "check_tts_requirements", lambda: True) + monkeypatch.setattr(tts_tool, "text_to_speech_tool", _fake_tts) + + async def handler(_event): + return "El deploy terminó sin errores." + + adapter.set_message_handler(handler) + + +@pytest.mark.asyncio +async def test_carries_text_suppresses_followup_text(monkeypatch, tmp_path, caplog): + """Flag set + play_tts success → audio only, no duplicate text bubble.""" + adapter = _CarriesTextDummy(Platform.DISCORD) + _wire_tts(adapter, monkeypatch, tmp_path) + + event = _make_voice_event(Platform.DISCORD) + with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"): + await adapter._process_message_background(event, build_session_key(event.source)) + + assert len(adapter.tts_calls) == 1, "play_tts must be invoked once" + assert adapter.sent == [], f"text must be suppressed, got {adapter.sent}" + # The audio counted as a delivery — no false silent-drop alarm. + assert "response_delivery_dropped" not in caplog.text + + +@pytest.mark.asyncio +async def test_default_flag_still_sends_text_after_tts(monkeypatch, tmp_path): + """Default (False) keeps today's behavior: audio AND text are sent.""" + adapter = _VoiceDummy(Platform.DISCORD) + _wire_tts(adapter, monkeypatch, tmp_path) + + event = _make_voice_event(Platform.DISCORD) + await adapter._process_message_background(event, build_session_key(event.source)) + + assert len(adapter.tts_calls) == 1 + assert len(adapter.sent) == 1, "text send must NOT be suppressed by default" + + +@pytest.mark.asyncio +async def test_carries_text_falls_back_to_text_when_tts_send_fails( + monkeypatch, tmp_path +): + """Flag set but play_tts FAILED → the reply must still arrive as text.""" + adapter = _CarriesTextDummy(Platform.DISCORD, tts_success=False) + _wire_tts(adapter, monkeypatch, tmp_path) + + event = _make_voice_event(Platform.DISCORD) + await adapter._process_message_background(event, build_session_key(event.source)) + + assert len(adapter.tts_calls) == 1 + assert len(adapter.sent) == 1, "failed audio send must fall back to text" + + +def test_carbonvoice_adapter_declares_carries_text(): + """The Carbon Voice adapter opts in: its transcript IS the text.""" + from plugins.platforms.carbonvoice.adapter import CarbonVoiceAdapter + + assert CarbonVoiceAdapter.voice_out_carries_text is True + assert BasePlatformAdapter.voice_out_carries_text is False From 26b8abcc54b105912da890408dd54d4ff32d6e90 Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 18:19:19 -0500 Subject: [PATCH 4/7] test(carbonvoice): plugin registration, voice-out wiring, PAT lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the three review items: register(ctx) → PlatformEntry hooks, the CARBONVOICE_VOICE_OUT env → seed → adapter._voice_out chain, and connect() bailing out when the credential lock is held (releasing it on disconnect). Co-Authored-By: Claude Fable 5 --- tests/gateway/test_carbonvoice_plugin.py | 188 +++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/gateway/test_carbonvoice_plugin.py diff --git a/tests/gateway/test_carbonvoice_plugin.py b/tests/gateway/test_carbonvoice_plugin.py new file mode 100644 index 000000000000..fc5811cd738d --- /dev/null +++ b/tests/gateway/test_carbonvoice_plugin.py @@ -0,0 +1,188 @@ +"""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 + (see ``test_voice_out_carries_text.py`` for the core contract itself). +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 suppresses the follow-up text send only for adapters that + # opt in — Carbon Voice does (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] From 547ace5d13e895386a39679e89491872a0bded95 Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 18:40:17 -0500 Subject: [PATCH 5/7] docs(carbonvoice): setup guide + messaging index entries Ports the setup guide from the native-platform branch, adapted to the plugin reality: python-socketio is optional (polling-only without it, no [messaging] extra change), and the voice-out note documents the one-bubble transcript behavior. Adds Carbon Voice to the platform comparison table and the setup-guide links in the messaging index. Co-Authored-By: Claude Fable 5 --- .../docs/user-guide/messaging/carbonvoice.md | 122 ++++++++++++++++++ website/docs/user-guide/messaging/index.md | 4 +- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 website/docs/user-guide/messaging/carbonvoice.md 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) From dc17aafcda5bb3cb0bcd91c0a49617578ba4c7fb Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 19:25:54 -0500 Subject: [PATCH 6/7] fix(carbonvoice): accept is_reconnect kwarg in connect() The gateway forwards is_reconnect to adapter.connect() on the retry / reconnection path; the adapter (ported from the external plugin, which targets older cores) didn't accept it, so every reconnect attempt died with 'unexpected keyword argument'. Matches the signature of all other platform plugins. No behavioral difference for Carbon Voice: the disk cursor makes cold boot and reconnect identical. Found by exercising the real gateway boot on this branch. Co-Authored-By: Claude Fable 5 --- plugins/platforms/carbonvoice/adapter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/carbonvoice/adapter.py b/plugins/platforms/carbonvoice/adapter.py index 7a5d44d9ce6f..78890f0bd534 100644 --- a/plugins/platforms/carbonvoice/adapter.py +++ b/plugins/platforms/carbonvoice/adapter.py @@ -313,7 +313,10 @@ def __init__(self, config: PlatformConfig): # ── Lifecycle ──────────────────────────────────────────────────────── - async def connect(self) -> bool: + 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 From a04cc55a1043d9761b68291fd62a58b09692ca2e Mon Sep 17 00:00:00 2001 From: cristianmgm7 Date: Wed, 15 Jul 2026 20:21:50 -0500 Subject: [PATCH 7/7] refactor(carbonvoice): defer the core delivery contract to PR #32655 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops this PR's own voice_out_carries_text implementation in base.py: the same contract already exists in PR #32655, where it is further along in review and hardened per feedback (completeness guard so a truncated TTS rendition never suppresses the full text, plus reply_to threading for the voice memo and behavioral tests). The plugin keeps declaring voice_out_carries_text = True; until #32655 lands the attribute is simply ignored (voice-out delivers audio plus a duplicate text bubble — degraded UX, never lost content). This PR is now strictly zero core changes. Co-Authored-By: Claude Fable 5 --- gateway/platforms/base.py | 21 +-- plugins/platforms/carbonvoice/adapter.py | 8 +- tests/gateway/test_carbonvoice_plugin.py | 7 +- tests/gateway/test_voice_out_carries_text.py | 156 ------------------- 4 files changed, 11 insertions(+), 181 deletions(-) delete mode 100644 tests/gateway/test_voice_out_carries_text.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ae84b7adca8a..d3c935733e6b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2318,18 +2318,6 @@ class BasePlatformAdapter(ABC): # set this to False to stay correct-by-default. supports_async_delivery: bool = True - # Whether a successful ``play_tts()`` on this platform already delivers - # the reply TEXT to the user — e.g. the platform transcribes the audio - # server-side and renders the transcript inline (Carbon Voice), or the - # adapter attaches the full text alongside the audio natively. When - # True, the auto-TTS response flow suppresses the follow-up text send - # after a successful ``play_tts()``, so the user doesn't get the same - # reply twice (audio memo + duplicate text bubble). Telegram's - # caption-based variant is length-conditional and handled separately in - # the response flow. Default False: audio and text are independent, and - # the text portion is always sent. - voice_out_carries_text: bool = False - # Whether this adapter's ``send()`` splits long content into multiple # messages via ``truncate_message()``. When True, the delivery router # (gateway/delivery.py) skips gateway-level truncation and lets the @@ -5069,15 +5057,8 @@ async def _stop_typing_task() -> None: caption=telegram_tts_caption, metadata=_final_thread_metadata, ) - # Text is carried by the audio when the Telegram - # caption was attached OR the adapter declares that a - # successful play_tts delivers the text natively - # (``voice_out_carries_text``, e.g. Carbon Voice's - # server-side transcript) — either way, skip the - # follow-up text send so the reply isn't duplicated. _tts_caption_delivered = bool( - (telegram_tts_caption or self.voice_out_carries_text) - and getattr(tts_result, "success", False) + telegram_tts_caption and getattr(tts_result, "success", False) ) finally: try: diff --git a/plugins/platforms/carbonvoice/adapter.py b/plugins/platforms/carbonvoice/adapter.py index 78890f0bd534..16b51e595e44 100644 --- a/plugins/platforms/carbonvoice/adapter.py +++ b/plugins/platforms/carbonvoice/adapter.py @@ -127,8 +127,12 @@ class CarbonVoiceAdapter(BasePlatformAdapter): # voice messages — different mechanism (STT vs caption), same UX # contract (one bubble, text + audio together). # - # The base class default is False, so adapters that don't override - # this are unaffected. + # 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): diff --git a/tests/gateway/test_carbonvoice_plugin.py b/tests/gateway/test_carbonvoice_plugin.py index fc5811cd738d..8ac4ee7ed688 100644 --- a/tests/gateway/test_carbonvoice_plugin.py +++ b/tests/gateway/test_carbonvoice_plugin.py @@ -9,7 +9,7 @@ env → ``_env_enablement()`` seed → ``PlatformConfig.extra`` → ``adapter._voice_out``, and the adapter declares ``voice_out_carries_text`` so core suppresses the duplicate text send - (see ``test_voice_out_carries_text.py`` for the core contract itself). + (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. """ @@ -125,8 +125,9 @@ def test_voice_out_off_by_default_on_adapter(self, clean_cv_env): assert adapter._voice_out is False def test_adapter_declares_carries_text_contract(self): - # Core suppresses the follow-up text send only for adapters that - # opt in — Carbon Voice does (server-side transcript IS the text). + # 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 diff --git a/tests/gateway/test_voice_out_carries_text.py b/tests/gateway/test_voice_out_carries_text.py deleted file mode 100644 index 6033a1d2d9ec..000000000000 --- a/tests/gateway/test_voice_out_carries_text.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Contract tests for ``voice_out_carries_text`` (audio/text suppression). - -Some platforms deliver the reply TEXT as part of a successful ``play_tts()`` -send — e.g. Carbon Voice transcribes the audio server-side and renders the -transcript inline with the voice memo. On those platforms the follow-up text -send in ``_process_message_background`` is pure duplication: the user gets -the same reply twice (audio memo + text bubble). - -Adapters declare this with the ``voice_out_carries_text`` class attribute -(default False on ``BasePlatformAdapter``). The response flow suppresses the -text send only when the flag is set AND ``play_tts()`` reported success — a -failed audio send must still fall back to text so the reply is never lost. - -Telegram's caption-based suppression (length-conditional, caption attached to -the voice message itself) is a separate mechanism and remains unchanged. -""" - -import asyncio -import json -import logging - -import pytest - -from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, -) -from gateway.session import SessionSource, build_session_key - - -class _VoiceDummy(BasePlatformAdapter): - """Minimal adapter recording text sends and play_tts calls.""" - - def __init__(self, platform: Platform, *, tts_success: bool = True): - super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform) - self.sent: list[dict] = [] - self.tts_calls: list[dict] = [] - self._tts_success = tts_success - - async def connect(self, *, is_reconnect: bool = False) -> bool: - return True - - async def disconnect(self) -> None: - return None - - async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: - self.sent.append({"chat_id": chat_id, "content": content}) - return SendResult(success=True, message_id="msg-1") - - async def send_typing(self, chat_id: str, metadata=None) -> None: - return None - - async def get_chat_info(self, chat_id: str): - return {"id": chat_id} - - async def play_tts(self, chat_id, audio_path, caption=None, metadata=None, **kwargs): - self.tts_calls.append({"chat_id": chat_id, "audio_path": audio_path}) - if self._tts_success: - return SendResult(success=True, message_id="voice-1") - return SendResult(success=False, error="upload failed") - - -class _CarriesTextDummy(_VoiceDummy): - voice_out_carries_text = True - - -async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None): - if stop_event is not None: - await stop_event.wait() - else: - await asyncio.Event().wait() - - -def _make_voice_event(platform: Platform, chat_id: str = "chan-1") -> MessageEvent: - return MessageEvent( - text="hola, ¿cómo va todo?", - message_type=MessageType.VOICE, - source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"), - message_id="m1", - ) - - -def _wire_tts(adapter, monkeypatch, tmp_path): - """Make the auto-TTS gate fire and the TTS tool produce a real file.""" - adapter._keep_typing = _hold_typing - adapter._auto_tts_default = True # _should_auto_tts_for_chat → True - - def _fake_tts(text: str): - audio = tmp_path / "reply.mp3" - audio.write_bytes(b"fake-audio") - return json.dumps({"file_path": str(audio)}) - - import tools.tts_tool as tts_tool - - monkeypatch.setattr(tts_tool, "check_tts_requirements", lambda: True) - monkeypatch.setattr(tts_tool, "text_to_speech_tool", _fake_tts) - - async def handler(_event): - return "El deploy terminó sin errores." - - adapter.set_message_handler(handler) - - -@pytest.mark.asyncio -async def test_carries_text_suppresses_followup_text(monkeypatch, tmp_path, caplog): - """Flag set + play_tts success → audio only, no duplicate text bubble.""" - adapter = _CarriesTextDummy(Platform.DISCORD) - _wire_tts(adapter, monkeypatch, tmp_path) - - event = _make_voice_event(Platform.DISCORD) - with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"): - await adapter._process_message_background(event, build_session_key(event.source)) - - assert len(adapter.tts_calls) == 1, "play_tts must be invoked once" - assert adapter.sent == [], f"text must be suppressed, got {adapter.sent}" - # The audio counted as a delivery — no false silent-drop alarm. - assert "response_delivery_dropped" not in caplog.text - - -@pytest.mark.asyncio -async def test_default_flag_still_sends_text_after_tts(monkeypatch, tmp_path): - """Default (False) keeps today's behavior: audio AND text are sent.""" - adapter = _VoiceDummy(Platform.DISCORD) - _wire_tts(adapter, monkeypatch, tmp_path) - - event = _make_voice_event(Platform.DISCORD) - await adapter._process_message_background(event, build_session_key(event.source)) - - assert len(adapter.tts_calls) == 1 - assert len(adapter.sent) == 1, "text send must NOT be suppressed by default" - - -@pytest.mark.asyncio -async def test_carries_text_falls_back_to_text_when_tts_send_fails( - monkeypatch, tmp_path -): - """Flag set but play_tts FAILED → the reply must still arrive as text.""" - adapter = _CarriesTextDummy(Platform.DISCORD, tts_success=False) - _wire_tts(adapter, monkeypatch, tmp_path) - - event = _make_voice_event(Platform.DISCORD) - await adapter._process_message_background(event, build_session_key(event.source)) - - assert len(adapter.tts_calls) == 1 - assert len(adapter.sent) == 1, "failed audio send must fall back to text" - - -def test_carbonvoice_adapter_declares_carries_text(): - """The Carbon Voice adapter opts in: its transcript IS the text.""" - from plugins.platforms.carbonvoice.adapter import CarbonVoiceAdapter - - assert CarbonVoiceAdapter.voice_out_carries_text is True - assert BasePlatformAdapter.voice_out_carries_text is False