Skip to content
Closed
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
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ def validate_env_var_name_for_write(key: str) -> None:
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_READ_RECEIPTS", "MATRIX_REACTIONS",
"MATRIX_RECOVERY_KEY",
# Langfuse observability plugin tuning keys + standard SDK vars (activation is via
# plugins.enabled; credentials gate the plugin at runtime).
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,19 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
"require_mention": True, # require @mention to respond in rooms
"free_response_rooms": "", # comma-separated room IDs answered without mention
"allowed_rooms": "", # if set, ONLY respond in these room IDs (whitelist)
# When to send an m.read receipt / fully-read marker for an incoming
# message. On Beeper the receipt propagates to every bridged network
# (Messenger/Instagram/WhatsApp/...), so marking read on ingestion makes
# all of them show the message as read the instant the gateway sees it.
# immediate — mark read on arrival (default; historical behavior)
# after_processing — mark read only once the agent finishes the turn
# disabled — never send read receipts
"read_receipts": "immediate",
# Lifecycle reactions on processed messages: an eyes reaction when the
# agent starts and a checkmark/cross when it finishes. On Beeper these
# propagate to every bridged network, so each incoming message visibly
# gets a checkmark. Set false to disable (eyes + checkmark/cross).
"reactions": True,
},
# Approvals for dangerous commands.
# mode: manual (always prompt) | smart (aux LLM auto-approves low-risk) | off (= --yolo)
Expand Down
121 changes: 119 additions & 2 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,29 @@ def __init__(self, config: PlatformConfig):
raw_session_scope = str(_extra_or_secret(config.extra, "session_scope", "MATRIX_SESSION_SCOPE", "auto")).strip().lower()
self._matrix_session_scope = raw_session_scope if raw_session_scope in {"auto", "room", "thread"} else "auto"
self._process_notices: bool = self._extra_truthy(config, "process_notices", "MATRIX_PROCESS_NOTICES", "false")
self._reactions_enabled: bool = str(_extra_or_secret(config.extra, "reactions", "MATRIX_REACTIONS", "true")).lower() not in {"false", "0", "no"}

# Lifecycle reactions: configurable via config.yaml ``matrix.reactions``
# (env ``MATRIX_REACTIONS`` as legacy fallback; YAML wins when set).
# When enabled (default), the adapter annotates every processed message
# with an eyes reaction on start and a checkmark/cross on completion. On
# Beeper those reactions propagate to each bridged network, so every
# incoming message visibly gets a checkmark — set ``reactions: false``
# to stop that.
self._reactions_enabled: bool = self._parse_reactions_enabled(config)

# Read receipts: configurable via config.yaml ``matrix.read_receipts``
# (env ``MATRIX_READ_RECEIPTS`` as legacy fallback; YAML wins when set).
# Three modes control when an ``m.read`` receipt / fully-read marker is
# sent for an incoming message:
# - "immediate" (default): mark read the instant the event arrives
# (historical behavior).
# - "after_processing": mark read only once the agent finishes the
# turn, so the receipt reflects an actual reply rather than mere
# ingestion.
# - "disabled": never send read receipts. On Beeper this stops every
# bridged network (Messenger/Instagram/WhatsApp/...) from showing
# messages as read the instant the gateway processes them.
self._read_receipts_mode: str = self._parse_read_receipts_mode(config)
self._pending_reactions: dict[tuple[str, str], str] = {}
# Let the final message land before redacting reactions ("missing event" in some
# clients). 5s is empirically safe; if it must be tunable, use config.yaml not env.
Expand Down Expand Up @@ -956,6 +978,79 @@ def _parse_thread_require_mention(config) -> bool:
configured = _extra_or_secret(config.extra, "thread_require_mention", "MATRIX_THREAD_REQUIRE_MENTION", False)
return configured if isinstance(configured, bool) else str(configured).lower() not in {"false", "0", "no", "off"}

@staticmethod
def _parse_read_receipts_mode(config) -> str:
"""Resolve read-receipt mode from config.yaml ``matrix.read_receipts``.

Falls back to the legacy ``MATRIX_READ_RECEIPTS`` env var; the YAML
value always wins when set. Returns one of ``"immediate"``,
``"after_processing"``, or ``"disabled"``.

Back-compat: earlier releases exposed only a boolean env var, so
boolean / ``"true"`` / ``"false"`` / ``"on"`` / ``"off"`` / ``"1"`` /
``"0"`` values are still accepted — truthy maps to ``"immediate"`` and
falsy to ``"disabled"``. Unrecognized values fall back to the default.
"""
default = "immediate"
valid = {"immediate", "after_processing", "disabled"}

def _coerce(raw) -> Optional[str]:
if raw is None:
return None
if isinstance(raw, bool):
return "immediate" if raw else "disabled"
token = str(raw).strip().lower()
if not token:
return None
if token in valid:
return token
# Legacy boolean spellings (the pre-mode env flag).
if token in {"true", "1", "yes", "on", "enabled"}:
return "immediate"
if token in {"false", "0", "no", "off"}:
return "disabled"
return default

configured = _coerce(config.extra.get("read_receipts"))
if configured is not None:
return configured
env_mode = _coerce(os.getenv("MATRIX_READ_RECEIPTS"))
if env_mode is not None:
return env_mode
return default

@staticmethod
def _parse_reactions_enabled(config) -> bool:
"""Resolve lifecycle-reaction toggle from ``matrix.reactions``.

Falls back to the legacy ``MATRIX_REACTIONS`` env var; the YAML value
always wins when set. Defaults to enabled. A value is falsy when it is
the boolean ``False`` or one of ``"false"`` / ``"0"`` / ``"no"`` /
``"off"`` (case-insensitive); everything else is truthy.
"""

def _coerce(raw) -> Optional[bool]:
if raw is None:
return None
if isinstance(raw, bool):
return raw
token = str(raw).strip().lower()
if not token:
return None
return token not in {"false", "0", "no", "off"}

configured = _coerce(config.extra.get("reactions"))
if configured is not None:
return configured
env_val = _coerce(os.getenv("MATRIX_REACTIONS"))
if env_val is not None:
return env_val
return True

# ------------------------------------------------------------------
# E2EE helpers
# ------------------------------------------------------------------

@staticmethod
def _extract_server_ed25519(device_keys_obj: Any) -> Optional[str]:
for kid, kval in (getattr(device_keys_obj, "keys", {}) or {}).items():
Expand Down Expand Up @@ -2081,7 +2176,12 @@ async def _resolve_message_context(
guild_id=identity.server_name, parent_chat_id=room_id if thread_id else None, message_id=event_id)
if thread_id:
await self._threads.mark_async(thread_id) # covers real roots and synthetic ones alike
self._background_read_receipt(room_id, event_id)

# "immediate" marks read on arrival; "after_processing" defers the
# receipt to on_processing_complete; "disabled" never sends one.
if self._read_receipts_mode == "immediate":
self._background_read_receipt(room_id, event_id)

return body, is_dm, chat_type, thread_id, display_name, source

async def _extract_reply_context(
Expand Down Expand Up @@ -2408,7 +2508,23 @@ async def on_processing_start(self, event: MessageEvent) -> None:
self._pending_reactions[(room_id, msg_id)] = reaction_event_id

async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Replace eyes with checkmark (success) or cross (failure).

Also emits a deferred read receipt when ``read_receipts`` is set to
``after_processing`` — the message is marked read once the turn is
done rather than the instant it arrived.
"""
msg_id, room_id = event.message_id, event.source.chat_id
# Deferred read receipt ("after_processing" mode). Independent of the
# reaction lifecycle below, and skipped on cancellation so a cancelled
# turn doesn't mark the triggering message read.
if (
self._read_receipts_mode == "after_processing"
and msg_id
and room_id
and outcome != ProcessingOutcome.CANCELLED
):
self._background_read_receipt(room_id, msg_id)
if not self._reactions_enabled or not msg_id or not room_id or outcome == ProcessingOutcome.CANCELLED:
return
eyes_event_id = self._pending_reactions.pop((room_id, msg_id), None)
Expand Down Expand Up @@ -3125,6 +3241,7 @@ def _ask(key: str, question: str, **kw) -> str:
("require_mention", "MATRIX_REQUIRE_MENTION", "lower"), ("process_notices", "MATRIX_PROCESS_NOTICES", "lower"),
("session_scope", "MATRIX_SESSION_SCOPE", "lower"), ("auto_thread", "MATRIX_AUTO_THREAD", "lower"),
("dm_mention_threads", "MATRIX_DM_MENTION_THREADS", "lower"),
("read_receipts", "MATRIX_READ_RECEIPTS", "lower"), ("reactions", "MATRIX_REACTIONS", "lower"),
("allowed_users", "MATRIX_ALLOWED_USERS", "csv"), ("free_response_rooms", "MATRIX_FREE_RESPONSE_ROOMS", "csv"),
("allowed_rooms", "MATRIX_ALLOWED_ROOMS", "csv"), ("ignore_user_patterns", "MATRIX_IGNORE_USER_PATTERNS", "csv"),
("max_message_length", "MATRIX_MAX_MESSAGE_LENGTH", "str"),
Expand Down
Loading