Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging
adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite,
wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained
bundled plugins under plugins/platforms/<x>/, discovered via the platform
registry. Strips the per-platform core touchpoints from gateway/run.py,
gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and
tools/send_message_tool.py.
Carries forward the migration fixes (explicit enabled:false honored,
get_connected_platforms forces discovery, plugin is_connected via
gateway.get_env_value, logs --component gateway matches plugins.platforms.*,
matrix hidden on Windows).
Additionally ports config keys main added since the PR base: the matrix
plugin's _apply_yaml_config now also covers allowed_users,
ignore_user_patterns, process_notices, and session_scope (the inline
gateway/config.py matrix block gained these in the 1340 commits the PR sat
open; they would otherwise have been silently dropped on deletion).
Two findings from Copilot's review on #15464, both addressed:
1. ``event.get("thread_ts")`` truthy vs
``event_thread_ts != ts``: the new channel branch treated ANY
truthy ``thread_ts`` as a real thread reply, but three lines below
``is_thread_reply`` is defined with the stricter
``event_thread_ts and event_thread_ts != ts`` invariant. If Slack
ever ships a payload where ``thread_ts == ts`` on a thread root,
the stricter check would treat it as a top-level message for the
``is_thread_reply`` path but as a thread reply for session keying
— divergent behaviour. Aligned this branch to the same
``and event_thread_ts_raw != ts`` invariant.
2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring
had the ternary logic backwards ("None != ts → reply_to_message_id
IS set"). The code reads
``reply_to_message_id = thread_ts if thread_ts != ts else None`` —
with ``thread_ts = None``, the condition is True so the expression
evaluates to ``thread_ts`` itself (None), meaning the reply stays
un-threaded. The test asserted the correct end-state; only the
explanatory docstring was wrong. Rewrote the docstring to match
the actual code flow, with the note that Copilot caught the
reversal.
7/7 tests still pass. No behaviour change for the existing
test_thread_reply_scopes_by_thread_even_when_shared case because
``event_thread_ts_raw = "1700000000.000000"`` and ``ts =
"1700000000.000005"`` are distinct — the new
``!= ts`` guard is a no-op there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-level Slack channel messages previously fell back to the message's
own ``ts`` as a synthetic ``thread_ts``:
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
That value flows into ``build_source(thread_id=thread_ts)`` at
line 1247. The gateway session store keys sessions by
``(platform, channel_id, thread_id)``, so every top-level channel
message ended up on a unique session. Operators who set
``reply_in_thread: false`` in ``config.yaml`` expected all top-level
channel messages to share one session (the whole point of that flag)
— instead each one spawned a fresh conversation with no context
carry-over.
### Fix
Three explicit cases in the channel branch:
| event.thread_ts | reply_in_thread | thread_ts for session keying |
|---|---|---|
| non-null (real thread reply) | either | event.thread_ts |
| null (top-level) | true (default) | ts (legacy: own-thread sessions) |
| null (top-level) | false | **None** (shared channel session) |
The outbound-reply gate at line 1264 (``reply_to_message_id =
thread_ts if thread_ts != ts else None``) still works correctly in
all three cases without further changes: ``None != ts`` is True, so
shared-channel top-level messages don't get their reply threaded
either — matching the operator's ``reply_in_thread=false`` intent
end-to-end.
Genuine thread replies still scope per-thread under both modes so
multi-person threaded conversations can't collide with unrelated
channel chatter.
### Tests (7 new in ``tests/gateway/test_slack_channel_session_scope.py``)
All drive the real ``SlackAdapter._handle_slack_message`` code path
(not a re-implementation) via the standard pytest fixture pattern
used by ``tests/gateway/test_slack.py``. Messages @mention the bot
so the mention gate doesn't drop them — the tests are specifically
about what happens once the handler decides to emit a ``MessageEvent``.
* ``TestChannelSessionScopeDefault`` (2 cases):
- Explicit ``reply_in_thread: true`` keeps ``thread_id = ts``
(legacy behaviour — regression guard)
- Unset config behaves like ``reply_in_thread: true`` (pins the
default)
* ``TestChannelSessionScopeShared`` (3 cases):
- ``reply_in_thread: false`` + top-level → ``thread_id is None``
(the #15421 bug 1 fix)
- ``reply_to_message_id is None`` in the same case (no threaded
outbound reply)
- Genuine thread reply still scopes per-thread when shared mode is
on — only TOP-LEVEL messages collapse to the channel session
* ``TestThreadReplyAlwaysScopesByThread`` (2 parametrised cases):
- Thread replies get ``thread_id = event.thread_ts`` regardless of
``reply_in_thread`` — critical invariant for multi-thread
channels; a regression here would leak per-thread context across
threads
**Regression guard verified**: reverted the else-branch to the legacy
``thread_ts = event.get("thread_ts") or ts`` one-liner;
``test_top_level_maps_to_none_when_reply_in_thread_false`` correctly
failed (asserts ``thread_id is None`` but got ``"1700000000.000003"``).
Restored → 182 slack tests pass (175 existing + 7 new).
Scope: this fixes#15421 bug 1 only. Bug 2 (sessions.json not
persisting across compression) lives elsewhere in the session
manager and is left for a separate diff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>