mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33c82962a2
User- and model-authored text (task titles, approval headers, command previews, judge output, error fragments, notification bodies) reaches both channel integrations verbatim, and nothing at the channel boundary neutralised it. The Discord client now carries a client-level allowed-mentions-none default, which every message create inherits — plain sends, edits, and embeds — so broadcast and mention syntax in untrusted text cannot resolve, without mutating the text itself. The Slack adapter escapes each untrusted field into mrkdwn entities at its interpolation site — never the assembled message, so deliberately bot-authored markup like the session-opener mention survives. The policy-deny feedback returned to the server stays verbatim; only the rendered notice escapes. Storage and the shared formatter stay channel-neutral and verbatim: projection happens per audience at the render boundary.
1374 lines
52 KiB
Python
1374 lines
52 KiB
Python
"""Tests for the Slack channel adapter (bot, config, CLI)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
slack_bolt = pytest.importorskip("slack_bolt")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run(coro): # type: ignore[no-untyped-def]
|
|
"""Run an async coroutine in a fresh event loop (no pytest-asyncio needed)."""
|
|
return asyncio.run(coro)
|
|
|
|
|
|
def _make_slack_event(
|
|
*,
|
|
bot_id: str | None = None,
|
|
subtype: str | None = None,
|
|
channel: str = "C01SAPU5414",
|
|
channel_type: str = "channel",
|
|
thread_ts: str = "1234567890.000100",
|
|
user: str = "U12345",
|
|
text: str = "hello",
|
|
ts: str = "1234567890.000200",
|
|
) -> dict[str, object]:
|
|
event: dict[str, object] = {
|
|
"channel": channel,
|
|
"channel_type": channel_type,
|
|
"user": user,
|
|
"text": text,
|
|
"ts": ts,
|
|
"thread_ts": thread_ts,
|
|
}
|
|
if bot_id is not None:
|
|
event["bot_id"] = bot_id
|
|
if subtype is not None:
|
|
event["subtype"] = subtype
|
|
return event
|
|
|
|
|
|
def _make_bot() -> tuple[object, MagicMock, MagicMock]:
|
|
"""Build a TurnstoneSlackBot with fully mocked dependencies."""
|
|
from turnstone.channels.slack.bot import TurnstoneSlackBot
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
|
|
config = SlackConfig(
|
|
bot_token="xoxb-test",
|
|
app_token="xapp-test",
|
|
allowed_channels=["C01SAPU5414"],
|
|
auto_approve=False,
|
|
slash_command="/network-help",
|
|
)
|
|
|
|
storage = MagicMock()
|
|
storage.list_channel_routes_by_type = MagicMock(return_value=[])
|
|
|
|
from turnstone.channels._routing import PolicyVerdict
|
|
|
|
router = MagicMock()
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-1", True))
|
|
router.send_message = AsyncMock()
|
|
router.send_approval = AsyncMock()
|
|
router.get_node_url = AsyncMock(return_value="http://localhost:8080")
|
|
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
|
router.delete_route = AsyncMock()
|
|
router.close_workstream = AsyncMock()
|
|
# Default: every test Slack user is already linked. Tests that
|
|
# exercise the unlinked path override this per-instance.
|
|
router.resolve_user = AsyncMock(return_value="turnstone-user-1")
|
|
router.aclose = AsyncMock()
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "1234567890.000100"})
|
|
client.chat_update = AsyncMock(return_value={"ok": True})
|
|
client.chat_postEphemeral = AsyncMock(return_value={"ok": True})
|
|
client.conversations_history = AsyncMock(return_value={"ok": True, "messages": []})
|
|
client.views_open = AsyncMock(return_value={"ok": True})
|
|
|
|
# Patch httpx.AsyncClient so each test doesn't open a real client that
|
|
# leaks an unclosed-warning at GC time. The bot's _http_client is only
|
|
# used by the SDK router (which we replace with a MagicMock below), so
|
|
# an AsyncMock standin is enough for every test that uses this factory.
|
|
with (
|
|
patch("turnstone.channels.slack.bot.AsyncApp", MagicMock()),
|
|
patch("turnstone.channels.slack.bot.AsyncWebClient", return_value=client),
|
|
patch("turnstone.channels.slack.bot.httpx.AsyncClient", return_value=AsyncMock()),
|
|
):
|
|
bot = TurnstoneSlackBot(
|
|
config,
|
|
server_url="http://localhost:8080",
|
|
storage=storage,
|
|
)
|
|
|
|
bot.router = router # type: ignore[attr-defined]
|
|
bot._client = client # type: ignore[attr-defined]
|
|
|
|
return bot, router, client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SlackConfig
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSlackConfig:
|
|
"""Tests for SlackConfig default and custom values."""
|
|
|
|
def test_defaults(self) -> None:
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
|
|
cfg = SlackConfig()
|
|
assert cfg.bot_token == ""
|
|
assert cfg.app_token == ""
|
|
assert cfg.allowed_channels == []
|
|
assert cfg.max_message_length == 3000
|
|
assert cfg.streaming_edit_interval == 1.5
|
|
assert cfg.slash_command == "/turnstone"
|
|
# Inherited from ChannelConfig
|
|
assert cfg.model == ""
|
|
assert cfg.auto_approve is False
|
|
|
|
def test_custom_values(self) -> None:
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
|
|
cfg = SlackConfig(
|
|
bot_token="xoxb-123",
|
|
app_token="xapp-456",
|
|
allowed_channels=["C1", "C2"],
|
|
max_message_length=4000,
|
|
streaming_edit_interval=0.5,
|
|
model="gpt-4.1",
|
|
auto_approve=True,
|
|
slash_command="/network-help",
|
|
)
|
|
assert cfg.bot_token == "xoxb-123"
|
|
assert cfg.app_token == "xapp-456"
|
|
assert cfg.allowed_channels == ["C1", "C2"]
|
|
assert cfg.max_message_length == 4000
|
|
assert cfg.streaming_edit_interval == 0.5
|
|
assert cfg.model == "gpt-4.1"
|
|
assert cfg.auto_approve is True
|
|
assert cfg.slash_command == "/network-help"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SlackRoute
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSlackRoute:
|
|
"""Tests for canonical Slack route parsing/formatting."""
|
|
|
|
def test_parse_channel_only(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
route = SlackRoute.parse("C123")
|
|
assert route.channel == "C123"
|
|
assert route.user_id is None
|
|
assert route.thread_ts is None
|
|
|
|
def test_parse_channel_and_user(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
route = SlackRoute.parse("C123:U456")
|
|
assert route.channel == "C123"
|
|
assert route.user_id == "U456"
|
|
assert route.thread_ts is None
|
|
|
|
def test_parse_full(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
route = SlackRoute.parse("C123:U456:111.222")
|
|
assert route.channel == "C123"
|
|
assert route.user_id == "U456"
|
|
assert route.thread_ts == "111.222"
|
|
|
|
def test_to_channel_id(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
assert SlackRoute(channel="C123").to_channel_id() == "C123"
|
|
assert SlackRoute(channel="C123", user_id="U456").to_channel_id() == "C123:U456"
|
|
assert (
|
|
SlackRoute(channel="C123", user_id="U456", thread_ts="111.222").to_channel_id()
|
|
== "C123:U456:111.222"
|
|
)
|
|
|
|
def test_round_trip(self) -> None:
|
|
"""Every shape emitted by to_channel_id must round-trip through parse."""
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
shapes = [
|
|
SlackRoute(channel="C123"),
|
|
SlackRoute(channel="C123", user_id="U456"),
|
|
SlackRoute(channel="C123", user_id="U456", thread_ts="111.222"),
|
|
]
|
|
for route in shapes:
|
|
assert SlackRoute.parse(route.to_channel_id()) == route
|
|
|
|
def test_parse_trailing_colon_normalizes(self) -> None:
|
|
"""``"C123:"`` should normalize to ``SlackRoute("C123")``."""
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
assert SlackRoute.parse("C123:") == SlackRoute(channel="C123")
|
|
assert SlackRoute.parse("C123:U456:") == SlackRoute(channel="C123", user_id="U456")
|
|
|
|
def test_parse_extra_colons_folded_into_thread_ts(self) -> None:
|
|
"""Extra ``:`` past the third field fold into ``thread_ts`` verbatim.
|
|
|
|
Slack IDs and timestamps never contain ``:`` so this is safe in
|
|
practice; the test locks the documented behaviour.
|
|
"""
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
route = SlackRoute.parse("C1:U1:ts:extra")
|
|
assert route.channel == "C1"
|
|
assert route.user_id == "U1"
|
|
assert route.thread_ts == "ts:extra"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route recovery + session archival
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRecoverRoutes:
|
|
"""Tests for TurnstoneSlackBot._recover_routes on bot startup."""
|
|
|
|
def test_latest_ts_per_user_wins(self) -> None:
|
|
"""When multiple routes exist for the same (channel, user), the
|
|
newest thread_ts populates _channel_sessions."""
|
|
bot, _router, _client = _make_bot()
|
|
bot.storage.list_channel_routes_by_type = MagicMock( # type: ignore[attr-defined]
|
|
return_value=[
|
|
{"ws_id": "ws-old", "channel_id": "C1:U1:1000000.000001"},
|
|
{"ws_id": "ws-new", "channel_id": "C1:U1:2000000.000001"},
|
|
]
|
|
)
|
|
bot.subscribe_ws = AsyncMock() # type: ignore[attr-defined]
|
|
|
|
_run(bot._recover_routes()) # type: ignore[attr-defined]
|
|
|
|
assert bot._channel_sessions == {("C1", "U1"): ("ws-new", "2000000.000001")} # type: ignore[attr-defined]
|
|
# Both routes get resubscribed so their SSE streams stay active.
|
|
assert bot.subscribe_ws.await_count == 2 # type: ignore[attr-defined]
|
|
|
|
def test_non_threaded_routes_skip_session_table(self) -> None:
|
|
"""A route without a thread_ts still gets subscribed but never
|
|
populates _channel_sessions (DMs fall into this shape)."""
|
|
bot, _router, _client = _make_bot()
|
|
bot.storage.list_channel_routes_by_type = MagicMock( # type: ignore[attr-defined]
|
|
return_value=[{"ws_id": "ws-dm", "channel_id": "D1:U9"}]
|
|
)
|
|
bot.subscribe_ws = AsyncMock() # type: ignore[attr-defined]
|
|
|
|
_run(bot._recover_routes()) # type: ignore[attr-defined]
|
|
|
|
assert bot._channel_sessions == {} # type: ignore[attr-defined]
|
|
bot.subscribe_ws.assert_awaited_once_with("ws-dm", "D1:U9") # type: ignore[attr-defined]
|
|
|
|
|
|
class TestArchiveSession:
|
|
"""Tests for TurnstoneSlackBot._archive_session cleanup."""
|
|
|
|
def test_archive_drops_route_and_closes_workstream(self) -> None:
|
|
bot, router, client = _make_bot()
|
|
bot._channel_sessions[("C1", "U1")] = ("ws-old", "1000000.000001") # type: ignore[attr-defined]
|
|
bot._subscribed_ws.add("ws-old") # type: ignore[attr-defined]
|
|
|
|
_run(bot._archive_session("C1", "U1", "ws-old", "1000000.000001")) # type: ignore[attr-defined]
|
|
|
|
router.delete_route.assert_awaited_once_with( # type: ignore[attr-defined]
|
|
"slack", "C1:U1:1000000.000001"
|
|
)
|
|
router.close_workstream.assert_awaited_once_with("ws-old") # type: ignore[attr-defined]
|
|
assert ("C1", "U1") not in bot._channel_sessions # type: ignore[attr-defined]
|
|
# archive notice posted in the old thread
|
|
client.chat_postMessage.assert_awaited() # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview sanitization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPreviewSanitization:
|
|
def test_sanitize_slack_preview_escapes_and_truncates(self) -> None:
|
|
from turnstone.channels.slack.bot import _sanitize_slack_preview
|
|
|
|
text = "<@U123>`abc`" + ("x" * 2000)
|
|
out = _sanitize_slack_preview(text, max_length=50)
|
|
|
|
# Mention markup is escaped so it can't render as a real ping
|
|
assert "<@U123>" in out
|
|
# Single backticks survive — code-quoted snippets stay readable
|
|
assert "`abc`" in out
|
|
assert len(out) <= 50
|
|
|
|
def test_sanitize_slack_preview_neutralizes_triple_backtick(self) -> None:
|
|
"""Triple backticks would close the surrounding mrkdwn fence — splice
|
|
a zero-width space inside so Slack no longer recognizes it as a
|
|
delimiter."""
|
|
from turnstone.channels.slack.bot import _sanitize_slack_preview
|
|
|
|
out = _sanitize_slack_preview("inner ``` text", max_length=200)
|
|
assert "```" not in out
|
|
assert "``\u200b`" in out
|
|
|
|
def test_sanitize_slack_preview_keeps_short_input(self) -> None:
|
|
from turnstone.channels.slack.bot import _sanitize_slack_preview
|
|
|
|
out = _sanitize_slack_preview("short and clean", max_length=200)
|
|
assert out == "short and clean"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mrkdwn field escaping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMrkdwnFieldEscaping:
|
|
"""User/model-authored fields must reach the Slack wire entity-escaped
|
|
while the bot's own mrkdwn framing stays live."""
|
|
|
|
def test_approval_card_escapes_hostile_fields(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ApproveRequestEvent
|
|
|
|
bot, _router, client = _make_bot()
|
|
|
|
event = ApproveRequestEvent(
|
|
ws_id="ws-1",
|
|
cycle_id="cyc-1",
|
|
items=[
|
|
{
|
|
"call_id": "c-1",
|
|
"func_name": "tasks",
|
|
"approval_label": "@everyone <cmd>",
|
|
"preview": "title=<!channel> ping <@U123> a & b <tag>",
|
|
"needs_approval": True,
|
|
}
|
|
],
|
|
)
|
|
route = SlackRoute(channel="C01SAPU5414", user_id="U9", thread_ts="1.2")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
client.chat_postMessage.assert_awaited_once()
|
|
body = client.chat_postMessage.call_args[1]["blocks"][0]["text"]["text"]
|
|
# Broadcast keywords, raw mention syntax, and entities arrive escaped.
|
|
assert "<!channel>" in body
|
|
assert "<@U123>" in body
|
|
assert "a & b <tag>" in body
|
|
assert "<cmd>" in body
|
|
assert "<!channel>" not in body
|
|
assert "<@U123>" not in body
|
|
# A literal @everyone is inert outside angle brackets — kept as-is.
|
|
assert "@everyone" in body
|
|
# Bot-authored framing stays live mrkdwn.
|
|
assert body.startswith("*Tool Approval Required*")
|
|
assert "```" in body
|
|
|
|
def test_policy_deny_notice_escapes_names_but_feedback_stays_verbatim(self) -> None:
|
|
from turnstone.channels._routing import PolicyVerdict
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ApproveRequestEvent
|
|
|
|
bot, router, client = _make_bot()
|
|
router.evaluate_tool_policies = AsyncMock(
|
|
return_value=PolicyVerdict(kind="deny", denied_tools=["evil<!channel>tool"])
|
|
)
|
|
|
|
event = ApproveRequestEvent(
|
|
ws_id="ws-1",
|
|
cycle_id="cyc-1",
|
|
items=[{"call_id": "c-1", "func_name": "evil<!channel>tool", "needs_approval": True}],
|
|
)
|
|
route = SlackRoute(channel="C01SAPU5414", user_id="U9", thread_ts="1.2")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
# Slack-rendered notice is escaped...
|
|
text = client.chat_postMessage.call_args[1]["text"]
|
|
assert "evil<!channel>tool" in text
|
|
assert "<!channel>" not in text
|
|
# ...but the feedback routed back to the server stays verbatim —
|
|
# projection happens per audience at render, never upstream.
|
|
router.send_approval.assert_awaited_once_with(
|
|
"ws-1",
|
|
"cyc-1",
|
|
approved=False,
|
|
feedback="Blocked by tool policy: evil<!channel>tool",
|
|
)
|
|
|
|
def test_intent_verdict_escapes_judge_fields(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import IntentVerdictEvent
|
|
|
|
bot, _router, client = _make_bot()
|
|
bot._pending_approval[("ws-1", "cyc-1")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C1",
|
|
message_ts="999.000",
|
|
owner_user_id="U9",
|
|
cycle_id="cyc-1",
|
|
call_ids=frozenset({"c-1"}),
|
|
)
|
|
|
|
event = IntentVerdictEvent(
|
|
ws_id="ws-1",
|
|
call_id="c-1",
|
|
func_name="bash <!everyone>",
|
|
risk_level="high",
|
|
confidence=0.9,
|
|
intent_summary="pings <!channel> & <@U123>",
|
|
)
|
|
route = SlackRoute(channel="C1", user_id="U9", thread_ts="1.2")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
client.chat_update.assert_awaited_once()
|
|
blocks = client.chat_update.call_args[1]["blocks"]
|
|
verdict_text = blocks[-1]["text"]["text"]
|
|
assert "bash <!everyone>" in verdict_text
|
|
assert "pings <!channel> & <@U123>" in verdict_text
|
|
assert "<!channel>" not in verdict_text
|
|
assert "<!everyone>" not in verdict_text
|
|
# Bot framing survives around the escaped fields.
|
|
assert verdict_text.startswith("*Judge Verdict: ")
|
|
|
|
def test_error_event_escapes_hostile_message(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ErrorEvent
|
|
|
|
bot, _router, client = _make_bot()
|
|
|
|
event = ErrorEvent(ws_id="ws-1", message="boom <!channel> & <@U123>")
|
|
route = SlackRoute(channel="C1", user_id="U9", thread_ts="1.2")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
text = client.chat_postMessage.call_args[1]["text"]
|
|
assert text.startswith("*Error:* ")
|
|
assert "boom <!channel> & <@U123>" in text
|
|
assert "<!channel>" not in text
|
|
|
|
def test_send_escapes_notification_content(self) -> None:
|
|
bot, _router, client = _make_bot()
|
|
|
|
_run(
|
|
bot.send( # type: ignore[attr-defined]
|
|
"C01SAPU5414:U12345",
|
|
"Task '<!channel> deploy & retry <now>' is idle",
|
|
)
|
|
)
|
|
|
|
text = client.chat_postMessage.call_args[1]["text"]
|
|
assert "<!channel> deploy & retry <now>" in text
|
|
assert "<!channel>" not in text
|
|
|
|
def test_session_opener_mention_survives_unescaped(self) -> None:
|
|
"""The opener's ``<@user>`` mention is bot-authored mrkdwn at a
|
|
trusted callsite — per-field escaping must never reach it."""
|
|
bot, router, client = _make_bot()
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-new", True))
|
|
|
|
body = {"channel_id": "C01SAPU5414", "user_id": "U777", "text": ""}
|
|
_run(bot._on_slash_command(AsyncMock(), body)) # type: ignore[attr-defined]
|
|
|
|
texts = [c.kwargs.get("text", "") for c in client.chat_postMessage.call_args_list]
|
|
assert any("<@U777> started a turnstone session." in t for t in texts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# StreamingMessage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStreamingMessage:
|
|
"""Tests for the StreamingMessage helper."""
|
|
|
|
def test_append_accumulates(self) -> None:
|
|
from turnstone.channels.slack.bot import StreamingMessage
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
client.chat_update = AsyncMock(return_value={"ok": True})
|
|
|
|
sm = StreamingMessage(client=client, channel="C1", edit_interval=999.0)
|
|
|
|
_run(sm.append("hello "))
|
|
_run(sm.append("world"))
|
|
|
|
assert sm.accumulated_text == "hello world"
|
|
|
|
def test_finalize_sends_when_no_prior_message(self) -> None:
|
|
from turnstone.channels.slack.bot import StreamingMessage
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
sm = StreamingMessage(client=client, channel="C1", edit_interval=999.0)
|
|
|
|
_run(sm.append("hello"))
|
|
_run(sm.finalize())
|
|
|
|
client.chat_postMessage.assert_awaited_once()
|
|
|
|
def test_finalize_edits_existing_message(self) -> None:
|
|
from turnstone.channels.slack.bot import StreamingMessage
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
client.chat_update = AsyncMock(return_value={"ok": True})
|
|
sm = StreamingMessage(client=client, channel="C1", edit_interval=0.0)
|
|
|
|
_run(sm.append("hi"))
|
|
assert sm.message_ts == "123"
|
|
|
|
_run(sm.finalize())
|
|
client.chat_update.assert_awaited()
|
|
|
|
def test_finalize_chunks_long_content(self) -> None:
|
|
from turnstone.channels.slack.bot import StreamingMessage
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
sm = StreamingMessage(client=client, channel="C1", max_length=10, edit_interval=999.0)
|
|
|
|
_run(sm.append("a" * 25))
|
|
_run(sm.finalize())
|
|
|
|
assert client.chat_postMessage.await_count >= 2
|
|
|
|
def test_finalize_empty_is_noop(self) -> None:
|
|
from turnstone.channels.slack.bot import StreamingMessage
|
|
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock()
|
|
sm = StreamingMessage(client=client, channel="C1")
|
|
|
|
_run(sm.finalize())
|
|
client.chat_postMessage.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _on_message filtering and routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOnMessage:
|
|
"""Tests for the _on_message handler filtering logic."""
|
|
|
|
def test_ignores_bot_messages(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
event = _make_slack_event(bot_id="B12345")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_not_awaited()
|
|
|
|
def test_ignores_subtype_messages(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
event = _make_slack_event(subtype="message_changed")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_not_awaited()
|
|
|
|
def test_ignores_non_allowed_channel(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
event = _make_slack_event(channel="C_NOT_ALLOWED")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_not_awaited()
|
|
|
|
def test_ignores_message_outside_session_thread(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
bot._channel_sessions[("C01SAPU5414", "U12345")] = ("ws-1", "9999999.000001") # type: ignore[attr-defined]
|
|
|
|
event = _make_slack_event(thread_ts="1234567890.000100")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_not_awaited()
|
|
|
|
def test_routes_message_in_active_session_thread(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
bot._channel_sessions[("C01SAPU5414", "U12345")] = ("ws-1", "1234567890.000100") # type: ignore[attr-defined]
|
|
|
|
event = _make_slack_event(thread_ts="1234567890.000100")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_awaited_once_with("ws-1", "hello")
|
|
|
|
def test_dm_routes_freely(self) -> None:
|
|
bot, router, _ = _make_bot()
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-dm", True))
|
|
|
|
event = _make_slack_event(channel="D12345", channel_type="im", thread_ts="")
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_awaited_once()
|
|
|
|
def test_notification_reply_routes_to_origin_workstream(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
bot, router, _ = _make_bot()
|
|
ws_id = "ws-123"
|
|
thread_ts = "1776321000.563629"
|
|
bot._notify_ws_map[thread_ts] = ( # type: ignore[attr-defined]
|
|
ws_id,
|
|
SlackRoute(channel="C01SAPU5414", user_id="U12345", thread_ts=thread_ts),
|
|
)
|
|
|
|
event = _make_slack_event(
|
|
channel="C01SAPU5414",
|
|
channel_type="channel",
|
|
thread_ts=thread_ts,
|
|
user="U12345",
|
|
text="reply text",
|
|
ts="1776321000.999999",
|
|
)
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
router.send_message.assert_awaited_once_with(ws_id, "reply text")
|
|
assert bot._notify_reply_routes[ws_id] == SlackRoute( # type: ignore[attr-defined]
|
|
channel="C01SAPU5414",
|
|
user_id="U12345",
|
|
thread_ts=thread_ts,
|
|
)
|
|
|
|
def test_notification_reply_dead_ws_clears_tracking(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk._types import TurnstoneAPIError
|
|
|
|
bot, router, client = _make_bot()
|
|
ws_id = "ws-dead"
|
|
thread_ts = "1776321102.784939"
|
|
bot._notify_ws_map[thread_ts] = ( # type: ignore[attr-defined]
|
|
ws_id,
|
|
SlackRoute(channel="C01SAPU5414", user_id="U12345", thread_ts=thread_ts),
|
|
)
|
|
router.send_message.side_effect = TurnstoneAPIError(404, "Unknown workstream")
|
|
|
|
event = _make_slack_event(
|
|
channel="C01SAPU5414",
|
|
channel_type="channel",
|
|
thread_ts=thread_ts,
|
|
user="U12345",
|
|
text="reply text",
|
|
ts="1776321103.000000",
|
|
)
|
|
say = AsyncMock()
|
|
|
|
_run(bot._on_message(event, say)) # type: ignore[attr-defined]
|
|
|
|
assert ws_id not in bot._notify_reply_routes # type: ignore[attr-defined]
|
|
assert thread_ts not in bot._notify_ws_map # type: ignore[attr-defined]
|
|
client.chat_postEphemeral.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-user session isolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPerUserSessionIsolation:
|
|
"""User1 session must not be closed when user2 starts a session."""
|
|
|
|
def test_user2_session_does_not_archive_user1(self) -> None:
|
|
bot, router, _client = _make_bot()
|
|
|
|
bot._channel_sessions[("C01SAPU5414", "U111")] = ("ws-user1", "1000000.000001") # type: ignore[attr-defined]
|
|
bot._subscribed_ws.add("ws-user1") # type: ignore[attr-defined]
|
|
|
|
body = {"channel_id": "C01SAPU5414", "user_id": "U222"}
|
|
ack = AsyncMock()
|
|
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-user2", True))
|
|
|
|
_run(bot._on_slash_command(ack, body)) # type: ignore[attr-defined]
|
|
|
|
assert ("C01SAPU5414", "U111") in bot._channel_sessions # type: ignore[attr-defined]
|
|
assert bot._channel_sessions[("C01SAPU5414", "U111")] == ("ws-user1", "1000000.000001") # type: ignore[attr-defined]
|
|
|
|
def test_same_user_second_session_archives_first(self) -> None:
|
|
bot, router, client = _make_bot()
|
|
|
|
bot._channel_sessions[("C01SAPU5414", "U111")] = ("ws-old", "1000000.000001") # type: ignore[attr-defined]
|
|
bot._subscribed_ws.add("ws-old") # type: ignore[attr-defined]
|
|
|
|
body = {"channel_id": "C01SAPU5414", "user_id": "U111"}
|
|
ack = AsyncMock()
|
|
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-new", True))
|
|
|
|
_run(bot._on_slash_command(ack, body)) # type: ignore[attr-defined]
|
|
|
|
assert bot._channel_sessions.get(("C01SAPU5414", "U111")) is not None # type: ignore[attr-defined]
|
|
ws_id, _ = bot._channel_sessions[("C01SAPU5414", "U111")] # type: ignore[attr-defined]
|
|
assert ws_id == "ws-new"
|
|
|
|
client.chat_postMessage.assert_awaited()
|
|
calls = [str(c) for c in client.chat_postMessage.call_args_list]
|
|
assert any("archived" in c for c in calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Approval ownership
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApprovalOwnership:
|
|
def test_non_owner_cannot_approve(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
|
|
bot, router, client = _make_bot()
|
|
ws_id = "ws-1"
|
|
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C01SAPU5414",
|
|
message_ts="111.222",
|
|
owner_user_id="U_OWNER",
|
|
cycle_id="corr-1",
|
|
)
|
|
|
|
body = {
|
|
"actions": [{"value": f"{ws_id}|corr-1"}],
|
|
"user": {"id": "U_OTHER"},
|
|
"container": {"channel_id": "C01SAPU5414", "message_ts": "111.222"},
|
|
}
|
|
|
|
_run(bot._resolve_approval(AsyncMock(), body, approved=True)) # type: ignore[attr-defined]
|
|
|
|
client.chat_postEphemeral.assert_awaited_once()
|
|
router.send_approval.assert_not_awaited()
|
|
|
|
def test_non_owner_cannot_deny(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
|
|
bot, router, client = _make_bot()
|
|
ws_id = "ws-1"
|
|
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C01SAPU5414",
|
|
message_ts="111.222",
|
|
owner_user_id="U_OWNER",
|
|
cycle_id="corr-1",
|
|
)
|
|
|
|
body = {
|
|
"actions": [{"value": f"{ws_id}|corr-1"}],
|
|
"user": {"id": "U_OTHER"},
|
|
"container": {"channel_id": "C01SAPU5414", "message_ts": "111.222"},
|
|
}
|
|
|
|
_run(bot._resolve_approval(AsyncMock(), body, approved=False)) # type: ignore[attr-defined]
|
|
|
|
client.chat_postEphemeral.assert_awaited_once()
|
|
router.send_approval.assert_not_awaited()
|
|
|
|
def test_owner_can_approve(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
|
|
bot, router, client = _make_bot()
|
|
ws_id = "ws-1"
|
|
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C01SAPU5414",
|
|
message_ts="111.222",
|
|
owner_user_id="U_OWNER",
|
|
cycle_id="corr-1",
|
|
)
|
|
|
|
body = {
|
|
"actions": [{"value": f"{ws_id}|corr-1"}],
|
|
"user": {"id": "U_OWNER"},
|
|
"container": {"channel_id": "C01SAPU5414", "message_ts": "111.222"},
|
|
}
|
|
|
|
_run(bot._resolve_approval(AsyncMock(), body, approved=True)) # type: ignore[attr-defined]
|
|
|
|
router.send_approval.assert_awaited_once_with(ws_id, "corr-1", approved=True)
|
|
client.chat_update.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _on_ws_event dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWsEventDispatch:
|
|
"""Tests for SSE event handling in the Slack bot."""
|
|
|
|
def _make_ws_bot(self) -> tuple[object, MagicMock]:
|
|
from turnstone.channels._routing import PolicyVerdict
|
|
from turnstone.channels.slack.bot import TurnstoneSlackBot
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
|
|
config = SlackConfig(
|
|
bot_token="xoxb-test",
|
|
app_token="xapp-test",
|
|
auto_approve=False,
|
|
)
|
|
storage = MagicMock()
|
|
router = MagicMock()
|
|
router.send_approval = AsyncMock()
|
|
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
|
router.resolve_user = AsyncMock(return_value="turnstone-user-1")
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
client.chat_update = AsyncMock(return_value={"ok": True})
|
|
client.conversations_history = AsyncMock(return_value={"ok": True, "messages": []})
|
|
|
|
with (
|
|
patch("turnstone.channels.slack.bot.AsyncApp", MagicMock()),
|
|
patch("turnstone.channels.slack.bot.AsyncWebClient", return_value=client),
|
|
patch(
|
|
"turnstone.channels.slack.bot.httpx.AsyncClient",
|
|
return_value=AsyncMock(),
|
|
),
|
|
):
|
|
bot = TurnstoneSlackBot(
|
|
config,
|
|
server_url="http://localhost:8080",
|
|
storage=storage,
|
|
)
|
|
bot.router = router # type: ignore[attr-defined]
|
|
bot._client = client # type: ignore[attr-defined]
|
|
bot.storage = None # type: ignore[attr-defined]
|
|
return bot, client
|
|
|
|
def test_content_event_creates_streaming_message(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ContentEvent
|
|
|
|
bot, _client = self._make_ws_bot()
|
|
|
|
event = ContentEvent(ws_id="ws-1", text="Hello")
|
|
route = SlackRoute(channel="C1", user_id="U1", thread_ts="123.456")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
assert "ws-1" in bot._streaming # type: ignore[attr-defined]
|
|
|
|
def test_stream_end_finalizes_streaming(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ContentEvent, StreamEndEvent
|
|
|
|
bot, _client = self._make_ws_bot()
|
|
route = SlackRoute(channel="C1", user_id="U1", thread_ts="123.456")
|
|
|
|
_run(bot._on_ws_event("ws-1", route, ContentEvent(ws_id="ws-1", text="Hi"))) # type: ignore[attr-defined]
|
|
assert "ws-1" in bot._streaming # type: ignore[attr-defined]
|
|
|
|
_run(bot._on_ws_event("ws-1", route, StreamEndEvent(ws_id="ws-1"))) # type: ignore[attr-defined]
|
|
assert "ws-1" not in bot._streaming # type: ignore[attr-defined]
|
|
|
|
def test_stream_end_no_streaming_is_noop(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import StreamEndEvent
|
|
|
|
bot, _client = self._make_ws_bot()
|
|
route = SlackRoute(channel="C1", user_id="U1", thread_ts="123.456")
|
|
|
|
_run(bot._on_ws_event("ws-1", route, StreamEndEvent(ws_id="ws-1"))) # type: ignore[attr-defined]
|
|
assert "ws-1" not in bot._streaming # type: ignore[attr-defined]
|
|
|
|
def test_error_event_posts_message(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ErrorEvent
|
|
|
|
bot, client = self._make_ws_bot()
|
|
route = SlackRoute(channel="C1", user_id="U1", thread_ts="123.456")
|
|
|
|
event = ErrorEvent(ws_id="ws-1", message="Something went wrong")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
client.chat_postMessage.assert_awaited_once()
|
|
text = client.chat_postMessage.call_args[1]["text"]
|
|
assert "Something went wrong" in text
|
|
|
|
def test_approve_request_auto_approve(self) -> None:
|
|
from turnstone.channels._routing import PolicyVerdict
|
|
from turnstone.channels.slack.bot import TurnstoneSlackBot
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ApproveRequestEvent
|
|
|
|
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test", auto_approve=True)
|
|
storage = MagicMock()
|
|
router = MagicMock()
|
|
router.send_approval = AsyncMock()
|
|
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
|
client = AsyncMock()
|
|
client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123"})
|
|
|
|
with (
|
|
patch("turnstone.channels.slack.bot.AsyncApp", MagicMock()),
|
|
patch("turnstone.channels.slack.bot.AsyncWebClient", return_value=client),
|
|
patch(
|
|
"turnstone.channels.slack.bot.httpx.AsyncClient",
|
|
return_value=AsyncMock(),
|
|
),
|
|
):
|
|
bot = TurnstoneSlackBot(config, server_url="http://localhost:8080", storage=storage)
|
|
bot.router = router # type: ignore[attr-defined]
|
|
bot._client = client # type: ignore[attr-defined]
|
|
bot.storage = None # type: ignore[attr-defined]
|
|
|
|
event = ApproveRequestEvent(
|
|
ws_id="ws-1", items=[{"func_name": "bash", "needs_approval": True}]
|
|
)
|
|
route = SlackRoute(channel="C1", user_id="U1", thread_ts="123.456")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
router.send_approval.assert_awaited_once_with("ws-1", "", approved=True)
|
|
|
|
def test_approve_request_sends_approval_buttons(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ApproveRequestEvent
|
|
|
|
bot, client = self._make_ws_bot()
|
|
|
|
event = ApproveRequestEvent(
|
|
ws_id="ws-1",
|
|
cycle_id="cyc-1",
|
|
items=[{"call_id": "c-1", "func_name": "bash", "needs_approval": True}],
|
|
)
|
|
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
client.chat_postMessage.assert_awaited_once()
|
|
call_kwargs = client.chat_postMessage.call_args[1]
|
|
assert "blocks" in call_kwargs
|
|
# Tracked under (ws_id, cycle_id) so concurrent cycles each get
|
|
# their own Slack message.
|
|
entry = bot._pending_approval[("ws-1", "cyc-1")] # type: ignore[attr-defined]
|
|
assert entry.owner_user_id == "U12345"
|
|
assert entry.cycle_id == "cyc-1"
|
|
assert entry.call_ids == frozenset({"c-1"})
|
|
|
|
def test_intent_verdict_updates_approval_message(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import IntentVerdictEvent
|
|
|
|
bot, client = self._make_ws_bot()
|
|
client.conversations_history = AsyncMock(
|
|
return_value={"ok": True, "messages": [{"blocks": []}]}
|
|
)
|
|
|
|
bot._pending_approval[("ws-1", "cyc-1")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C1",
|
|
message_ts="999.000",
|
|
owner_user_id="U12345",
|
|
cycle_id="cyc-1",
|
|
call_ids=frozenset({"c-1"}),
|
|
)
|
|
|
|
event = IntentVerdictEvent(
|
|
ws_id="ws-1",
|
|
call_id="c-1",
|
|
func_name="bash",
|
|
risk_level="high",
|
|
confidence=0.9,
|
|
intent_summary="Dangerous",
|
|
)
|
|
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
client.chat_update.assert_awaited_once()
|
|
|
|
def test_approval_resolved_clears_pending(self) -> None:
|
|
from turnstone.channels.slack.bot import PendingApproval
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
from turnstone.sdk.events import ApprovalResolvedEvent
|
|
|
|
bot, client = self._make_ws_bot()
|
|
bot._pending_approval[("ws-1", "cyc-9")] = PendingApproval( # type: ignore[attr-defined]
|
|
channel="C1",
|
|
message_ts="999.000",
|
|
owner_user_id="U12345",
|
|
cycle_id="cyc-9",
|
|
)
|
|
|
|
# Event WITHOUT a cycle_id (pre-multi-cycle server): the legacy
|
|
# fallback clears the ws's single tracked entry, as before.
|
|
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True)
|
|
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
|
|
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
|
|
assert not bot._pending_approval # type: ignore[attr-defined]
|
|
client.chat_update.assert_awaited_once()
|
|
|
|
def test_link_prefix_does_not_hijack_regular_prompt(self) -> None:
|
|
"""`/turnstone linking up the docs` must not misroute into
|
|
_handle_link with `"ing up the docs"` as the token."""
|
|
bot, router, client = _make_bot()
|
|
bot._handle_link = AsyncMock() # type: ignore[attr-defined]
|
|
# Force the linked-user gate to pass so the natural-language
|
|
# prompt can flow through to the session-start branch.
|
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-new", True))
|
|
body = {
|
|
"channel_id": "C01SAPU5414",
|
|
"user_id": "U111",
|
|
"text": "linking up the docs",
|
|
}
|
|
_run(bot._on_slash_command(AsyncMock(), body)) # type: ignore[attr-defined]
|
|
bot._handle_link.assert_not_awaited() # type: ignore[attr-defined]
|
|
|
|
def test_link_rate_limit_blocks_after_cap(self) -> None:
|
|
"""Sec-3: /turnstone link must throttle at _LINK_RATE_LIMIT/hour."""
|
|
from turnstone.channels.slack.bot import _LINK_RATE_LIMIT
|
|
|
|
bot, _router, client = _make_bot()
|
|
# Make the user already linked so _handle_link skips past the
|
|
# rate limit check would otherwise take a slot on a successful
|
|
# storage hit; we still want to exercise the throttle directly.
|
|
for _ in range(_LINK_RATE_LIMIT):
|
|
assert bot._allow_link_attempt("U111") # type: ignore[attr-defined]
|
|
# Next attempt is blocked.
|
|
assert not bot._allow_link_attempt("U111") # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notification tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNotificationTracking:
|
|
"""Tests for notification message tracking."""
|
|
|
|
def test_track_notification_stores_entry(self) -> None:
|
|
from turnstone.channels.slack.bot import TurnstoneSlackBot
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
|
|
storage = MagicMock()
|
|
with (
|
|
patch("turnstone.channels.slack.bot.AsyncApp", MagicMock()),
|
|
patch("turnstone.channels.slack.bot.AsyncWebClient"),
|
|
patch(
|
|
"turnstone.channels.slack.bot.httpx.AsyncClient",
|
|
return_value=AsyncMock(),
|
|
),
|
|
):
|
|
bot = TurnstoneSlackBot(config, server_url="http://localhost:8080", storage=storage)
|
|
|
|
route = SlackRoute(channel="C01SAPU5414", user_id="U12345", thread_ts="ts-123")
|
|
bot._track_notification("ts-123", "ws-1", route) # type: ignore[attr-defined]
|
|
assert bot._notify_ws_map["ts-123"] == ("ws-1", route) # type: ignore[attr-defined]
|
|
|
|
def test_track_notification_evicts_oldest(self) -> None:
|
|
from turnstone.channels.slack.bot import TurnstoneSlackBot
|
|
from turnstone.channels.slack.config import SlackConfig
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
|
|
storage = MagicMock()
|
|
with (
|
|
patch("turnstone.channels.slack.bot.AsyncApp", MagicMock()),
|
|
patch("turnstone.channels.slack.bot.AsyncWebClient"),
|
|
patch(
|
|
"turnstone.channels.slack.bot.httpx.AsyncClient",
|
|
return_value=AsyncMock(),
|
|
),
|
|
):
|
|
bot = TurnstoneSlackBot(config, server_url="http://localhost:8080", storage=storage)
|
|
|
|
bot._MAX_NOTIFY_TRACKING = 3 # type: ignore[attr-defined]
|
|
bot._notify_ws_map = { # type: ignore[attr-defined]
|
|
"ts-1": ("ws-1", SlackRoute(channel="C1", user_id="U1", thread_ts="ts-1")),
|
|
"ts-2": ("ws-2", SlackRoute(channel="C1", user_id="U2", thread_ts="ts-2")),
|
|
"ts-3": ("ws-3", SlackRoute(channel="C1", user_id="U3", thread_ts="ts-3")),
|
|
}
|
|
|
|
bot._track_notification( # type: ignore[attr-defined]
|
|
"ts-4",
|
|
"ws-4",
|
|
SlackRoute(channel="C1", user_id="U4", thread_ts="ts-4"),
|
|
)
|
|
|
|
assert "ts-4" in bot._notify_ws_map # type: ignore[attr-defined]
|
|
assert "ts-1" not in bot._notify_ws_map # type: ignore[attr-defined]
|
|
assert len(bot._notify_ws_map) <= 3 # type: ignore[attr-defined]
|
|
|
|
def test_send_notification_tracks_root_thread_ts(self) -> None:
|
|
from turnstone.channels.slack.routes import SlackRoute
|
|
|
|
bot, _router, client = _make_bot()
|
|
client.chat_postMessage = AsyncMock(
|
|
side_effect=[
|
|
{"ok": True, "ts": "111.222"},
|
|
{"ok": True, "ts": "111.333"},
|
|
]
|
|
)
|
|
|
|
msg_id = _run(
|
|
bot.send_notification( # type: ignore[attr-defined]
|
|
"C01SAPU5414:U12345",
|
|
"x" * 5000,
|
|
"ws-1",
|
|
)
|
|
)
|
|
|
|
assert msg_id == "111.222"
|
|
assert "111.222" in bot._notify_ws_map # type: ignore[attr-defined]
|
|
stored_ws, stored_route = bot._notify_ws_map["111.222"] # type: ignore[attr-defined]
|
|
assert stored_ws == "ws-1"
|
|
assert stored_route == SlackRoute(
|
|
channel="C01SAPU5414",
|
|
user_id="U12345",
|
|
thread_ts="111.222",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DM continuity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDmContinuity:
|
|
def test_dm_messages_reuse_same_workstream(self) -> None:
|
|
bot, router, _client = _make_bot()
|
|
router.get_or_create_workstream = AsyncMock(side_effect=[("ws-dm", True), ("ws-dm", False)])
|
|
|
|
event1 = _make_slack_event(
|
|
channel="D12345",
|
|
channel_type="im",
|
|
thread_ts="",
|
|
ts="1.000",
|
|
text="hello",
|
|
)
|
|
event2 = _make_slack_event(
|
|
channel="D12345",
|
|
channel_type="im",
|
|
thread_ts="1.000",
|
|
ts="1.111",
|
|
text="again",
|
|
)
|
|
|
|
_run(bot._handle_dm(event1, AsyncMock())) # type: ignore[attr-defined]
|
|
_run(bot._handle_dm(event2, AsyncMock())) # type: ignore[attr-defined]
|
|
|
|
assert router.get_or_create_workstream.await_count == 2
|
|
assert router.send_message.await_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestChannelCLI:
|
|
"""Tests for the channel CLI entry point with Slack args."""
|
|
|
|
def test_standby_without_adapter_token(self) -> None:
|
|
# No Discord/Slack token → standby: main() runs the gateway with zero
|
|
# adapters instead of exiting (which would crash-loop under
|
|
# `restart: unless-stopped`).
|
|
import sys
|
|
|
|
from turnstone.channels import cli
|
|
from turnstone.channels.cli import main
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
async def _fake_gateway(adapters, channel_app, storage, args): # type: ignore[no-untyped-def]
|
|
captured["adapters"] = adapters
|
|
|
|
with (
|
|
patch.object(sys, "argv", ["turnstone-channel"]),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("turnstone.core.storage._registry.init_storage"),
|
|
patch("turnstone.core.storage._registry.get_storage", return_value=MagicMock()),
|
|
patch("turnstone.channels._http.create_channel_app", return_value=MagicMock()),
|
|
patch.object(cli, "_run_gateway", _fake_gateway),
|
|
):
|
|
main() # must not raise SystemExit
|
|
|
|
assert captured["adapters"] == {}
|
|
|
|
def test_slack_requires_both_tokens(self) -> None:
|
|
import sys
|
|
|
|
from turnstone.channels.cli import main
|
|
|
|
with (
|
|
patch.object(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"turnstone-channel",
|
|
"--slack-token",
|
|
"xoxb-test",
|
|
"--server-url",
|
|
"http://localhost:8080",
|
|
],
|
|
),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
pytest.raises(SystemExit) as exc_info,
|
|
):
|
|
main()
|
|
|
|
assert "--slack-token and --slack-app-token must be provided together" in str(
|
|
exc_info.value
|
|
)
|
|
|
|
def test_slack_only_startup_creates_channel_app(self) -> None:
|
|
import sys
|
|
|
|
from turnstone.channels.cli import main
|
|
|
|
created_adapters: dict[str, object] = {}
|
|
|
|
class FakeSlackBot:
|
|
channel_type = "slack"
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeServer:
|
|
def __init__(self, _config) -> None:
|
|
pass
|
|
|
|
async def serve(self) -> None:
|
|
return None
|
|
|
|
def _fake_create_channel_app(adapters, storage, *, jwt_secret=""): # type: ignore[no-untyped-def]
|
|
created_adapters.update(adapters)
|
|
return MagicMock()
|
|
|
|
async def _fake_gather(*aws, return_exceptions=False): # type: ignore[no-untyped-def]
|
|
for aw in aws:
|
|
await aw
|
|
return []
|
|
|
|
storage = MagicMock()
|
|
storage.register_service = MagicMock()
|
|
storage.deregister_service = MagicMock()
|
|
|
|
with (
|
|
patch.object(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"turnstone-channel",
|
|
"--slack-token",
|
|
"xoxb-test",
|
|
"--slack-app-token",
|
|
"xapp-test",
|
|
"--server-url",
|
|
"http://localhost:8080",
|
|
],
|
|
),
|
|
patch("turnstone.core.storage._registry.init_storage"),
|
|
patch("turnstone.core.storage._registry.get_storage", return_value=storage),
|
|
patch(
|
|
"turnstone.channels._http.create_channel_app", side_effect=_fake_create_channel_app
|
|
),
|
|
patch("turnstone.channels._http._get_service_id", return_value="channel-test"),
|
|
patch("asyncio.gather", side_effect=_fake_gather),
|
|
patch("uvicorn.Config", return_value=MagicMock()),
|
|
patch("uvicorn.Server", FakeServer),
|
|
patch("turnstone.channels.slack.bot.TurnstoneSlackBot", FakeSlackBot),
|
|
):
|
|
main()
|
|
|
|
assert "slack" in created_adapters
|
|
assert len(created_adapters) == 1
|
|
|
|
def test_discord_and_slack_startup_creates_both_adapters(self) -> None:
|
|
import sys
|
|
|
|
from turnstone.channels.cli import main
|
|
|
|
created_adapters: dict[str, object] = {}
|
|
|
|
class FakeSlackBot:
|
|
channel_type = "slack"
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeDiscordBot:
|
|
channel_type = "discord"
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
class FakeServer:
|
|
def __init__(self, _config) -> None:
|
|
pass
|
|
|
|
async def serve(self) -> None:
|
|
return None
|
|
|
|
def _fake_create_channel_app(adapters, storage, *, jwt_secret=""): # type: ignore[no-untyped-def]
|
|
created_adapters.update(adapters)
|
|
return MagicMock()
|
|
|
|
async def _fake_gather(*aws, return_exceptions=False): # type: ignore[no-untyped-def]
|
|
for aw in aws:
|
|
await aw
|
|
return []
|
|
|
|
storage = MagicMock()
|
|
storage.register_service = MagicMock()
|
|
storage.deregister_service = MagicMock()
|
|
|
|
with (
|
|
patch.object(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"turnstone-channel",
|
|
"--discord-token",
|
|
"discord-test",
|
|
"--slack-token",
|
|
"xoxb-test",
|
|
"--slack-app-token",
|
|
"xapp-test",
|
|
"--server-url",
|
|
"http://localhost:8080",
|
|
],
|
|
),
|
|
patch("turnstone.core.storage._registry.init_storage"),
|
|
patch("turnstone.core.storage._registry.get_storage", return_value=storage),
|
|
patch(
|
|
"turnstone.channels._http.create_channel_app", side_effect=_fake_create_channel_app
|
|
),
|
|
patch("turnstone.channels._http._get_service_id", return_value="channel-test"),
|
|
patch("asyncio.gather", side_effect=_fake_gather),
|
|
patch("uvicorn.Config", return_value=MagicMock()),
|
|
patch("uvicorn.Server", FakeServer),
|
|
patch("turnstone.channels.slack.bot.TurnstoneSlackBot", FakeSlackBot),
|
|
patch("turnstone.channels.discord.bot.TurnstoneBot", FakeDiscordBot),
|
|
):
|
|
main()
|
|
|
|
assert "discord" in created_adapters
|
|
assert "slack" in created_adapters
|
|
assert len(created_adapters) == 2
|