mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
959 lines
33 KiB
Python
959 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# Check optional Slack dependencies before running tests
|
|
try:
|
|
import slack_sdk # noqa: F401
|
|
except ImportError:
|
|
pytest.skip("Slack dependencies not installed (slack-sdk)", allow_module_level=True)
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.channels.slack.runtime import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig
|
|
|
|
|
|
class _FakeAsyncWebClient:
|
|
def __init__(self) -> None:
|
|
self.chat_post_calls: list[dict[str, object | None]] = []
|
|
self.file_upload_calls: list[dict[str, object | None]] = []
|
|
self.reactions_add_calls: list[dict[str, object | None]] = []
|
|
self.reactions_remove_calls: list[dict[str, object | None]] = []
|
|
self.conversations_list_calls: list[dict[str, object | None]] = []
|
|
self.conversations_replies_calls: list[dict[str, object | None]] = []
|
|
self.users_list_calls: list[dict[str, object | None]] = []
|
|
self.conversations_open_calls: list[dict[str, object | None]] = []
|
|
self._conversations_pages: list[dict[str, object]] = []
|
|
self._conversations_replies_response: dict[str, object] = {"messages": []}
|
|
self._users_pages: list[dict[str, object]] = []
|
|
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}}
|
|
|
|
async def chat_postMessage( # noqa: N802 - mirrors Slack SDK method name
|
|
self,
|
|
*,
|
|
channel: str,
|
|
text: str,
|
|
thread_ts: str | None = None,
|
|
blocks: list[dict[str, object]] | None = None,
|
|
) -> None:
|
|
call: dict[str, object | None] = {
|
|
"channel": channel,
|
|
"text": text,
|
|
"thread_ts": thread_ts,
|
|
}
|
|
if blocks is not None:
|
|
call["blocks"] = blocks
|
|
self.chat_post_calls.append(call)
|
|
|
|
async def files_upload_v2(
|
|
self,
|
|
*,
|
|
channel: str,
|
|
file: str,
|
|
thread_ts: str | None = None,
|
|
) -> None:
|
|
self.file_upload_calls.append(
|
|
{
|
|
"channel": channel,
|
|
"file": file,
|
|
"thread_ts": thread_ts,
|
|
}
|
|
)
|
|
|
|
async def reactions_add(
|
|
self,
|
|
*,
|
|
channel: str,
|
|
name: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
self.reactions_add_calls.append(
|
|
{
|
|
"channel": channel,
|
|
"name": name,
|
|
"timestamp": timestamp,
|
|
}
|
|
)
|
|
|
|
async def reactions_remove(
|
|
self,
|
|
*,
|
|
channel: str,
|
|
name: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
self.reactions_remove_calls.append(
|
|
{
|
|
"channel": channel,
|
|
"name": name,
|
|
"timestamp": timestamp,
|
|
}
|
|
)
|
|
|
|
async def conversations_list(self, **kwargs):
|
|
self.conversations_list_calls.append(kwargs)
|
|
if self._conversations_pages:
|
|
return self._conversations_pages.pop(0)
|
|
return {"channels": [], "response_metadata": {"next_cursor": ""}}
|
|
|
|
async def conversations_replies(self, **kwargs):
|
|
self.conversations_replies_calls.append(kwargs)
|
|
return self._conversations_replies_response
|
|
|
|
async def users_list(self, **kwargs):
|
|
self.users_list_calls.append(kwargs)
|
|
if self._users_pages:
|
|
return self._users_pages.pop(0)
|
|
return {"members": [], "response_metadata": {"next_cursor": ""}}
|
|
|
|
async def conversations_open(self, **kwargs):
|
|
self.conversations_open_calls.append(kwargs)
|
|
return self._open_dm_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_uses_thread_for_channel_messages() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="C123",
|
|
content="hello",
|
|
media=["/tmp/demo.txt"],
|
|
metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}},
|
|
)
|
|
)
|
|
|
|
assert len(fake_web.chat_post_calls) == 1
|
|
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
|
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
|
assert len(fake_web.file_upload_calls) == 1
|
|
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_omits_thread_for_dm_root_messages() -> None:
|
|
"""DM root replies should not be threaded; metadata carries thread_ts=None."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="D123",
|
|
content="hello",
|
|
media=["/tmp/demo.txt"],
|
|
metadata={"slack": {"thread_ts": None, "channel_type": "im"}},
|
|
)
|
|
)
|
|
|
|
assert len(fake_web.chat_post_calls) == 1
|
|
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
|
assert fake_web.chat_post_calls[0]["thread_ts"] is None
|
|
assert len(fake_web.file_upload_calls) == 1
|
|
assert fake_web.file_upload_calls[0]["thread_ts"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_keeps_thread_for_dm_thread_messages() -> None:
|
|
"""When the user replies inside a DM thread, bot replies stay in the same thread."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="D123",
|
|
content="hello",
|
|
media=["/tmp/demo.txt"],
|
|
metadata={
|
|
"slack": {
|
|
"thread_ts": "1700000000.000100",
|
|
"channel_type": "im",
|
|
"event": {"channel": "D123"},
|
|
}
|
|
},
|
|
)
|
|
)
|
|
|
|
assert len(fake_web.chat_post_calls) == 1
|
|
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
|
assert len(fake_web.file_upload_calls) == 1
|
|
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_splits_long_messages() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="C123",
|
|
content="x" * (SLACK_MAX_MESSAGE_LEN + 10),
|
|
)
|
|
)
|
|
|
|
assert len(fake_web.chat_post_calls) == 2
|
|
assert all(len(str(call["text"])) <= SLACK_MAX_MESSAGE_LEN for call in fake_web.chat_post_calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_renders_buttons_on_last_message_chunk() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="C123",
|
|
content="Choose one",
|
|
buttons=[["Yes", "No"]],
|
|
)
|
|
)
|
|
|
|
assert len(fake_web.chat_post_calls) == 1
|
|
blocks = fake_web.chat_post_calls[0]["blocks"]
|
|
assert isinstance(blocks, list)
|
|
assert blocks[-1] == {
|
|
"type": "actions",
|
|
"elements": [
|
|
{
|
|
"type": "button",
|
|
"text": {"type": "plain_text", "text": "Yes"},
|
|
"value": "Yes",
|
|
"action_id": "btn_Yes",
|
|
},
|
|
{
|
|
"type": "button",
|
|
"text": {"type": "plain_text", "text": "No"},
|
|
"value": "No",
|
|
"action_id": "btn_No",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_updates_reaction_when_final_response_sent() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="C123",
|
|
content="done",
|
|
metadata={
|
|
"slack": {"event": {"ts": "1700000000.000100"}, "channel_type": "channel"},
|
|
},
|
|
)
|
|
)
|
|
|
|
assert fake_web.reactions_remove_calls == [
|
|
{"channel": "C123", "name": "eyes", "timestamp": "1700000000.000100"}
|
|
]
|
|
assert fake_web.reactions_add_calls == [
|
|
{"channel": "C123", "name": "white_check_mark", "timestamp": "1700000000.000100"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_resolves_channel_name_to_channel_id() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._conversations_pages = [
|
|
{
|
|
"channels": [{"id": "C999", "name": "channel_x"}],
|
|
"response_metadata": {"next_cursor": ""},
|
|
}
|
|
]
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="#channel_x",
|
|
content="hello",
|
|
)
|
|
)
|
|
|
|
assert fake_web.chat_post_calls == [
|
|
{"channel": "C999", "text": "hello", "thread_ts": None}
|
|
]
|
|
assert len(fake_web.conversations_list_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_resolves_user_handle_to_dm_channel() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._users_pages = [
|
|
{
|
|
"members": [
|
|
{
|
|
"id": "U234",
|
|
"name": "alice",
|
|
"profile": {"display_name": "Alice"},
|
|
}
|
|
],
|
|
"response_metadata": {"next_cursor": ""},
|
|
}
|
|
]
|
|
fake_web._open_dm_response = {"channel": {"id": "D234"}}
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="@alice",
|
|
content="hello",
|
|
)
|
|
)
|
|
|
|
assert fake_web.conversations_open_calls == [{"users": "U234"}]
|
|
assert fake_web.chat_post_calls == [
|
|
{"channel": "D234", "text": "hello", "thread_ts": None}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._conversations_pages = [
|
|
{
|
|
"channels": [{"id": "C999", "name": "channel_x"}],
|
|
"response_metadata": {"next_cursor": ""},
|
|
}
|
|
]
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="channel_x",
|
|
content="done",
|
|
metadata={
|
|
"slack": {
|
|
"event": {"ts": "1700000000.000100", "channel": "D_ORIGIN"},
|
|
"channel_type": "im",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
assert fake_web.chat_post_calls == [
|
|
{"channel": "C999", "text": "done", "thread_ts": None}
|
|
]
|
|
assert fake_web.reactions_remove_calls == [
|
|
{"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"}
|
|
]
|
|
assert fake_web.reactions_add_calls == [
|
|
{"channel": "D_ORIGIN", "name": "white_check_mark", "timestamp": "1700000000.000100"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._conversations_pages = [
|
|
{
|
|
"channels": [{"id": "C999", "name": "channel_x"}],
|
|
"response_metadata": {"next_cursor": ""},
|
|
}
|
|
]
|
|
channel._web_client = fake_web
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="channel_x",
|
|
content="done",
|
|
metadata={
|
|
"slack": {
|
|
"event": {"ts": "1700000000.000100", "channel": "C_ORIGIN"},
|
|
"thread_ts": "1700000000.000200",
|
|
"channel_type": "channel",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
assert fake_web.chat_post_calls == [
|
|
{"channel": "C999", "text": "done", "thread_ts": None}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_raises_when_named_target_cannot_be_resolved() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
fake_web = _FakeAsyncWebClient()
|
|
channel._web_client = fake_web
|
|
|
|
with pytest.raises(ValueError, match="was not found"):
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel="slack",
|
|
chat_id="#missing-channel",
|
|
content="hello",
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_thread_context_fetches_root_once() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._conversations_replies_response = {
|
|
"messages": [
|
|
{"ts": "111.000", "user": "UROOT", "text": "drink water"},
|
|
{"ts": "112.000", "user": "U2", "text": "good idea"},
|
|
{"ts": "112.500", "user": "UBOT", "text": "I'll remind you."},
|
|
{"ts": "113.000", "user": "U3", "text": "<@UBOT> what did you see?"},
|
|
]
|
|
}
|
|
channel._web_client = fake_web
|
|
|
|
content = await channel._with_thread_context(
|
|
"what did you see?",
|
|
chat_id="C123",
|
|
channel_type="channel",
|
|
thread_ts="111.000",
|
|
raw_thread_ts="111.000",
|
|
current_ts="113.000",
|
|
)
|
|
|
|
assert fake_web.conversations_replies_calls == [
|
|
{"channel": "C123", "ts": "111.000", "limit": 20}
|
|
]
|
|
assert "Slack thread context before this mention:" in content
|
|
assert "- <@UROOT>: drink water" in content
|
|
assert "- <@U2>: good idea" in content
|
|
assert "- bot: I'll remind you." in content
|
|
assert "U3" not in content
|
|
assert content.endswith("Current message:\nwhat did you see?")
|
|
|
|
second = await channel._with_thread_context(
|
|
"again",
|
|
chat_id="C123",
|
|
channel_type="channel",
|
|
thread_ts="111.000",
|
|
raw_thread_ts="111.000",
|
|
current_ts="114.000",
|
|
)
|
|
assert second == "again"
|
|
assert len(fake_web.conversations_replies_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_thread_context_fetches_replies_in_dm_thread() -> None:
|
|
"""DM threads should also pull thread history (not only channel threads)."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
fake_web = _FakeAsyncWebClient()
|
|
fake_web._conversations_replies_response = {
|
|
"messages": [
|
|
{"ts": "211.000", "user": "UA", "text": "here is the file"},
|
|
{"ts": "212.000", "user": "UA", "text": "please read it"},
|
|
]
|
|
}
|
|
channel._web_client = fake_web
|
|
|
|
content = await channel._with_thread_context(
|
|
"what did you see?",
|
|
chat_id="D123",
|
|
channel_type="im",
|
|
thread_ts="211.000",
|
|
raw_thread_ts="211.000",
|
|
current_ts="213.000",
|
|
)
|
|
|
|
assert fake_web.conversations_replies_calls == [
|
|
{"channel": "D123", "ts": "211.000", "limit": 20}
|
|
]
|
|
assert "Slack thread context before this mention:" in content
|
|
assert "- <@UA>: here is the file" in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_root_message_has_no_thread_ts_and_no_thread_session() -> None:
|
|
"""A top-level DM should not synthesize a thread_ts and uses the default session."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
req = SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id="env-dm-root",
|
|
payload={
|
|
"event": {
|
|
"type": "message",
|
|
"user": "U1",
|
|
"channel": "D123",
|
|
"channel_type": "im",
|
|
"text": "hello",
|
|
"ts": "1700000000.000100",
|
|
}
|
|
},
|
|
)
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["session_key"] is None
|
|
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
|
"""A DM message inside a real thread should preserve thread_ts and isolate the session."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
req = SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id="env-dm-thread",
|
|
payload={
|
|
"event": {
|
|
"type": "message",
|
|
"user": "U1",
|
|
"channel": "D123",
|
|
"channel_type": "im",
|
|
"text": "hello",
|
|
"ts": "1700000000.000200",
|
|
"thread_ts": "1700000000.000100",
|
|
}
|
|
},
|
|
)
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["session_key"] == "slack:D123:1700000000.000100"
|
|
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
|
|
|
|
|
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id=envelope_id,
|
|
payload={
|
|
"event": {
|
|
"type": "app_mention",
|
|
"user": "U1",
|
|
"channel": "C123",
|
|
"text": "<@UBOT> hello",
|
|
"ts": ts,
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_root_message_uses_thread_scoped_session() -> None:
|
|
"""A channel mention that opens a thread belongs to that thread's session."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
|
|
req = _channel_mention_request("env-c1", "1700000000.000100")
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
|
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_root_messages_do_not_share_one_session() -> None:
|
|
"""Two threads opened in the same channel must not collapse into one session."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
|
|
first = _channel_mention_request("env-c1", "1700000000.000100")
|
|
second = _channel_mention_request("env-c2", "1700000000.000200")
|
|
|
|
await channel._on_socket_request(client, first)
|
|
await channel._on_socket_request(client, second)
|
|
|
|
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
|
|
assert session_keys == [
|
|
"slack:C123:1700000000.000100",
|
|
"slack:C123:1700000000.000200",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
|
|
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
|
|
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
|
|
req = _channel_mention_request("env-c3", "1700000000.000300")
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["session_key"] is None
|
|
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_thread_reply_keeps_thread_session() -> None:
|
|
"""A reply inside a channel thread stays in the session opened by the root message."""
|
|
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
req = SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id="env-c4",
|
|
payload={
|
|
"event": {
|
|
"type": "app_mention",
|
|
"user": "U1",
|
|
"channel": "C123",
|
|
"text": "<@UBOT> follow up",
|
|
"ts": "1700000000.000400",
|
|
"thread_ts": "1700000000.000100",
|
|
}
|
|
},
|
|
)
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_slash_command_skips_thread_context() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._with_thread_context = AsyncMock(return_value="wrapped") # type: ignore[method-assign]
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
req = SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id="env-1",
|
|
payload={
|
|
"event": {
|
|
"type": "app_mention",
|
|
"user": "U1",
|
|
"channel": "C123",
|
|
"text": "<@UBOT> /restart",
|
|
"thread_ts": "111.000",
|
|
"ts": "112.000",
|
|
}
|
|
},
|
|
)
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._with_thread_context.assert_not_awaited()
|
|
channel._handle_message.assert_awaited_once()
|
|
assert channel._handle_message.await_args.kwargs["content"] == "/restart"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_file_share_downloads_media_and_reaches_agent() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
channel._web_client = _FakeAsyncWebClient()
|
|
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
channel._download_slack_file = AsyncMock( # type: ignore[method-assign]
|
|
return_value=("/tmp/report.pdf", "[file: report.pdf]")
|
|
)
|
|
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
req = SimpleNamespace(
|
|
type="events_api",
|
|
envelope_id="env-file",
|
|
payload={
|
|
"event": {
|
|
"type": "message",
|
|
"subtype": "file_share",
|
|
"user": "U1",
|
|
"channel": "D123",
|
|
"channel_type": "im",
|
|
"text": "please read this",
|
|
"ts": "1700000000.000100",
|
|
"files": [
|
|
{
|
|
"id": "F123",
|
|
"name": "report.pdf",
|
|
"mimetype": "application/pdf",
|
|
"url_private_download": "https://files.slack.com/report.pdf",
|
|
}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
await channel._on_socket_request(client, req)
|
|
|
|
channel._download_slack_file.assert_awaited_once()
|
|
channel._handle_message.assert_awaited_once()
|
|
kwargs = channel._handle_message.await_args.kwargs
|
|
assert kwargs["content"] == "please read this\n[file: report.pdf]"
|
|
assert kwargs["media"] == ["/tmp/report.pdf"]
|
|
|
|
|
|
def test_slack_download_rejects_login_html() -> None:
|
|
html_response = httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/html; charset=utf-8"},
|
|
content=b"<!doctype html><html><title>Sign in to Slack</title>",
|
|
)
|
|
markdown_response = httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/markdown"},
|
|
content=b"# PR Extraction Guide\n",
|
|
)
|
|
|
|
assert SlackChannel._looks_like_html_download(html_response) is True
|
|
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
|
|
|
|
|
def test_slack_download_failure_marker_is_actionable() -> None:
|
|
marker = SlackChannel._download_failure_marker("image", "screenshot.png", "download failed")
|
|
|
|
assert "not available to nanobot" in marker
|
|
assert "files:read" in marker
|
|
assert "reinstall the Slack app" in marker
|
|
|
|
|
|
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
|
assert channel.is_allowed("U1") is True
|
|
assert channel._is_allowed("U1", "C123", "channel") is True
|
|
|
|
|
|
def test_mention_policy_responds_to_mentions_in_any_channel() -> None:
|
|
channel = SlackChannel(SlackConfig(enabled=True, group_policy="mention"), MessageBus())
|
|
channel._bot_user_id = "UBOT"
|
|
|
|
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C123") is True
|
|
assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C999") is True
|
|
assert channel._should_respond_in_channel("message", "no mention here", "C123") is False
|
|
|
|
|
|
def test_allowlist_policy_restricts_to_approved_channels() -> None:
|
|
channel = SlackChannel(
|
|
SlackConfig(enabled=True, group_policy="allowlist", group_allow_from=["C_OK"]),
|
|
MessageBus(),
|
|
)
|
|
channel._bot_user_id = "UBOT"
|
|
|
|
# In an approved channel without require_mention, respond to anything.
|
|
assert channel._should_respond_in_channel("message", "anything", "C_OK") is True
|
|
# An unapproved channel is always rejected.
|
|
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False
|
|
# _is_allowed also gates on the channel allowlist.
|
|
assert channel._is_allowed("U1", "C_OK", "channel") is True
|
|
assert channel._is_allowed("U1", "C_NOPE", "channel") is False
|
|
|
|
|
|
def test_allowlist_with_require_mention_needs_both_channel_and_mention() -> None:
|
|
channel = SlackChannel(
|
|
SlackConfig(
|
|
enabled=True,
|
|
group_policy="allowlist",
|
|
group_allow_from=["C_OK"],
|
|
group_require_mention=True,
|
|
),
|
|
MessageBus(),
|
|
)
|
|
channel._bot_user_id = "UBOT"
|
|
|
|
# Approved channel + mention -> respond.
|
|
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_OK") is True
|
|
assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C_OK") is True
|
|
# Approved channel but no mention -> stay quiet.
|
|
assert channel._should_respond_in_channel("message", "just chatting", "C_OK") is False
|
|
# Mention in an unapproved channel -> stay quiet.
|
|
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False
|
|
|
|
|
|
def test_group_require_mention_accepts_camel_case_alias() -> None:
|
|
config = SlackConfig.model_validate(
|
|
{
|
|
"enabled": True,
|
|
"groupPolicy": "allowlist",
|
|
"groupAllowFrom": ["C_OK"],
|
|
"groupRequireMention": True,
|
|
}
|
|
)
|
|
assert config.group_require_mention is True
|
|
assert config.group_allow_from == ["C_OK"]
|
|
|
|
|
|
def test_to_mrkdwn_keeps_fenced_markdown_tables_intact() -> None:
|
|
text = "Intro\n\n```\n| a | b |\n| - | - |\n| 1 | 2 |\n```\n\nOutro"
|
|
out = SlackChannel._to_mrkdwn(text)
|
|
|
|
assert "```\n| a | b |\n| - | - |\n| 1 | 2 |\n```" in out
|
|
assert "**a**: 1" not in out
|
|
assert "*a*: 1" not in out
|
|
|
|
|
|
def test_to_mrkdwn_still_converts_unfenced_markdown_tables() -> None:
|
|
out = SlackChannel._to_mrkdwn("| a | b |\n| - | - |\n| 1 | 2 |")
|
|
|
|
assert "| a | b |" not in out
|
|
assert "a" in out and "1" in out and "b" in out and "2" in out
|
|
|
|
|
|
# ── file download SSRF ─────────────────────────────────────────────
|
|
|
|
|
|
def _patch_download_transport(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
handler: Callable[[httpx.Request], httpx.Response],
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"nanobot.channels.slack.runtime.PinnedDNSAsyncTransport",
|
|
lambda: httpx.MockTransport(handler),
|
|
)
|
|
monkeypatch.setattr("nanobot.channels.slack.runtime.httpx_env_proxy_mounts", lambda: {})
|
|
|
|
|
|
def _patch_download_validation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
validated: list[str],
|
|
) -> None:
|
|
async def validate(url: str) -> tuple[bool, str]:
|
|
validated.append(url)
|
|
if "169.254.169.254" in url:
|
|
return False, "blocked metadata address"
|
|
return True, ""
|
|
|
|
monkeypatch.setattr("nanobot.channels.slack.runtime.async_validate_url_target", validate)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_blocks_ssrf_target(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""An internal file URL is rejected before the transport sees it."""
|
|
requests: list[httpx.Request] = []
|
|
validated: list[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(200, content=b"should not be fetched")
|
|
|
|
_patch_download_transport(monkeypatch, handler)
|
|
_patch_download_validation(monkeypatch, validated)
|
|
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
|
url = "http://169.254.169.254/latest/meta-data/"
|
|
|
|
path, _marker = await channel._download_slack_file(
|
|
{"id": "F1", "name": "x.bin", "url_private_download": url}
|
|
)
|
|
|
|
assert path is None
|
|
assert requests == []
|
|
assert validated == [url]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_blocks_unsafe_redirect(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Redirect targets are validated before the redirected request is sent."""
|
|
requests: list[httpx.Request] = []
|
|
validated: list[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
302,
|
|
headers={"location": "http://169.254.169.254/latest/meta-data/"},
|
|
)
|
|
|
|
_patch_download_transport(monkeypatch, handler)
|
|
_patch_download_validation(monkeypatch, validated)
|
|
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
|
url = "https://files.slack.com/files-pri/x"
|
|
|
|
path, _marker = await channel._download_slack_file(
|
|
{"id": "F1", "name": "x.bin", "url_private_download": url}
|
|
)
|
|
|
|
assert path is None
|
|
assert len(requests) == 1
|
|
assert validated == [url, "http://169.254.169.254/latest/meta-data/"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_follows_safe_redirect(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Public redirects still download the file without forwarding cross-host auth."""
|
|
requests: list[httpx.Request] = []
|
|
validated: list[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
if request.url.host == "files.slack.com":
|
|
return httpx.Response(302, headers={"location": "https://cdn.example/file.bin"})
|
|
return httpx.Response(
|
|
200,
|
|
content=b"filedata",
|
|
headers={"content-type": "application/octet-stream"},
|
|
)
|
|
|
|
_patch_download_transport(monkeypatch, handler)
|
|
_patch_download_validation(monkeypatch, validated)
|
|
monkeypatch.setattr(
|
|
"nanobot.channels.slack.runtime.get_media_dir", lambda _channel=None: str(tmp_path)
|
|
)
|
|
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
|
url = "https://files.slack.com/files-pri/x"
|
|
|
|
path, marker = await channel._download_slack_file(
|
|
{"id": "F1", "name": "x.bin", "url_private_download": url}
|
|
)
|
|
|
|
assert path is not None
|
|
assert Path(path).read_bytes() == b"filedata"
|
|
assert marker == "[file: x.bin]"
|
|
assert validated == [url, "https://cdn.example/file.bin"]
|
|
assert requests[0].headers["Authorization"] == "Bearer xoxb-test"
|
|
assert "Authorization" not in requests[1].headers
|