fix(slack): resolve a cold channel's workspace from the sole authenticated team

scope_id_for_chat only consulted the channel→team map, which is empty right after boot (and
after a reconnect) until an inbound event from that channel arrives. A /handoff into a Slack home
without a stored scope_id (SLACK_HOME_CHANNEL env homes, or config homes never re-set via
/sethome) therefore built a key without the team while every thread reply carries it — the
handed-off thread was still orphaned across a restart (#111896).

When the map has no entry and the channel is not known to be shared across workspaces, fall back
to the single authenticated workspace (filled by auth.test at connect); multi-workspace installs
keep returning None.
This commit is contained in:
teknium1
2026-09-15 14:23:45 -07:00
committed by Teknium
parent 1f3f45e87b
commit 79bf0be53b
2 changed files with 30 additions and 10 deletions

View File

@@ -1909,8 +1909,14 @@ class SlackAdapter(BasePlatformAdapter):
def scope_id_for_chat(self, chat_id: str) -> Optional[str]:
"""Return the workspace id owning ``chat_id``.
``None`` for unknown channels and for channels claimed by several workspaces (dropped from
the map) — no scope beats a wrong one."""
the map) — no scope beats a wrong one. A channel unseen since boot (the map only fills from
inbound events) still resolves when exactly one workspace is authenticated: every inbound
reply will carry that team_id, so a caller keying a session on it must match (#111896)."""
team_id = chat_id and (getattr(self, "_channel_team", None) or {}).get(str(chat_id))
if not team_id and chat_id and str(chat_id) not in (getattr(self, "_channel_teams", None) or {}):
team_clients = getattr(self, "_team_clients", None) or {}
if len(team_clients) == 1:
team_id = next(iter(team_clients))
return str(team_id) if team_id else None
def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any:

View File

@@ -104,24 +104,27 @@ def test_discord_handoff_key_does_not_use_parent_channel():
assert handoff != buggy, "handoff regressed to keying on the parent channel"
def _slack_handoff_destination(channel_id: str, thread_ts: str, team_id: str):
"""Run the real handoff destination/key path against a Slack home channel."""
config = GatewayConfig(platforms={Platform.SLACK: PlatformConfig(enabled=True, token="test")})
config.platforms[Platform.SLACK].home_channel = HomeChannel(
platform=Platform.SLACK, chat_id=channel_id, name="home", scope_id=team_id)
adapter = MagicMock()
adapter.create_handoff_thread = AsyncMock(return_value=thread_ts)
def _handoff_destination(platform: Platform, channel_id: str, thread_id: str, scope_id, adapter):
"""Run the real handoff destination/key path against a configured home channel."""
config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, token="test")})
config.platforms[platform].home_channel = HomeChannel(
platform=platform, chat_id=channel_id, name="home", scope_id=scope_id)
adapter.create_handoff_thread = AsyncMock(return_value=thread_id)
runner = object.__new__(GatewayRunner)
runner.config = config
runner.adapters = {Platform.SLACK: adapter}
runner.adapters = {platform: adapter}
runner.session_store = None
with patch("gateway.delivery.resolve_delivery_transport",
lambda *_a: SimpleNamespace(adapter=adapter, send=AsyncMock())):
dest = asyncio.run(runner._handoff_resolve_destination(
{"id": "cli-session", "title": "work", "handoff_platform": "slack"}, profile_name=None))
{"id": "cli-session", "title": "work", "handoff_platform": platform.value}, profile_name=None))
return dest, runner._handoff_session_key(dest, profile_name=None)
def _slack_handoff_destination(channel_id: str, thread_ts: str, team_id: str):
return _handoff_destination(Platform.SLACK, channel_id, thread_ts, team_id, MagicMock())
def _organic_slack_reply_key(channel_id: str, thread_ts: str, team_id: str, chat_type: str) -> str:
"""Key the Slack adapter builds for a thread reply (``_build_message_event``): parent channel
as chat_id, ``dm``/``group`` from the channel type, workspace id as scope_id."""
@@ -144,3 +147,14 @@ def test_slack_channel_handoff_key_matches_the_thread_reply_key():
dest, handoff = _slack_handoff_destination("C0CHANNEL01", "1789474088.089709", "T0C2HL96FH6")
assert handoff == _organic_slack_reply_key("C0CHANNEL01", "1789474088.089709", "T0C2HL96FH6", "group")
assert dest.source.chat_id == "C0CHANNEL01"
def test_slack_handoff_without_stored_scope_uses_the_sole_workspace_on_a_cold_channel_map():
"""An env/legacy home has no scope_id and the adapter's channel→team map is empty right after
boot; a single authenticated workspace still identifies the team the reply key will carry."""
from plugins.platforms.slack.adapter import SlackAdapter
adapter = SlackAdapter.__new__(SlackAdapter)
adapter._channel_team, adapter._channel_teams, adapter._team_clients = {}, {}, {"T0C2HL96FH6": object()}
_dest, handoff = _handoff_destination(Platform.SLACK, "D0C1HFBMQAX", "1789474088.089709", None, adapter)
assert handoff == _organic_slack_reply_key("D0C1HFBMQAX", "1789474088.089709", "T0C2HL96FH6", "dm")