feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
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 AsyncMock, 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"
|
||||
Reference in new issue
Block a user