6532d9d8da
The outer final send is bracketed by the adapter against the event that OPENED the chain, so a terminal reply was recorded under the first message's id. When two turns of one chain answered with the same text, the terminal reply computed the earlier row's obligation id, replaced its outstanding row and marked it delivered, so a first reply the platform had refused was never redelivered. MessageEvent gains a documented ledger_message_id that the obligation hash prefers, the queued follow-up returns the terminal turn's inbound id (innermost wins on nested chains), and the handler sets it before the adapter brackets the send. Reply routing is untouched: the anchor still comes from the event. Three of the four new tests fail without this change; the whole tests/gateway suite shows the same failure set before and after.
110 lines
5.3 KiB
Python
110 lines
5.3 KiB
Python
"""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
|
||
# Delivery-ledger identity for the final send, when it differs from ``message_id``. A queued
|
||
# (/queue) chain answers the LAST message of the chain, so its final send has to be ledgered
|
||
# under that message's id. Keyed on the opening event's id instead, two chained turns carrying
|
||
# the same text collide on one obligation id and the earlier turn's row is overwritten (a
|
||
# refused first reply then reads as delivered). Reply routing is unaffected: the reply anchor
|
||
# still comes from this event.
|
||
ledger_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", "-")
|