fix(gateway): /sethome must not persist Slack's synthetic per-message session thread as the home target
Third lane of the same contract (found in live staging validation): /sethome run as a top-level relay-fronted Slack DM message captured the adapter's session-keying thread stamp (the /sethome message's own id) into the persisted HomeChannel.thread_id and its legacy env mirror. Every bare-platform delivery (deliver="slack") then resolved home chat + home thread and landed inside the ephemeral thread around the old /sethome message. Extracted _home_thread_from_source with the same synthetic-stamp recognition as cron origin capture; a /sethome run inside a genuine thread keeps that thread as the home target. Users repair an already-poisoned home target by rerunning /sethome.
This commit is contained in:
committed by
Teknium
parent
58ff0fd302
commit
9c5d08c0d5
@@ -98,6 +98,31 @@ def _model_switch_skew_guard() -> Optional[str]:
|
||||
)
|
||||
|
||||
|
||||
def _home_thread_from_source(source) -> Optional[str]:
|
||||
"""The thread id /sethome should persist on the home target, or None.
|
||||
|
||||
Slack thread-per-message session keying stamps a top-level message's own
|
||||
id as ``source.thread_id`` (a session KEY, not a durable location).
|
||||
Persisting it would pin the HOME target itself to the ephemeral thread
|
||||
spawned around the /sethome message — every bare-platform delivery
|
||||
(``deliver="slack"``) would then land in that thread forever. Same
|
||||
recognition as cron origin capture: a Slack thread id equal to the
|
||||
message's own id is synthetic. A /sethome run inside a genuine thread
|
||||
(thread id = the parent's id, not this message's own) keeps that thread
|
||||
as the home target.
|
||||
"""
|
||||
thread_id = getattr(source, "thread_id", None)
|
||||
if not thread_id:
|
||||
return None
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.SLACK
|
||||
and getattr(source, "message_id", None)
|
||||
and str(thread_id) == str(source.message_id)
|
||||
):
|
||||
return None
|
||||
return str(thread_id)
|
||||
|
||||
|
||||
class GatewaySlashCommandsMixin:
|
||||
"""In-session slash-command handlers for GatewayRunner."""
|
||||
|
||||
@@ -3020,7 +3045,7 @@ class GatewaySlashCommandsMixin:
|
||||
error="Relay does not authenticate this logical home target",
|
||||
)
|
||||
|
||||
thread_id = source.thread_id
|
||||
thread_id = _home_thread_from_source(source)
|
||||
home = HomeChannel(
|
||||
platform=source.platform,
|
||||
chat_id=str(chat_id),
|
||||
|
||||
48
tests/gateway/test_sethome_synthetic_thread.py
Normal file
48
tests/gateway/test_sethome_synthetic_thread.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""/sethome must not persist Slack's synthetic per-message session thread.
|
||||
|
||||
Live repro (relay-fronted Slack staging, 2026-08-13): /sethome run as a
|
||||
top-level DM message captured source.thread_id — which the relay adapter had
|
||||
stamped with the /sethome message's OWN id for session keying — into the
|
||||
persisted HomeChannel. Every bare-platform delivery (deliver="slack") then
|
||||
resolved home chat + home thread and landed inside the ephemeral thread
|
||||
spawned around the old /sethome message.
|
||||
|
||||
Same contract as cron origin capture: a Slack thread id equal to the
|
||||
message's own id is a synthetic session key, never a durable location.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.slash_commands import _home_thread_from_source
|
||||
|
||||
|
||||
def _source(platform=Platform.SLACK, thread_id=None, message_id=None):
|
||||
return SimpleNamespace(
|
||||
platform=platform, thread_id=thread_id, message_id=message_id
|
||||
)
|
||||
|
||||
|
||||
class TestHomeThreadFromSource:
|
||||
def test_synthetic_slack_thread_dropped(self):
|
||||
"""Top-level /sethome: stamped thread == own message id -> None."""
|
||||
src = _source(thread_id="1755043010.123456", message_id="1755043010.123456")
|
||||
assert _home_thread_from_source(src) is None
|
||||
|
||||
def test_genuine_slack_thread_kept(self):
|
||||
"""/sethome inside a real thread keeps that thread as home target."""
|
||||
src = _source(thread_id="1755040000.000100", message_id="1755043010.123456")
|
||||
assert _home_thread_from_source(src) == "1755040000.000100"
|
||||
|
||||
def test_no_thread_returns_none(self):
|
||||
assert _home_thread_from_source(_source()) is None
|
||||
|
||||
def test_no_message_id_keeps_thread(self):
|
||||
"""Without a message id to compare, never guess: keep the thread."""
|
||||
src = _source(thread_id="1755040000.000100", message_id=None)
|
||||
assert _home_thread_from_source(src) == "1755040000.000100"
|
||||
|
||||
def test_non_slack_platform_untouched(self):
|
||||
"""Telegram forum topics legitimately reuse ids; rule is Slack-scoped."""
|
||||
src = _source(platform=Platform.TELEGRAM, thread_id="2203", message_id="2203")
|
||||
assert _home_thread_from_source(src) == "2203"
|
||||
Reference in New Issue
Block a user