Files
shandGitHub 68916aa90a feat: add simplex-support-bot-light (#7378)
* 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
2026-08-17 09:12:47 +01:00

535 lines
19 KiB
Python

import pytest
from simplex_chat import ChatCommandError
from support_bot_light import business, messages
from support_bot_light.config import Config
from support_bot_light.context import BotContext
from tests.conftest import (
ROSTER_GROUP_ID,
USER_ID,
join_roster_group,
make_contact,
make_group,
make_member,
)
BUSINESS_GROUP_ID = 42
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
@pytest.fixture
def ctx(api):
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
def event(name="Alex"):
return {
"type": "acceptingBusinessRequest",
"groupInfo": make_group(BUSINESS_GROUP_ID, {"displayName": name, "fullName": ""}),
}
async def test_adds_active_roster_members(ctx, api):
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(3, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}),
]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [
(BUSINESS_GROUP_ID, 2, "owner"),
(BUSINESS_GROUP_ID, 1, "owner"),
]
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added Narasimha, sh")]
@pytest.mark.parametrize("status", ["rejected", "removed", "left", "deleted", "unknown"])
async def test_does_not_add_someone_who_has_left_the_roster_group(ctx, api, status):
# The departure event and a queued business request arrive in whatever order
# the core dispatches them, so an active mark is not authority on its own:
# this is what stops a departed member reading a conversation started after
# they went.
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
join_roster_group(api)
api.members[ROSTER_GROUP_ID][1]["memberStatus"] = status
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added sh")]
async def test_reconcile_does_not_add_someone_who_has_left_the_roster_group(ctx, api):
api.contacts.append(
make_contact(
1, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.members[ROSTER_GROUP_ID][0]["memberStatus"] = "removed"
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
await business.reconcile_chats(ctx)
assert api.added == []
async def test_skips_members_already_in_the_group(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="invited")]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
async def test_does_not_skip_members_who_left(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
async def test_empty_roster_logs_and_adds_nobody(ctx, api):
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
async def test_pending_only_roster_counts_as_empty(ctx, api):
api.contacts.append(
make_contact(1, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}})
)
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
async def test_one_failure_does_not_block_the_rest(ctx, api):
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
calls: list[int] = []
original = api.api_add_member
async def flaky(group_id, contact_id, member_role):
calls.append(contact_id)
if contact_id == 2:
raise ChatCommandError("nope", {"type": "chatCmdError"})
return await original(group_id, contact_id, member_role)
api.api_add_member = flaky
join_roster_group(api)
await business.on_business_request(ctx, event())
assert sorted(calls) == [1, 2] # both attempted
assert api.sent[-1][1] == "Connected: Alex → added sh (failed: Narasimha)"
async def test_roster_read_failure_logs_and_adds_nobody(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_contacts")
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
async def test_member_list_failure_logs_and_adds_nobody(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_members")
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
async def test_uses_configured_member_role(api):
ctx = BotContext(
api=api,
user_id=USER_ID,
roster_group_id=ROSTER_GROUP_ID,
config=Config("S", "./x", "hi", "R", "admin"),
)
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "admin")]
async def test_reconcile_repairs_a_chat_left_half_added_by_a_crash(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_reconcile_is_idempotent_when_everyone_is_present(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="complete")]
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
# The chat was left unmarked, so a crash took the roster group's record of
# this customer with it; the repair puts it back even with nothing to add.
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
async def test_reconcile_skips_non_business_groups(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": ROSTER_GROUP_ID,
"groupProfile": {"displayName": "roster", "fullName": ""},
"localDisplayName": "roster",
"membership": make_member(99, name="bot", status="complete"),
}
)
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
async def test_reconcile_skips_a_chat_the_bot_has_left(ctx, api):
# The core keeps the group row after removal; adding into it would fail on
# every start, and the customer is no longer the bot's to serve.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="removed"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
assert api.group_custom_data == []
async def test_reconcile_failure_does_not_stop_startup(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_groups")
join_roster_group(api)
await business.reconcile_chats(ctx) # must not raise
async def test_reconcile_skips_a_chat_whose_roster_pass_already_ran(ctx, api):
# Someone who joins the roster later must not be back-filled into every
# conversation the bot has ever handled.
api.contacts.append(
make_contact(
1, "newbie", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": {"supportBotLight": {"rostered": True}},
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
assert api.sent == []
async def test_reconcile_does_not_re_invite_someone_who_left_a_chat(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": {"supportBotLight": {"rostered": True}},
}
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
async def test_on_business_request_marks_the_chat_as_rostered(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.group_custom_data[-1] == (
BUSINESS_GROUP_ID,
{"supportBotLight": {"rostered": True}},
)
async def test_reconcile_marks_chats_even_with_an_empty_roster(ctx, api):
# Otherwise the chat stays unmarked and a later restart back-fills whoever
# joined the roster in the meantime.
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
await business.reconcile_chats(ctx)
assert api.group_custom_data[-1] == (
BUSINESS_GROUP_ID,
{"supportBotLight": {"rostered": True}},
)
assert api.added == []
async def test_a_failed_mark_does_not_report_nobody_added(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_set_group_custom_data")
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_a_chat_where_every_add_failed_is_retried_next_start(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_add_member")
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.group_custom_data == [] # not marked, so repair will revisit it
async def test_a_chat_left_unmarked_is_not_back_filled_with_a_later_roster(ctx, api):
# The one bit that keeps a new roster member out of old conversations.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.fail_on.add("api_send_text_message")
await business.on_business_request(ctx, event())
api.fail_on.clear()
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": api.group_custom_data[-1][1],
}
)
api.contacts.append(
make_contact(
2, "newbie", {"supportBotLight": {"roster": "active", "since": "y"}}, connected=True
)
)
join_roster_group(api)
api.added.clear()
await business.reconcile_chats(ctx)
assert api.added == []
async def test_a_chat_is_marked_even_when_its_line_never_went_out(ctx, api):
# An unmarked chat is repaired by every later start with the roster of the
# day, so withholding the marker to preserve a log line would hand this
# customer's conversation to whoever joins the roster next.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.fail_on.add("api_send_text_message")
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.group_custom_data[-1][1] == {"supportBotLight": {"rostered": True}}
async def test_a_failed_mark_still_reports_the_repaired_chat(ctx, api):
# The marker is re-derived on the next start; the report is not, because
# the event that would have produced it was consumed before the crash.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
api.fail_on.add("api_set_group_custom_data")
await business.reconcile_chats(ctx)
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_an_unfinished_repair_is_retried_by_the_queued_event(ctx, api):
# Nothing was added and the chat was left unmarked, so the event that the
# startup pass raced is the only remaining chance to finish it.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
api.fail_on.add("api_add_member")
await business.reconcile_chats(ctx)
assert api.group_custom_data == [] # not marked: the repair failed
api.fail_on.clear()
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
async def test_repair_does_not_re_report_a_chat_to_the_event_handler(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
posts_after_repair = len(api.sent)
await business.on_business_request(ctx, event())
assert len(api.sent) == posts_after_repair # the queued event adds no line
# Only that one event is swallowed: the same customer coming back later
# must be handled like anyone else.
api.members[BUSINESS_GROUP_ID] = []
await business.on_business_request(ctx, event())
assert len(api.sent) == posts_after_repair + 1