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,147 changes: 37 additions & 1,110 deletions AGENTS.md

Large diffs are not rendered by default.

1,102 changes: 1,102 additions & 0 deletions docs/AGENTS.reference.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ class GatewayConfig:
# Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
shared_group_chat_ids: List[str] = field(default_factory=list) # Specific group/channel chat IDs that should share one session

# Unauthorized DM policy
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
Expand Down Expand Up @@ -583,6 +584,7 @@ def to_dict(self) -> Dict[str, Any]:
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
"shared_group_chat_ids": self.shared_group_chat_ids,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
Expand Down Expand Up @@ -628,6 +630,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":

group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user")
shared_group_chat_ids = data.get("shared_group_chat_ids") or []
if not isinstance(shared_group_chat_ids, list):
shared_group_chat_ids = []
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
data.get("unauthorized_dm_behavior"),
"pair",
Expand All @@ -651,6 +656,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
shared_group_chat_ids=[str(v).strip() for v in shared_group_chat_ids if str(v).strip()],
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
Expand Down Expand Up @@ -720,6 +726,10 @@ def load_gateway_config() -> GatewayConfig:
if sr and isinstance(sr, dict):
gw_data["default_reset_policy"] = sr

shared_group_chat_ids = yaml_cfg.get("shared_group_chat_ids")
if shared_group_chat_ids is not None:
gw_data["shared_group_chat_ids"] = shared_group_chat_ids

qc = yaml_cfg.get("quick_commands")
if qc is not None:
if isinstance(qc, dict):
Expand Down
1 change: 1 addition & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2989,6 +2989,7 @@ async def handle_message(self, event: MessageEvent) -> None:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

# On-entry self-heal: if the adapter still has an _active_sessions
Expand Down
8 changes: 6 additions & 2 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,12 @@ async def disconnect(self) -> None:
def _webhook_url(self) -> str:
"""Compute the external webhook URL for BlueBubbles registration."""
host = self.webhook_host
if host in {"0.0.0.0", "127.0.0.1", "localhost", "::"}:
host = "localhost"
# BlueBubbles Server/Electron on macOS can resolve localhost to ::1,
# while Hermes aiohttp listener is bound to IPv4 127.0.0.1 by default.
# Register the literal IPv4 loopback address so real inbound iMessage
# webhooks hit the listener deterministically.
if host in {"0.0.0.0", "::", "localhost"}:
host = "127.0.0.1"
return f"http://{host}:{self.webhook_port}{self.webhook_path}"

@property
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3058,6 +3058,7 @@ def _media_batch_key(self, event: MessageEvent) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)
return f"{session_key}:media:{event.message_type.value}"

Expand Down Expand Up @@ -3343,6 +3344,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

@staticmethod
Expand Down
1 change: 1 addition & 0 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
thread_sessions_per_user=self.config.extra.get(
"thread_sessions_per_user", False
),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

def _enqueue_text_event(self, event: MessageEvent) -> None:
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2875,11 +2875,13 @@ def _has_active_session_for_thread(
store_cfg = getattr(session_store, "config", None)
gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True
tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False
shared_group_chat_ids = getattr(store_cfg, "shared_group_chat_ids", []) if store_cfg else []

session_key = build_session_key(
source,
group_sessions_per_user=gspu,
thread_sessions_per_user=tspu,
shared_group_chat_ids=shared_group_chat_ids,
)

session_store._ensure_loaded()
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -4872,6 +4872,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

def _enqueue_text_event(self, event: MessageEvent) -> None:
Expand Down Expand Up @@ -4961,6 +4962,7 @@ def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
Expand Down
1 change: 1 addition & 0 deletions gateway/platforms/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

def _enqueue_text_event(self, event: MessageEvent) -> None:
Expand Down
1 change: 1 addition & 0 deletions gateway/platforms/yuanbao.py
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,7 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.source,
group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=adapter.config.extra.get("shared_group_chat_ids", []),
)

async def _dispatch_inbound_event() -> None:
Expand Down
26 changes: 14 additions & 12 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:
build_session_context,
build_session_context_prompt,
build_session_key,
group_sessions_per_user_for_source,
is_shared_multi_user_session,
)
from gateway.delivery import DeliveryRouter
Expand Down Expand Up @@ -2066,6 +2067,7 @@ def _session_key_for_source(self, source: SessionSource) -> str:
source,
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
shared_group_chat_ids=getattr(config, "shared_group_chat_ids", []),
)

def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool:
Expand Down Expand Up @@ -4320,17 +4322,9 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None:
)

# Compute the gateway's session_key for that destination using the
# same rules its adapters use, so switch_session targets the right
# entry. For thread destinations build_session_key keys without
# user_id (thread_sessions_per_user defaults to False) — so the
# next real user message in the thread shares this same session.
platform_cfg = self.config.platforms.get(platform)
extra = platform_cfg.extra if platform_cfg else {}
session_key = build_session_key(
dest_source,
group_sessions_per_user=extra.get("group_sessions_per_user", True),
thread_sessions_per_user=extra.get("thread_sessions_per_user", False),
)
# same rules its adapters/session store use, so switch_session targets
# the right entry (including any shared_group_chat_ids overrides).
session_key = self._session_key_for_source(dest_source)

# Make sure there's an entry in the session_store for this key. If
# the home channel has never been used, get_or_create_session
Expand Down Expand Up @@ -5975,6 +5969,10 @@ def _create_adapter(
"thread_sessions_per_user",
getattr(self.config, "thread_sessions_per_user", False),
)
config.extra.setdefault(
"shared_group_chat_ids",
getattr(self.config, "shared_group_chat_ids", []),
)

# ── Plugin-registered platforms (checked first) ───────────────────
try:
Expand Down Expand Up @@ -7649,7 +7647,11 @@ async def _prepare_inbound_message_text(
"""
history = history or []
message_text = event.text or ""
_group_sessions_per_user = getattr(self.config, "group_sessions_per_user", True)
_group_sessions_per_user = group_sessions_per_user_for_source(
source,
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []),
)
_thread_sessions_per_user = getattr(self.config, "thread_sessions_per_user", False)
# Use the same helper every other call site uses so the write key here
# matches the consume key at the run_conversation site — even if the
Expand Down
45 changes: 43 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,14 +593,39 @@ def is_shared_multi_user_session(
if source.chat_type == "dm":
return False
if source.thread_id:
if not group_sessions_per_user:
return True
return not thread_sessions_per_user
return not group_sessions_per_user


def group_sessions_per_user_for_source(
source: SessionSource,
*,
group_sessions_per_user: bool = True,
shared_group_chat_ids: Optional[List[str]] = None,
) -> bool:
"""Return the effective per-user isolation setting for this source.

``group_sessions_per_user`` remains the safe global default. Specific
group/channel chat IDs can opt into one shared session by listing the raw
``chat_id`` (or ``chat_id_alt``) in ``shared_group_chat_ids``.
"""
if source.chat_type in {"group", "channel"}:
ids = {str(v).strip() for v in (shared_group_chat_ids or []) if str(v).strip()}
if ids and (
str(source.chat_id) in ids
or (source.chat_id_alt is not None and str(source.chat_id_alt) in ids)
):
return False
return group_sessions_per_user


def build_session_key(
source: SessionSource,
group_sessions_per_user: bool = True,
thread_sessions_per_user: bool = False,
shared_group_chat_ids: Optional[List[str]] = None,
) -> str:
"""Build a deterministic session key from a message source.

Expand All @@ -625,6 +650,12 @@ def build_session_key(
shared session per chat.
- Without identifiers, messages fall back to one session per platform/chat_type.
"""
group_sessions_per_user = group_sessions_per_user_for_source(
source,
group_sessions_per_user=group_sessions_per_user,
shared_group_chat_ids=shared_group_chat_ids,
)

platform = source.platform.value
if source.chat_type == "dm":
dm_chat_id = source.chat_id
Expand Down Expand Up @@ -743,10 +774,16 @@ def _save(self) -> None:

def _generate_session_key(self, source: SessionSource) -> str:
"""Generate a session key from a source."""
return build_session_key(
group_sessions_per_user = group_sessions_per_user_for_source(
source,
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []),
)
return build_session_key(
source,
group_sessions_per_user=group_sessions_per_user,
thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False),
shared_group_chat_ids=getattr(self.config, "shared_group_chat_ids", []),
)

def _is_session_expired(self, entry: SessionEntry) -> bool:
Expand Down Expand Up @@ -1334,7 +1371,11 @@ def build_session_context(
home_channels=home_channels,
shared_multi_user_session=is_shared_multi_user_session(
source,
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
group_sessions_per_user=group_sessions_per_user_for_source(
source,
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
shared_group_chat_ids=getattr(config, "shared_group_chat_ids", []),
),
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
),
)
Expand Down
1 change: 1 addition & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4850,6 +4850,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
shared_group_chat_ids=self.config.extra.get("shared_group_chat_ids", []),
)

def _enqueue_text_event(self, event: MessageEvent) -> None:
Expand Down
27 changes: 27 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def test_full_roundtrip(self):
quick_commands={"limits": {"type": "exec", "command": "echo ok"}},
group_sessions_per_user=False,
thread_sessions_per_user=True,
shared_group_chat_ids=["group-1", "group-2"],
)
d = config.to_dict()
restored = GatewayConfig.from_dict(d)
Expand All @@ -209,6 +210,15 @@ def test_full_roundtrip(self):
assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
assert restored.group_sessions_per_user is False
assert restored.thread_sessions_per_user is True
assert restored.shared_group_chat_ids == ["group-1", "group-2"]

def test_from_dict_coerces_shared_group_chat_ids_to_strings(self):
restored = GatewayConfig.from_dict({"shared_group_chat_ids": [" group-1 ", 123, ""]})
assert restored.shared_group_chat_ids == ["group-1", "123"]

def test_from_dict_ignores_non_list_shared_group_chat_ids(self):
restored = GatewayConfig.from_dict({"shared_group_chat_ids": "group-1"})
assert restored.shared_group_chat_ids == []

def test_roundtrip_preserves_unauthorized_dm_behavior(self):
config = GatewayConfig(
Expand Down Expand Up @@ -306,6 +316,23 @@ def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch)

assert config.thread_sessions_per_user is False

def test_bridges_shared_group_chat_ids_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"shared_group_chat_ids:\n"
" - group-1\n"
" - 123\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))

config = load_gateway_config()

assert config.shared_group_chat_ids == ["group-1", "123"]

def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch):
"""discord.thread_require_mention in config.yaml should reach the runtime env var."""
hermes_home = tmp_path / ".hermes"
Expand Down
Loading