Files
Alexander Russell 6532d9d8da fix(gateway): ledger a queued chain's terminal reply under its own inbound id
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.
2026-09-09 12:20:51 +05:30

110 lines
5.3 KiB
Python
Raw Permalink 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
# 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", "-")