mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-27 22:34:51 +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
535 lines
19 KiB
Python
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
|