fix(slack): resolve bare user targets to DMs across all adapter send paths

Widen the #19237 send_message fix to the live adapter: send, _upload_file,
send_multiple_images, send_image, send_video, send_document,
send_exec_approval, send_slash_confirm, and send_clarify now route bare
Slack user IDs (U.../W...) through a shared _ensure_dm_conversation helper
before calling chat.postMessage / files_upload_v2, which reject user IDs.

This closes the gap in #17261 where an attachment worked when replying in
a thread but failed when directed at a user DM, and extends the DM-open
fallback to clarify/approval Block Kit prompts so gated actions can reach
a user directly.

Resolution uses the workspace-scoped client (multi-workspace installs open
the DM with the right bot token), caches per (team, user), and records the
opened D... channel in the channel→team map. On failure the original
target passes through so the downstream API call surfaces the real Slack
error.

Fixes #17261
Refs #19236
This commit is contained in:
Teknium 2026-07-22 08:30:44 -07:00
parent 4ab4894f44
commit 994a405f2d
2 changed files with 231 additions and 0 deletions

View file

@ -744,6 +744,8 @@ class SlackAdapter(BasePlatformAdapter):
# the primary client meanwhile.
self._channel_team: Dict[str, str] = {}
self._CHANNEL_TEAM_MAX = 10000
# user target (team_id:user_id) → opened DM conversation ID (D...)
self._dm_conversation_cache: Dict[str, str] = {}
# Dedup cache: prevents duplicate bot responses when Socket Mode
# reconnects redeliver events (#4777). The TTL must outlast Slack's
# worst-case reconnect-redelivery gap, not just a few seconds — the
@ -1966,6 +1968,7 @@ class SlackAdapter(BasePlatformAdapter):
self._team_clients = {}
self._team_bot_user_ids = {}
self._channel_team = {}
self._dm_conversation_cache = {}
self._release_platform_lock()
@ -1992,6 +1995,49 @@ class SlackAdapter(BasePlatformAdapter):
return self._team_clients[team_id]
return self._app.client # fallback to primary
async def _ensure_dm_conversation(
self, chat_id: str, team_id: Optional[str] = None
) -> str:
"""Resolve a bare Slack user ID target to a DM conversation ID.
``chat.postMessage`` and ``files_upload_v2`` reject user IDs (U.../W...)
a DM must be opened first via ``conversations.open`` to obtain a D...
conversation ID (#19236 / #17261). Conversation IDs (C/G/D...) pass
through unchanged. Resolution goes through the workspace-scoped client
so multi-workspace installs open the DM with the right bot token, and
results are cached per (team, user) so repeated sends don't re-open.
Returns the resolved conversation ID, or the original ``chat_id`` when
resolution is not applicable or fails (the downstream API call then
surfaces the real Slack error).
"""
cid = str(chat_id or "")
if not cid or cid[0] not in ("U", "W"):
return chat_id
cache_key = f"{team_id or ''}:{cid}"
cached = self._dm_conversation_cache.get(cache_key)
if cached:
return cached
try:
response = await self._get_client(cid, team_id=team_id).conversations_open(
users=cid
)
dm_id = ((response or {}).get("channel") or {}).get("id")
if dm_id:
self._dm_conversation_cache[cache_key] = dm_id
# DM belongs to the same workspace as the user target.
if team_id:
self._channel_team[dm_id] = team_id
return dm_id
except Exception as e:
logger.warning(
"[Slack] conversations.open failed for user target %s: %s "
"(check the bot's im:write scope)",
cid,
e,
)
return chat_id
async def send(
self,
chat_id: str,
@ -2003,6 +2049,9 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
thread_ts = None
try:
# Check for a pending slash-command context. When the user ran a
@ -2536,6 +2585,9 @@ class SlackAdapter(BasePlatformAdapter):
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
thread_ts = self._resolve_thread_ts(reply_to, metadata)
last_exc = None
for attempt in range(3):
@ -2586,6 +2638,9 @@ class SlackAdapter(BasePlatformAdapter):
if not images:
return
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
import httpx as _httpx
from urllib.parse import unquote as _unquote
@ -3261,6 +3316,9 @@ class SlackAdapter(BasePlatformAdapter):
response.raise_for_status()
thread_ts = self._resolve_thread_ts(reply_to, metadata)
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
result = await self._get_client(
chat_id, team_id=self._metadata_team_id(metadata)
).files_upload_v2(
@ -3334,6 +3392,9 @@ class SlackAdapter(BasePlatformAdapter):
success=False, error=f"Video file not found: {video_path}"
)
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
thread_ts = self._resolve_thread_ts(reply_to, metadata)
last_exc = None
@ -3396,6 +3457,9 @@ class SlackAdapter(BasePlatformAdapter):
display_name = file_name or os.path.basename(file_path)
thread_ts = self._resolve_thread_ts(reply_to, metadata)
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
last_exc = None
@ -5176,6 +5240,9 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
thread_ts = self._resolve_thread_ts(None, metadata)
@ -5270,6 +5337,9 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
thread_ts = self._resolve_thread_ts(None, metadata)
# Same 3000-char section-block cap as send_exec_approval: budget
@ -5374,6 +5444,9 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
chat_id = await self._ensure_dm_conversation(
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
thread_ts = self._resolve_thread_ts(None, metadata)

View file

@ -7021,3 +7021,161 @@ class TestTrackingStructureBounds:
# can never remove a newer entry while an older one remains).
for i in range(450, 500):
assert f"{2000 + i}.000000" in adapter._bot_message_ts
# ---------------------------------------------------------------------------
# TestEnsureDmConversation — bare user-ID targets resolve to DM channels
# (#19236 / #17261: attachments and Block Kit prompts to U... targets)
# ---------------------------------------------------------------------------
class TestEnsureDmConversation:
@pytest.mark.asyncio
async def test_user_id_target_is_opened_as_dm(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
resolved = await adapter._ensure_dm_conversation("U123ABCDEF")
assert resolved == "D999NEW"
adapter._app.client.conversations_open.assert_awaited_once_with(
users="U123ABCDEF"
)
@pytest.mark.asyncio
async def test_conversation_ids_pass_through(self, adapter):
adapter._app.client.conversations_open = AsyncMock()
for cid in ("C123CHAN", "G123GROUP", "D123DM"):
assert await adapter._ensure_dm_conversation(cid) == cid
adapter._app.client.conversations_open.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolution_is_cached_per_user(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
first = await adapter._ensure_dm_conversation("U123ABCDEF")
second = await adapter._ensure_dm_conversation("U123ABCDEF")
assert first == second == "D999NEW"
adapter._app.client.conversations_open.assert_awaited_once()
@pytest.mark.asyncio
async def test_failure_returns_original_target(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
side_effect=Exception("missing_scope")
)
resolved = await adapter._ensure_dm_conversation("U123ABCDEF")
assert resolved == "U123ABCDEF"
@pytest.mark.asyncio
async def test_workspace_scoped_client_used_for_team_id(self, adapter):
team_client = AsyncMock()
team_client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D_TEAM2"}}
)
adapter._team_clients["T_SECOND"] = team_client
adapter._app.client.conversations_open = AsyncMock()
resolved = await adapter._ensure_dm_conversation(
"U123ABCDEF", team_id="T_SECOND"
)
assert resolved == "D_TEAM2"
team_client.conversations_open.assert_awaited_once_with(users="U123ABCDEF")
adapter._app.client.conversations_open.assert_not_awaited()
# The opened DM is recorded as belonging to the same workspace.
assert adapter._channel_team["D_TEAM2"] == "T_SECOND"
@pytest.mark.asyncio
async def test_send_resolves_user_target_before_posting(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ok": True, "ts": "111.222"}
)
result = await adapter.send("U123ABCDEF", "hello there")
assert result.success is True
post_kwargs = adapter._app.client.chat_postMessage.await_args.kwargs
assert post_kwargs["channel"] == "D999NEW"
@pytest.mark.asyncio
async def test_upload_file_resolves_user_target(self, adapter, tmp_path):
media = tmp_path / "report.pdf"
media.write_bytes(b"%PDF-1.4 fake")
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
adapter._app.client.files_upload_v2 = AsyncMock(
return_value={"ok": True, "file": {"id": "F1"}}
)
result = await adapter._upload_file("U123ABCDEF", str(media))
assert result.success is True
upload_kwargs = adapter._app.client.files_upload_v2.await_args.kwargs
assert upload_kwargs["channel"] == "D999NEW"
@pytest.mark.asyncio
async def test_send_document_resolves_user_target(self, adapter, tmp_path):
media = tmp_path / "notes.md"
media.write_bytes(b"# notes")
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
adapter._app.client.files_upload_v2 = AsyncMock(
return_value={"ok": True, "file": {"id": "F1"}}
)
result = await adapter.send_document("U123ABCDEF", str(media))
assert result.success is True
upload_kwargs = adapter._app.client.files_upload_v2.await_args.kwargs
assert upload_kwargs["channel"] == "D999NEW"
@pytest.mark.asyncio
async def test_send_clarify_resolves_user_target(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ok": True, "ts": "111.222"}
)
result = await adapter.send_clarify(
chat_id="U123ABCDEF",
question="Which one?",
choices=["a", "b"],
clarify_id="cl-1",
session_key="sk-1",
)
assert result.success is True
post_kwargs = adapter._app.client.chat_postMessage.await_args.kwargs
assert post_kwargs["channel"] == "D999NEW"
@pytest.mark.asyncio
async def test_send_exec_approval_resolves_user_target(self, adapter):
adapter._app.client.conversations_open = AsyncMock(
return_value={"ok": True, "channel": {"id": "D999NEW"}}
)
adapter._app.client.chat_postMessage = AsyncMock(
return_value={"ok": True, "ts": "111.222"}
)
result = await adapter.send_exec_approval(
chat_id="U123ABCDEF",
command="rm -rf /tmp/x",
session_key="sk-1",
)
assert result.success is True
post_kwargs = adapter._app.client.chat_postMessage.await_args.kwargs
assert post_kwargs["channel"] == "D999NEW"