mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-27 02:49:52 +00:00
* feat: add simplex-support-bot-light * feat: package simplex-support-bot-light for docker * docs: document simplex-support-bot-light * fix: make a missing config actionable and bounded * docs: note the attached compose Ctrl+C behaviour * refactor: use the library for startup and custom data * refactor: drop the display-name workaround * style: shorten the startup comment * build: build the core and the library into the image * docs: correct where the image type rule comes from * fix: log what the core said a command got wrong * build: run the container as the operator's uid * docs: keep the container uid in .env * docs: tighten the README
313 lines
9.7 KiB
Python
313 lines
9.7 KiB
Python
import pytest
|
|
from simplex_chat import Bot, BotProfile, SqliteDb
|
|
from simplex_chat.core import ChatAPIError
|
|
|
|
from support_bot_light import handlers, health
|
|
from support_bot_light.__main__ import (
|
|
_register,
|
|
_run,
|
|
_serve,
|
|
bot_profile,
|
|
build_bot,
|
|
startup_error,
|
|
)
|
|
from support_bot_light.config import Config, Health
|
|
from support_bot_light.context import BotContext
|
|
from tests.conftest import (
|
|
ROSTER_GROUP_ID,
|
|
USER_ID,
|
|
make_group,
|
|
make_group_message,
|
|
make_member,
|
|
)
|
|
|
|
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
|
OTHER_GROUP_ID = 99
|
|
|
|
|
|
def plain_bot() -> Bot:
|
|
return Bot(
|
|
profile=BotProfile(display_name="Support"),
|
|
db=SqliteDb(file_prefix="./unused"),
|
|
welcome="hi",
|
|
)
|
|
|
|
|
|
def registered(api) -> Bot:
|
|
bot = plain_bot()
|
|
ctx = BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
|
_register(bot, ctx)
|
|
return bot
|
|
|
|
|
|
def test_registers_all_four_commands(api):
|
|
bot = registered(api)
|
|
keywords = [names for names, _predicate, _handler in bot._command_handlers]
|
|
assert keywords == [("dm",), ("list",), ("leave",), ("help",)]
|
|
|
|
|
|
def test_registers_connection_and_business_events(api):
|
|
bot = registered(api)
|
|
assert set(bot._event_handlers) == {
|
|
"acceptingBusinessRequest",
|
|
"contactConnected",
|
|
"contactSndReady",
|
|
"deletedMember",
|
|
"leftMember",
|
|
}
|
|
|
|
|
|
def test_commands_match_in_the_roster_group(api):
|
|
bot = registered(api)
|
|
msg = make_group_message(api, make_member(1), "/dm", group_id=ROSTER_GROUP_ID)
|
|
_names, predicate, _handler = bot._command_handlers[0]
|
|
assert predicate(msg) is True
|
|
|
|
|
|
def test_commands_do_not_match_in_other_groups(api):
|
|
# A /dm typed inside a business chat must not be acted on.
|
|
bot = registered(api)
|
|
msg = make_group_message(api, make_member(1), "/dm", group_id=OTHER_GROUP_ID)
|
|
_names, predicate, _handler = bot._command_handlers[0]
|
|
assert predicate(msg) is False
|
|
|
|
|
|
def test_bot_profile_carries_display_name_and_image():
|
|
profile = bot_profile(
|
|
Config("Support", "./x", "hi", "R", "owner", image="data:image/png;base64,AAA")
|
|
)
|
|
assert profile.display_name == "Support"
|
|
assert profile.image == "data:image/png;base64,AAA"
|
|
|
|
|
|
def test_bot_profile_without_image():
|
|
assert bot_profile(CONFIG).image is None
|
|
|
|
|
|
@pytest.mark.parametrize("index,keyword", [(0, "dm"), (1, "list"), (2, "leave"), (3, "help")])
|
|
def test_every_command_is_scoped_to_the_roster_group(api, index, keyword):
|
|
# A /list answered in a business chat would show the roster to a customer.
|
|
bot = registered(api)
|
|
names, predicate, _handler = bot._command_handlers[index]
|
|
assert names == (keyword,)
|
|
inside = make_group_message(api, make_member(1), f"/{keyword}", group_id=ROSTER_GROUP_ID)
|
|
outside = make_group_message(api, make_member(1), f"/{keyword}", group_id=OTHER_GROUP_ID)
|
|
assert predicate(inside) is True
|
|
assert predicate(outside) is False
|
|
|
|
|
|
async def test_registered_handlers_call_the_matching_handler(api, monkeypatch):
|
|
# Registration bookkeeping alone would not catch /dm being wired to leave().
|
|
bot = registered(api)
|
|
called: list[str] = []
|
|
|
|
def spy(name):
|
|
async def handler(_ctx, _msg):
|
|
called.append(name)
|
|
|
|
return handler
|
|
|
|
for name in ("dm", "list_roster", "leave", "help_cmd"):
|
|
monkeypatch.setattr(handlers, name, spy(name))
|
|
for keywords, _predicate, handler in bot._command_handlers:
|
|
await handler(make_group_message(api, make_member(1), f"/{keywords[0]}"), None)
|
|
assert called == ["dm", "list_roster", "leave", "help_cmd"]
|
|
|
|
|
|
def test_a_taken_display_name_is_explained():
|
|
# The core reports it as a bare errorStore; the cause is in the store error.
|
|
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "duplicateName"}})
|
|
assert "bot.display_name" in startup_error(e)
|
|
|
|
|
|
def test_any_other_chat_error_keeps_its_detail():
|
|
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "userNotFound"}})
|
|
assert "userNotFound" in startup_error(e)
|
|
|
|
|
|
def test_a_rejected_command_is_quoted_as_the_core_wrote_it():
|
|
# The core puts what the caller did wrong in the message, and the tag says
|
|
# nothing; printing the raw dict instead would bury it.
|
|
e = ChatAPIError(
|
|
"chat command error: error",
|
|
{"type": "error", "errorType": {"type": "commandError", "message": "Profile image"}},
|
|
)
|
|
assert startup_error(e) == "Profile image"
|
|
|
|
|
|
def test_an_error_without_detail_is_rendered_plainly():
|
|
assert startup_error(ValueError("no active user after start")) == "no active user after start"
|
|
|
|
|
|
def test_the_bot_opens_a_business_address():
|
|
# Without these two the address yields direct chats that nothing handles:
|
|
# acceptingBusinessRequest never fires and no roster is ever added.
|
|
bot = build_bot(CONFIG)
|
|
assert bot._business_address is True
|
|
assert bot._auto_accept is True
|
|
assert bot._welcome == "hi"
|
|
|
|
|
|
def test_the_bot_does_not_apply_its_profile_while_starting():
|
|
# The name the core will accept is only knowable from the database, which
|
|
# nothing can read until the client has started. _apply_profile does it.
|
|
assert build_bot(CONFIG)._update_profile is False
|
|
|
|
|
|
class FakeBot:
|
|
"""A Bot stand-in for _serve: an async context manager with an api."""
|
|
|
|
def __init__(self, api, sync_error: Exception | None = None):
|
|
self.api = api
|
|
self.profile = BotProfile(display_name="Support")
|
|
self.served = 0
|
|
self.syncs = 0
|
|
self.sync_error = sync_error
|
|
self.signal_handlers = 0
|
|
self._command_handlers = []
|
|
self._event_handlers = {}
|
|
self.stop_requested = False
|
|
self.stopped = False
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_exc):
|
|
return False
|
|
|
|
def install_signal_handlers(self):
|
|
self.signal_handlers += 1
|
|
|
|
async def sync_profile(self) -> bool:
|
|
self.syncs += 1
|
|
if self.sync_error is not None:
|
|
raise self.sync_error
|
|
return True
|
|
|
|
def on_command(self, *_names, **_kw):
|
|
def register(handler):
|
|
self._command_handlers.append(handler)
|
|
return handler
|
|
|
|
return register
|
|
|
|
def on_event(self, tag):
|
|
def register(handler):
|
|
self._event_handlers.setdefault(tag, []).append(handler)
|
|
return handler
|
|
|
|
return register
|
|
|
|
async def serve_forever(self):
|
|
self.served += 1
|
|
|
|
def stop(self):
|
|
self.stopped = True
|
|
|
|
|
|
def serve_api(api):
|
|
"""The fake api with the calls _serve makes before serving."""
|
|
|
|
async def api_get_active_user():
|
|
return {"userId": USER_ID, "localDisplayName": "Support"}
|
|
|
|
api.api_get_active_user = api_get_active_user
|
|
api.group_links[ROSTER_GROUP_ID] = "https://example.invalid/g#x"
|
|
api.groups.append(
|
|
make_group(
|
|
ROSTER_GROUP_ID,
|
|
{"displayName": "Invite roster", "fullName": ""},
|
|
{"supportBotLight": {"group": "roster"}},
|
|
)
|
|
)
|
|
return api
|
|
|
|
|
|
async def test_serve_wires_the_handlers_and_serves(api):
|
|
bot = FakeBot(serve_api(api))
|
|
await _serve(CONFIG, bot)
|
|
assert bot.served == 1
|
|
assert len(bot._command_handlers) == 4 # nothing is delivered without these
|
|
assert set(bot._event_handlers) == {
|
|
"acceptingBusinessRequest",
|
|
"contactConnected",
|
|
"contactSndReady",
|
|
"deletedMember",
|
|
"leftMember",
|
|
}
|
|
|
|
|
|
async def test_serve_reads_the_group_listing_once(api):
|
|
# It is the largest thing startup marshals and grows with every customer.
|
|
bot = FakeBot(serve_api(api))
|
|
calls = {"n": 0}
|
|
original = api.api_list_groups
|
|
|
|
async def counted(user_id, **kw):
|
|
calls["n"] += 1
|
|
return await original(user_id, **kw)
|
|
|
|
api.api_list_groups = counted
|
|
await _serve(CONFIG, bot)
|
|
assert calls["n"] == 2 # one for discovery, one shared by both passes
|
|
|
|
|
|
async def test_serve_does_not_begin_serving_after_a_signal(api):
|
|
bot = FakeBot(serve_api(api))
|
|
bot.stop_requested = True
|
|
await _serve(CONFIG, bot)
|
|
assert bot.served == 0
|
|
|
|
|
|
async def test_serve_closes_the_health_endpoint_afterwards(api):
|
|
config = Config("Support", "./x", "hi", "Invite roster", "owner", health=Health("127.0.0.1", 0))
|
|
bot = FakeBot(serve_api(api))
|
|
servers: list = []
|
|
original = health.serve
|
|
|
|
async def spy(ctx, cfg):
|
|
server = await original(ctx, cfg)
|
|
servers.append(server)
|
|
return server
|
|
|
|
health.serve = spy
|
|
try:
|
|
await _serve(config, bot)
|
|
finally:
|
|
health.serve = original
|
|
assert servers and not servers[0].is_serving()
|
|
|
|
|
|
async def test_the_bot_serves_after_a_refused_rename(api, caplog):
|
|
# The core keeps display names unique; a refused one is not a reason to
|
|
# leave customers unanswered.
|
|
refused = ChatAPIError("x", {"storeError": {"type": "duplicateName"}})
|
|
bot = FakeBot(serve_api(api), sync_error=refused)
|
|
await _serve(CONFIG, bot)
|
|
assert bot.served == 1
|
|
assert "bot.display_name" in caplog.text
|
|
|
|
|
|
async def test_the_profile_is_applied_after_start(api, monkeypatch):
|
|
bot = FakeBot(serve_api(api))
|
|
await _serve(CONFIG, bot)
|
|
assert bot.syncs == 1
|
|
|
|
|
|
async def test_run_installs_signal_handlers_before_starting(monkeypatch):
|
|
# Startup runs migrations and address creation; a signal there would
|
|
# otherwise kill the process mid-write.
|
|
order: list[str] = []
|
|
bot = FakeBot(None)
|
|
|
|
def build(_config):
|
|
return bot
|
|
|
|
async def serve(_config, b):
|
|
order.append(f"serve:{b.signal_handlers}")
|
|
|
|
monkeypatch.setattr("support_bot_light.__main__.build_bot", build)
|
|
monkeypatch.setattr("support_bot_light.__main__._serve", serve)
|
|
await _run(CONFIG)
|
|
assert order == ["serve:1"]
|