Since5ea8fb2b78(#111624, for #110952) the gateway rejects a bare silence marker on any human turn and delivers "The model returned only a silence marker for a message that needed a reply" instead. That protects a human who asked this bot something and got nothing back. It also fires on every human message the adapter admitted without the bot being addressed at all: a free-response channel, a thread follow-up under `thread_require_mention: false`, or a message @-mentioning another person or bot with `ignore_other_user_mentions: false`. A bot whose SOUL declines peer-addressed turns with a deliberate marker now posts that notice on every such message. A fleet running several bots in shared Slack threads reported it as spam on v2026.9.21. #37940 established that intentional silence must not be re-inflated. Both contracts hold once the turn knows whether a reply was expected. `MessageEvent.reply_expected` (True, False, None) is set by the adapter where the message is admitted. Slack (`slack_reply_expected`): a 1:1 DM, an @mention of this bot or a command is True, anything else it admits is False. Other adapters leave None, which keeps today's behaviour, so nothing changes for them until they are ported. `response_filters.silence_allowed` holds the one rule (machinery turn, or reply not expected) and both call sites use it: the live turn in `run_turn._hmwa_shape_agent_response` and the crash-recovery redelivery from #120377 (1136f135dd), which reads the flag back from the persisted turn metadata. The suppressed case logs one DEBUG line naming platform and chat. Operator workaround until this lands: `platforms.slack.extra. ignore_other_user_mentions: true` drops peer-addressed messages before a turn exists. (cherry picked from commit 094439776ab898cccde303a1c2c911c8ab5bfb75)
120 lines
6.1 KiB
Python
120 lines
6.1 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
|
||
# Reply anchor for the final send when the answer is to a DIFFERENT message than the one that
|
||
# opened the turn: a successful busy redirect turns the running turn onto the redirecting
|
||
# message, so its reply must quote that message (#115001). ``_reply_anchor_for_event``
|
||
# honours this over ``message_id``; None = derive from the event as usual.
|
||
reply_anchor_override: 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
|
||
# Whether this inbound turn was addressed to this bot. False means the adapter admitted a
|
||
# free-response or peer-addressed message, None means the adapter cannot determine it.
|
||
reply_expected: Optional[bool] = None
|
||
|
||
# Process-local admission receipt, never routing metadata or execution acknowledgement.
|
||
_gateway_accepted: bool = field(default=False, init=False, repr=False, compare=False)
|
||
# Run-owned final presentation snapshot; never deserialized from ingress metadata.
|
||
_notification_reply_muted: Optional[bool] = field(default=None, 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", "-")
|