hermes - 💡(How to fix) Fix [Bug]: Matrix gateway misclassifies 2-person rooms as DMs, disabling `MATRIX_REQUIRE_MENTION` and group auto-threading [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

async def _is_dm_room(self, room_id: str) -> bool: if self._dm_rooms.get(room_id, False): return True # Fallback: check member count via state store. state_store = ( getattr(self._client, "state_store", None) if self._client else None ) if state_store: try: members = await state_store.get_members(room_id) if members and len(members) == 2: return True except Exception: pass return False

Root Cause

MatrixAdapter._is_dm_room treats any room with exactly two joined members as a DM, regardless of whether the room was actually created as a direct chat. Because _resolve_message_context short-circuits mention-gating, free-room checks, and the group auto-thread path when is_dm is true, this misclassification produces two visible regressions in any small group room the bot is in:

Fix Action

Fixed

Code Example

Report       https://paste.rs/yhzi4
agent.log    https://paste.rs/jpeop
gateway.log  https://paste.rs/EhIaw

---



---

async def _is_dm_room(self, room_id: str) -> bool:
    if self._dm_rooms.get(room_id, False):
        return True
    # Fallback: check member count via state store.
    state_store = (
        getattr(self._client, "state_store", None) if self._client else None
    )
    if state_store:
        try:
            members = await state_store.get_members(room_id)
            if members and len(members) == 2:
                return True
        except Exception:
            pass
    return False

---

# gateway/platforms/matrix.py
is_dm = await self._is_dm_room(room_id)
...
if not is_dm:
    is_free_room = room_id in self._free_rooms
    in_bot_thread = bool(thread_id and thread_id in self._threads)
    if self._require_mention and not is_free_room and not in_bot_thread:
        if not is_mentioned:
            return None  # drop
...
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)
):
    thread_id = event_id
    self._threads.mark(thread_id)

---

elif len(members) > 2 or not evt.content.is_direct:
    await self.handle_puppet_group_invite(...)
else:
    await self.handle_puppet_dm_invite(...)

---

async def _is_direct_chat(self, room_id: RoomID) -> tuple[bool, bool]:
    members = await self.az.intent.get_room_members(room_id)
    return len(members) == 2, self.az.bot_mxid in members

---

def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
    adapter._dm_rooms[room_id] = is_dm

---

async def _is_dm_room(self, room_id: str) -> bool:
    return self._dm_rooms.get(room_id, False)
RAW_BUFFERClick to expand / collapse

Bug Description

MatrixAdapter._is_dm_room treats any room with exactly two joined members as a DM, regardless of whether the room was actually created as a direct chat. Because _resolve_message_context short-circuits mention-gating, free-room checks, and the group auto-thread path when is_dm is true, this misclassification produces two visible regressions in any small group room the bot is in:

  1. MATRIX_REQUIRE_MENTION=true is silently ignored — the bot answers every message in the room.
  2. MATRIX_AUTO_THREAD=true does not create threads — the bot replies inline because the DM-path uses MATRIX_DM_AUTO_THREAD (default false) instead.

The Matrix protocol does distinguish DMs from 2-person rooms (via the invite's m.room.member is_direct flag and the inviter's m.direct account data); the gateway just isn't consulting those signals.

Steps to Reproduce

  1. Create a Matrix group room (e.g. via Element's "Create a room" flow, not the "Start chat" / DM flow) and invite the bot into it. Leave the room with only two members (you + bot).
  2. Configure the gateway with MATRIX_REQUIRE_MENTION=true and MATRIX_AUTO_THREAD=true (defaults are fine — both default true). Confirm the room is not in the inviter's m.direct account data.
  3. Send a message that does not @-mention the bot.

Expected Behavior

  • The message is dropped by mention-gating (it's a group room, no mention, no free-room override, no in-thread context). Gateway logs the standard "ignoring message ... — no @mention" debug line.
  • If the gateway did respond (e.g. you @-mentioned), the reply would be in a new thread rooted on the user message, per MATRIX_AUTO_THREAD=true.

Actual Behavior

  • The bot responds to every message, mention-gating never fires.
  • Replies are posted inline rather than in a new thread.

Affected Component

Gateway (Telegram/Discord/Slack/WhatsApp), Other

Messaging Platform (if gateway-related)

No response

Debug Report

Report       https://paste.rs/yhzi4
agent.log    https://paste.rs/jpeop
gateway.log  https://paste.rs/EhIaw

Operating System

NixOS 25.11

Python Version

3.12.10

Hermes Version

v0.13.0 (2026.5.7)

Additional Logs / Traceback (optional)

Root Cause Analysis (optional)

gateway/platforms/matrix.py defines DM detection as:

async def _is_dm_room(self, room_id: str) -> bool:
    if self._dm_rooms.get(room_id, False):
        return True
    # Fallback: check member count via state store.
    state_store = (
        getattr(self._client, "state_store", None) if self._client else None
    )
    if state_store:
        try:
            members = await state_store.get_members(room_id)
            if members and len(members) == 2:
                return True
        except Exception:
            pass
    return False

The fallback at lines ~2295–2301 returns True for any 2-member room. _dm_rooms is populated from m.direct account data (line ~2304's _refresh_dm_cache), but the fallback runs whenever _dm_rooms.get(room_id, False) is falsy — including the legitimate "this is a 2-person group" case.

Downstream, _resolve_message_context uses is_dm as the sole gate for mention-checking and chooses the thread-creation flag based on it:

# gateway/platforms/matrix.py
is_dm = await self._is_dm_room(room_id)
...
if not is_dm:
    is_free_room = room_id in self._free_rooms
    in_bot_thread = bool(thread_id and thread_id in self._threads)
    if self._require_mention and not is_free_room and not in_bot_thread:
        if not is_mentioned:
            return None  # drop
...
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)
):
    thread_id = event_id
    self._threads.mark(thread_id)

So a single false-positive in _is_dm_room disables two independent features that the operator opted into.

The protocol does distinguish the two cases

Two authoritative signals exist for "this is a DM":

  1. The invite's m.room.member event carries is_direct: true when the inviting client intended a DM. mautrix exposes this directly: mautrix/types/event/state.pyMemberStateEventContent.is_direct: bool.
  2. The recipient's m.direct account data, which the gateway already reads via _refresh_dm_cache.

mautrix-python's own bridge code (mautrix/bridge/matrix.py) uses the canonical pattern: 2-member-count is treated as necessary but not sufficient, AND-gated with the invite's is_direct:

elif len(members) > 2 or not evt.content.is_direct:
    await self.handle_puppet_group_invite(...)
else:
    await self.handle_puppet_dm_invite(...)

and its _is_direct_chat helper returns the two signals separately:

async def _is_direct_chat(self, room_id: RoomID) -> tuple[bool, bool]:
    members = await self.az.intent.get_room_members(room_id)
    return len(members) == 2, self.az.bot_mxid in members

The hermes gateway's _on_invite ignores the invite event's content entirely (event: Any — only room_id is read), so the is_direct intent from the invite is dropped on the floor before it can be remembered.

Test coverage

tests/gateway/test_matrix_mention.py stubs _dm_rooms directly:

def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
    adapter._dm_rooms[room_id] = is_dm

The state-store fallback path is never exercised, which is why this bug shipped — the unit tests only cover the m.direct-backed branch.

Proposed Fix (optional)

Drop the member-count fallback entirely. Trust m.direct only:

async def _is_dm_room(self, room_id: str) -> bool:
    return self._dm_rooms.get(room_id, False)

Cleanest and matches the strict-spec view. Risk: rooms that are DMs but where the inviter's client never wrote m.direct (rare, but some bridges or older clients fall into this bucket) would now be classified as groups.

Are you willing to submit a PR for this?

  • I'd like to fix this myself and submit a PR

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING