Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
_MEDIA_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'})
_MEDIA_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})


def _platform_name(platform) -> str:
Expand Down Expand Up @@ -487,6 +489,32 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home


class MediaKind(Enum):
IMAGE = "image"
VIDEO = "video"
VOICE = "voice"
DOCUMENT = "document"


def classify_media_kind(path, is_voice=False, platform=None, force_document=False) -> MediaKind:
"""Map a local media path to the MediaKind a dispatcher should send it as.

Mirrors the reply path's routing (image/video by extension, audio via
should_send_media_as_audio). ``force_document`` wins, matching the
``[[as_document]]`` directive; unknown extensions fall back to DOCUMENT.
"""
if force_document:
return MediaKind.DOCUMENT
ext = Path(path).suffix.lower()
if ext in _MEDIA_IMAGE_EXTS:
return MediaKind.IMAGE
if ext in _MEDIA_VIDEO_EXTS:
return MediaKind.VIDEO
if should_send_media_as_audio(platform, ext, is_voice=is_voice):
return MediaKind.VOICE
return MediaKind.DOCUMENT


GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
"Secure secret entry is not supported over messaging. "
"Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually."
Expand Down Expand Up @@ -2033,6 +2061,13 @@ async def send(
# property) so the stream consumer knows not to short-circuit.
REQUIRES_EDIT_FINALIZE: bool = False

# Media kinds this adapter natively DELIVERS via its send_* overrides.
# Fail-closed default: an adapter advertises nothing until it declares.
# Single source of truth for every media-dispatch site — declaring a kind
# you don't truly deliver re-opens the path-as-text leak, so the declared
# set is pinned by tests/gateway/test_media_kinds.py.
MEDIA_KINDS: frozenset[MediaKind] = frozenset()

async def create_handoff_thread(
self,
parent_chat_id: str,
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -101,6 +102,7 @@ def _normalize_server_url(raw: str) -> str:
# ---------------------------------------------------------------------------

class BlueBubblesAdapter(BasePlatformAdapter):
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})
platform = Platform.BLUEBUBBLES
SUPPORTS_MESSAGE_EDITING = False
MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/dingtalk.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -157,6 +158,7 @@ class DingTalkAdapter(BasePlatformAdapter):
- Session webhook caching with expiry tracking
- Markdown formatted replies
"""
MEDIA_KINDS = frozenset()

MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH

Expand Down
35 changes: 35 additions & 0 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from typing import Any, Dict, List, Optional, Tuple

from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -244,6 +245,7 @@ def _extract_attachments(

class EmailAdapter(BasePlatformAdapter):
"""Email gateway adapter using IMAP (receive) and SMTP (send)."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.DOCUMENT})

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.EMAIL)
Expand Down Expand Up @@ -684,6 +686,39 @@ def _send_email_with_attachments(
logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths))
return msg_id

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,
) -> SendResult:
"""Send a local image as a real MIME attachment.

The dispatcher's per-file IMAGE path (``_dispatch_media_one``) calls
``send_image_file``; without this override email would inherit the
base stub and leak the local path as body text. Delegates to the same
attachment helper as ``send_document`` so the IMAGE and DOCUMENT paths
stay symmetric and honor the ``SendResult`` contract. (The batch
``send_multiple_images`` override handles the reply/kanban paths.)
"""
try:
loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(
None,
self._send_email_with_attachment,
chat_id,
caption or "",
image_path,
None,
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
logger.error("[Email] Send image file failed: %s", e)
return SendResult(success=False, error=str(e))

async def send_document(
self,
chat_id: str,
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -1408,6 +1409,7 @@ def _import():

class FeishuAdapter(BasePlatformAdapter):
"""Feishu/Lark bot adapter."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

MAX_MESSAGE_LENGTH = 8000
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand All @@ -56,6 +57,7 @@ class HomeAssistantAdapter(BasePlatformAdapter):
MessageEvent objects. Supports domain/entity filtering and
per-entity cooldowns to avoid event floods.
"""
MEDIA_KINDS = frozenset()

MAX_MESSAGE_LENGTH = 4096

Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class _TrustStateStub: # type: ignore[no-redef]

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -350,6 +351,7 @@ async def find_shared_rooms(self, user_id: str) -> list:

class MatrixAdapter(BasePlatformAdapter):
"""Gateway adapter for Matrix (any homeserver)."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/msgraph_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand All @@ -44,6 +45,7 @@ def check_msgraph_webhook_requirements() -> bool:

class MSGraphWebhookAdapter(BasePlatformAdapter):
"""Receive Microsoft Graph change notifications and surface them internally."""
MEDIA_KINDS = frozenset()

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -153,6 +154,7 @@ def _coerce_list(value: Any) -> List[str]:

class QQAdapter(BasePlatformAdapter):
"""QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

# QQ Bot API does not support editing sent messages.
SUPPORTS_MESSAGE_EDITING = False
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -173,6 +174,7 @@ def check_signal_requirements() -> bool:

class SignalAdapter(BasePlatformAdapter):
"""Signal messenger adapter using signal-cli HTTP daemon."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

platform = Platform.SIGNAL
# Signal has no real edit API for already-sent messages. Mark it explicitly
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -315,6 +316,7 @@ class SlackAdapter(BasePlatformAdapter):
- Slash commands (/hermes)
- Typing indicators (not natively supported by Slack bots)
"""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin

Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -60,6 +61,7 @@ class SmsAdapter(BasePlatformAdapter):
Each inbound phone number gets its own Hermes session (multi-tenant).
Replies are always sent from the configured TWILIO_PHONE_NUMBER.
"""
MEDIA_KINDS = frozenset()

MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH

Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class _MockContextTypes:

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -341,6 +342,7 @@ class TelegramAdapter(BasePlatformAdapter):
- Forum topics (thread_id support)
- Media messages
"""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

# Telegram message limits
MAX_MESSAGE_LENGTH = 4096
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -99,6 +100,7 @@ def check_webhook_requirements() -> bool:

class WebhookAdapter(BasePlatformAdapter):
"""Generic webhook receiver that triggers agent runs from HTTP POSTs."""
MEDIA_KINDS = frozenset()

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WEBHOOK)
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -141,6 +142,7 @@ def _entry_matches(entries: List[str], target: str) -> bool:

class WeComAdapter(BasePlatformAdapter):
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
SUPPORTS_MESSAGE_EDITING = False
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -1129,6 +1130,7 @@ async def qr_login(

class WeixinAdapter(BasePlatformAdapter):
"""Native Hermes adapter for Weixin personal accounts."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

MAX_MESSAGE_LENGTH = 2000

Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -238,6 +239,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
- group_policy: "open" | "allowlist" | "disabled" — which groups are processed (default: "open")
- group_allow_from: List of group JIDs allowed (when group_policy="allowlist")
"""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.VIDEO, MediaKind.VOICE, MediaKind.DOCUMENT})

# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/yuanbao.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MediaKind,
BasePlatformAdapter,
MessageEvent,
MessageType,
Expand Down Expand Up @@ -4563,6 +4564,7 @@ async def close(self) -> None:

class YuanbaoAdapter(BasePlatformAdapter):
"""Yuanbao AI Bot adapter backed by a persistent WebSocket connection."""
MEDIA_KINDS = frozenset({MediaKind.IMAGE, MediaKind.DOCUMENT})

PLATFORM = Platform.YUANBAO
MAX_TEXT_CHUNK: int = 4000 # Yuanbao single message character limit
Expand Down
Loading
Loading