Files

418 lines
17 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import hashlib
import hmac
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.api.webhooks.slack as slack_webhook
import app.services.slack_events as slack_events
from app.api.webhooks.slack import _handle_lifecycle_event
from app.channels import get_adapter
from app.channels.slack import SlackAdapter, build_home_view, verify_slack_signature
from app.core.config import settings
def make_event(event: dict, team_id="T111", event_id="Ev1"):
return {"type": "event_callback", "team_id": team_id, "event_id": event_id, "event": event}
MENTION_EVENT = make_event({
"type": "app_mention",
"user": "U42",
"text": "<@UBOT> what are your opening hours?",
"channel": "C9",
"ts": "1720000000.000100",
})
DM_EVENT = make_event({
"type": "message",
"channel_type": "im",
"user": "U42",
"text": "hello there",
"channel": "D7",
"ts": "1720000001.000200",
})
@pytest.fixture
def adapter():
return SlackAdapter()
def test_registry_resolves_slack():
assert isinstance(get_adapter("slack"), SlackAdapter)
def test_parse_mention_threads_conversation(adapter):
messages = adapter.parse_inbound(MENTION_EVENT)
assert len(messages) == 1
m = messages[0]
assert m.external_account_id == "T111"
assert m.external_conversation_id == "C9:1720000000.000100"
assert m.text == "what are your opening hours?" # mention stripped
assert m.external_message_id == "Ev1"
def test_parse_mention_reply_keeps_thread(adapter):
payload = make_event({**MENTION_EVENT["event"], "thread_ts": "1719990000.000001"})
m = adapter.parse_inbound(payload)[0]
assert m.external_conversation_id == "C9:1719990000.000001"
def test_parse_dm_uses_channel_as_conversation(adapter):
m = adapter.parse_inbound(DM_EVENT)[0]
assert m.external_conversation_id == "D7"
assert m.text == "hello there"
def test_bot_and_subtype_events_ignored(adapter):
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "bot_id": "B1"})) == []
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "subtype": "message_changed"})) == []
assert adapter.parse_inbound(make_event({"type": "reaction_added"})) == []
def test_split_conversation():
assert SlackAdapter._split_conversation("C9:123.456") == ("C9", "123.456")
assert SlackAdapter._split_conversation("D7") == ("D7", None)
def test_format_outbound_mrkdwn(adapter):
assert adapter.format_outbound("**bold** text") == "*bold* text"
class TestLifecycleEvents:
"""app_uninstalled / tokens_revoked must drop the workspace's credentials,
but only when it's really our bot token that died."""
@pytest.fixture
def repo(self):
repo = MagicMock()
repo.get_by_external_id.return_value = MagicMock(name="account")
repo.delete.return_value = True
return repo
def test_uninstall_deletes_account(self, repo):
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
repo.get_by_external_id.assert_called_once_with("slack", "T111")
repo.delete.assert_called_once()
def test_bot_token_revoked_deletes_account(self, repo):
payload = make_event({"type": "tokens_revoked", "tokens": {"bot": ["B123"]}})
_handle_lifecycle_event(payload, repo)
repo.delete.assert_called_once()
def test_user_token_revoke_keeps_account(self, repo):
"""We only ever store a bot token — a user-token revoke leaves the
workspace working, so deleting it would disconnect a healthy install."""
payload = make_event({"type": "tokens_revoked", "tokens": {"oauth": ["U123"]}})
_handle_lifecycle_event(payload, repo)
repo.delete.assert_not_called()
def test_unknown_team_is_noop(self, repo):
repo.get_by_external_id.return_value = None
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
repo.delete.assert_not_called()
def test_missing_team_id_is_noop(self, repo):
payload = make_event({"type": "app_uninstalled"}, team_id="")
_handle_lifecycle_event(payload, repo)
repo.get_by_external_id.assert_not_called()
repo.delete.assert_not_called()
def test_failed_delete_does_not_raise(self, repo):
"""delete() reports failure by returning False; Slack still needs a 200."""
repo.delete.return_value = False
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
repo.delete.assert_called_once()
def test_lifecycle_events_are_not_messages(self, adapter):
"""They must stay out of the message path — parse_inbound yields nothing,
which is why the route resolves the team off the envelope instead."""
assert adapter.parse_inbound(make_event({"type": "app_uninstalled"})) == []
assert adapter.parse_inbound(make_event({"type": "tokens_revoked"})) == []
class TestSignature:
def _sign(self, body: bytes, secret: str, ts: str) -> str:
return "v0=" + hmac.new(secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256).hexdigest()
def test_valid(self, monkeypatch):
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
ts = str(int(time.time()))
body = b'{"type":"event_callback"}'
headers = {"x-slack-request-timestamp": ts,
"x-slack-signature": self._sign(body, "sek", ts)}
assert verify_slack_signature(headers, body) is True
def test_wrong_secret_or_missing(self, monkeypatch):
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
ts = str(int(time.time()))
body = b"{}"
assert verify_slack_signature(
{"x-slack-request-timestamp": ts,
"x-slack-signature": self._sign(body, "other", ts)}, body) is False
assert verify_slack_signature({}, body) is False
def test_replay_rejected(self, monkeypatch):
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
old_ts = str(int(time.time()) - 3600)
body = b"{}"
headers = {"x-slack-request-timestamp": old_ts,
"x-slack-signature": self._sign(body, "sek", old_ts)}
assert verify_slack_signature(headers, body) is False
def test_unconfigured_secret_fails_closed(self, monkeypatch):
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "")
assert verify_slack_signature({"x-slack-request-timestamp": "1",
"x-slack-signature": "v0=x"}, b"{}") is False
class TestSlackProfileAndTyping:
@pytest.mark.asyncio
async def test_fetch_profile_resolves_real_name(self, adapter, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
acc = MagicMock()
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
monkeypatch.setattr("app.channels.slack.slack_api", AsyncMock(return_value={
"ok": True, "user": {"real_name": "Ada Lovelace",
"profile": {"display_name": "ada", "email": "ada@acme.com"}}}))
prof = await adapter.fetch_profile(acc, "U09UPKP7")
assert prof["name"] == "Ada Lovelace"
assert prof["email"] == "ada@acme.com"
@pytest.mark.asyncio
async def test_fetch_profile_handles_error(self, adapter, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
monkeypatch.setattr("app.channels.slack.slack_api",
AsyncMock(return_value={"ok": False, "error": "missing_scope"}))
assert await adapter.fetch_profile(MagicMock(), "U0") == {}
@pytest.mark.asyncio
async def test_typing_placeholder_then_edit(self, adapter, monkeypatch):
from unittest.mock import MagicMock
import app.channels.slack as slk
acc = MagicMock(); acc.id = "acc1"
conv = MagicMock(external_conversation_id="D7")
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
calls = []
async def fake_api(method, token, payload):
calls.append((method, payload))
if method == "chat.postMessage":
return {"ok": True, "ts": "111.222"}
if method == "chat.update":
return {"ok": True, "ts": payload["ts"]}
return {"ok": False}
monkeypatch.setattr(slk, "slack_api", fake_api)
await adapter.send_typing(acc, conv) # posts placeholder
result = await adapter.send_text(acc, conv, "Here is your answer") # edits it
assert result.ok and result.external_message_id == "111.222"
methods = [m for m, _ in calls]
assert methods == ["chat.postMessage", "chat.update"] # not a second post
# placeholder consumed
assert slk._typing_placeholders.get(("acc1", "D7")) is None
@pytest.mark.asyncio
async def test_placeholder_deleted_when_update_fails(self, adapter, monkeypatch):
"""When chat.update fails, the stale '_typing…_' is deleted and a fresh
message is posted — no dangling placeholder beside the reply."""
from unittest.mock import MagicMock
import app.channels.slack as slk
acc = MagicMock(); acc.id = "acc9"
conv = MagicMock(external_conversation_id="D9")
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
calls = []
async def fake_api(method, token, payload):
calls.append((method, payload))
if method == "chat.postMessage":
return {"ok": True, "ts": "555.666"}
if method == "chat.update":
return {"ok": False, "error": "cant_update_message"}
return {"ok": True}
monkeypatch.setattr(slk, "slack_api", fake_api)
await adapter.send_typing(acc, conv)
result = await adapter.send_text(acc, conv, "The answer")
methods = [m for m, _ in calls]
assert methods == ["chat.postMessage", "chat.update", "chat.delete", "chat.postMessage"]
assert result.ok
assert slk._typing_placeholders.get(("acc9", "D9")) is None
def test_prune_placeholders_drops_stale(self, monkeypatch):
import app.channels.slack as slk
slk._typing_placeholders.clear()
slk._typing_placeholders[("a", "c1")] = ("1.1", 1000.0)
slk._typing_placeholders[("a", "c2")] = ("2.2", 1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 1)
slk._prune_placeholders(1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 2)
assert ("a", "c1") not in slk._typing_placeholders # stale, pruned
assert ("a", "c2") in slk._typing_placeholders # fresh, kept
slk._typing_placeholders.clear()
def test_parse_dm_with_thread_keys_by_thread(adapter):
"""Assistant-sidebar messages are DMs carrying the assistant thread ts — they
key by thread so the reply posts back into that thread."""
ev = make_event({"type": "message", "channel_type": "im", "user": "U42",
"text": "hi", "channel": "D7", "ts": "1720000002.0003",
"thread_ts": "1719999999.0001"})
m = adapter.parse_inbound(ev)[0]
assert m.external_conversation_id == "D7:1719999999.0001"
DASH = "https://app.replypier.site"
class TestHomeView:
def test_intro_and_dashboard_link_always_present(self):
import json
blob = json.dumps(build_home_view(None, DASH))
assert "AI customer support" in blob # what it does, up top
assert DASH in blob # dashboard link
assert "How to use me" in blob
def test_no_agent_shows_connect_prompt(self):
import json
v = build_home_view(None, DASH)
assert v["type"] == "home"
assert "No agent connected" in json.dumps(v)
def test_connected_agent_card_and_truncation(self):
import json
v = build_home_view({"name": "Support", "is_active": True,
"instruction": "x" * 300, "photo_url": None}, DASH)
blob = json.dumps(v)
assert "Connected agent" in blob and "Support" in blob
section_texts = [b["text"]["text"] for b in v["blocks"] if b.get("type") == "section"]
long_text = next(t for t in section_texts if t.startswith("x"))
assert long_text.endswith("…") and len(long_text) <= 250
class TestAppSurfaceDispatch:
"""Webhook fan-out: the right event schedules the right background handler."""
def _repo(self, active=True):
account = MagicMock(); account.id = "acc1"; account.is_active = active
repo = MagicMock(); repo.get_by_external_id.return_value = account
return repo
def test_messages_tab_open_schedules_welcome(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "app_home_opened", "user": "U1", "channel": "D1", "tab": "messages"},
"T1", self._repo(), bg)
args = bg.add_task.call_args[0]
assert args[0] is slack_webhook.deliver_home_welcome
assert args[1:] == ("acc1", "U1", "D1")
def test_missing_tab_defaults_to_welcome(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "app_home_opened", "user": "U1", "channel": "D1"}, "T1", self._repo(), bg)
assert bg.add_task.call_args[0][0] is slack_webhook.deliver_home_welcome
def test_home_tab_open_schedules_home_publish(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "app_home_opened", "user": "U1", "tab": "home"}, "T1", self._repo(), bg)
args = bg.add_task.call_args[0]
assert args[0] is slack_webhook.publish_agent_home and args[1:] == ("acc1", "U1")
def test_assistant_started_schedules_handler(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "assistant_thread_started",
"assistant_thread": {"channel_id": "D2", "thread_ts": "111.222"}},
"T1", self._repo(), bg)
args = bg.add_task.call_args[0]
assert args[0] is slack_webhook.handle_assistant_thread_started
assert args[1:] == ("acc1", "D2", "111.222")
def test_context_changed_is_noop(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "assistant_thread_context_changed"}, "T1", self._repo(), bg)
bg.add_task.assert_not_called()
def test_inactive_team_is_noop(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "app_home_opened", "user": "U1", "tab": "messages"},
"T1", self._repo(active=False), bg)
bg.add_task.assert_not_called()
def test_missing_team_id_is_noop(self):
bg = MagicMock()
slack_webhook._dispatch_app_surface_event(
{"type": "app_home_opened", "user": "U1"}, "", self._repo(), bg)
bg.add_task.assert_not_called()
class TestHomeWelcomeDelivery:
def _patch_common(self, monkeypatch):
db = MagicMock()
monkeypatch.setattr(slack_events, "SessionLocal", lambda: db)
account = MagicMock(); account.is_active = True
repo = MagicMock(); repo.get_by_id.return_value = account
monkeypatch.setattr(slack_events, "ChannelAccountRepository", lambda d: repo)
monkeypatch.setattr(slack_events, "_assigned_agent", lambda d, a: None)
monkeypatch.setattr(slack_events.SlackAdapter, "_access_token", staticmethod(lambda a: "tok"))
api = AsyncMock()
monkeypatch.setattr(slack_events, "slack_api", api)
return api
@pytest.mark.asyncio
async def test_welcome_sent_once(self, monkeypatch):
api = self._patch_common(monkeypatch)
redis = MagicMock()
redis.set.side_effect = [True, None] # first claims the slot, second is taken
monkeypatch.setattr(slack_events, "get_redis", lambda: redis)
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
assert api.await_count == 1
assert api.await_args[0][0] == "chat.postMessage"
assert api.await_args[0][2]["channel"] == "D1"
@pytest.mark.asyncio
async def test_no_channel_no_send(self, monkeypatch):
api = self._patch_common(monkeypatch)
monkeypatch.setattr(slack_events, "get_redis", lambda: None)
await slack_events.deliver_home_welcome("acc1", "U1", "")
api.assert_not_awaited()
@pytest.mark.asyncio
async def test_assistant_started_sets_status_prompts_and_welcome(self, monkeypatch):
self._patch_common(monkeypatch)
status = AsyncMock(); prompts = AsyncMock()
monkeypatch.setattr(slack_events, "set_assistant_status", status)
monkeypatch.setattr(slack_events, "set_suggested_prompts", prompts)
api = AsyncMock()
monkeypatch.setattr(slack_events, "slack_api", api)
await slack_events.handle_assistant_thread_started("acc1", "D2", "111.222")
status.assert_awaited_once()
prompts.assert_awaited_once()
api.assert_awaited_once()
assert api.await_args[0][0] == "chat.postMessage"
assert api.await_args[0][2].get("thread_ts") == "111.222"