Files
hermes-agent/gateway/platforms/event.py
T
2026-09-07 14:16:57 -07:00

103 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Inbound message event types shared by every gateway platform adapter.
A leaf module: adapters, helpers and the runner import it, so it must not import from
gateway.platforms.*.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from gateway.session import SessionSource
class MessageType(Enum):
"""Types of incoming messages."""
TEXT = "text"
LOCATION = "location"
PHOTO = "photo"
VIDEO = "video"
AUDIO = "audio"
VOICE = "voice"
DOCUMENT = "document"
STICKER = "sticker"
COMMAND = "command" # /command style
class ProcessingOutcome(Enum):
"""Result classification for message-processing lifecycle hooks."""
SUCCESS = "success"
FAILURE = "failure"
CANCELLED = "cancelled"
@dataclass
class MessageEvent:
"""Incoming message from a platform — the normalized shape all adapters produce."""
text: str
message_type: MessageType = MessageType.TEXT
# Author, mirrored from ``source`` for per-message prompt builders; None for non-IM sources.
user_id: Optional[str] = None
user_name: Optional[str] = None
# None only in isolated unit tests; production always sets it. Typing it Optional
# exposes ~60 unguarded ``.source.<attr>`` reads, so that is a separate change.
source: SessionSource = None
raw_message: Any = None
message_id: Optional[str] = None
# Platform update id (Telegram ``update_id``): ``/restart`` records it so the new gateway
# advances past it even if PTB's shutdown ACK times out.
platform_update_id: Optional[int] = None
# Media attachments: local file paths (for vision tool access)
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)
# Per-attachment text-inlining contract; None = legacy "text/* already inlined into ``text``".
media_text_inlined: List[Optional[bool]] = field(default_factory=list)
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
reply_to_author_id: Optional[str] = None
reply_to_author_name: Optional[str] = None
reply_to_is_own_message: bool = False # True when the user replied to this bot/assistant's message
# Structured interactive-prompt reply (relay only): {prompt_id, option_id, label?,
# prompt_message_id?}; routed to the approval/slash-confirm/clarify resolvers BEFORE dispatch.
prompt_response: Optional[Dict[str, Any]] = None
# Auto-loaded skill(s) for topic/channel bindings; a single name or ordered list.
auto_skill: Optional[str | list[str]] = None
# Per-channel ephemeral system prompt; applied at API call time, never persisted to transcript.
channel_prompt: Optional[str] = None
# History-backfilled channel context (missed under require_mention); kept out of ``text`` so
# run.py's sender-prefix logic sees only the trigger message.
channel_context: Optional[str] = None
# Set for synthetic events (e.g. background-process notifications) that must bypass user authorization.
internal: bool = False
# Free-form per-event metadata (e.g. ``whatsapp_from_owner=True``); plugins must ``.get()``.
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
# May this event resolve gateway commands / control prompts? Proactive plugin events set False
# so untrusted payload text stays conversational. Kept last for positional compat.
allow_gateway_control: bool = True
# Process-local admission receipt, never routing metadata or execution acknowledgement.
_gateway_accepted: bool = field(default=False, init=False, repr=False, compare=False)
def is_command(self) -> bool:
"""Check if this is a command message (e.g., /new, /reset)."""
return self.allow_gateway_control and (self.text or "").lstrip().startswith("/")
def get_command(self) -> Optional[str]:
"""Extract command name if this is a command message."""
if not self.is_command():
return None
raw = (self.text or "").lstrip().split(maxsplit=1)[0][1:].lower().split("@", 1)[0]
# Reject file paths: valid command names never contain /
return None if "/" in raw else raw
def get_command_args(self) -> str:
"""Get the arguments after a command."""
if not self.is_command():
return self.text
parts = (self.text or "").lstrip().split(maxsplit=1)
args = parts[1] if len(parts) > 1 else ""
# iOS auto-corrects -- to — (em dash) and - to – (en dash)
return args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-")