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

225 lines
8.6 KiB
Python

import asyncio
import logging
from support_bot_light import commands, setup
from support_bot_light.config import Config
from tests.conftest import ROSTER_GROUP_ID, USER_ID, make_group
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
MARKER = {"supportBotLight": {"group": "roster"}}
async def test_creates_group_when_none_marked(api, caplog):
caplog.set_level(logging.INFO)
group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG)
assert group_id == ROSTER_GROUP_ID
profile = api.new_groups[0]
assert profile["displayName"] == "Invite roster"
assert profile["groupPreferences"]["directMessages"] == {"enable": "on"}
assert profile["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS)
assert api.group_custom_data == [(ROSTER_GROUP_ID, MARKER)]
assert api.links == [ROSTER_GROUP_ID] # created exactly once
assert api.group_links[ROSTER_GROUP_ID] in caplog.text
async def test_finds_existing_group_by_marker(api):
profile = {
"displayName": "renamed by a human",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
assert api.new_groups == [] # not recreated
assert api.profile_updates == [] # commands already match — no broadcast
async def test_found_group_with_link_logs_it_without_recreating(api, caplog):
caplog.set_level(logging.INFO)
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
assert api.links == [] # fetched, not recreated
assert "https://simplex.chat/contact#/?v=2&group=77" in caplog.text
async def test_found_group_without_link_recreates_and_logs_it(api, caplog):
# Crash window between marking the group and creating its link.
caplog.set_level(logging.INFO)
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
assert api.links == [77] # recovered by creating a new link
assert api.group_links[77] in caplog.text
async def test_group_link_get_failure_with_link_present_does_not_block_startup(api, caplog):
# The create fallback hits the unique link index. A missing link must not
# stop the bot starting.
caplog.set_level(logging.WARNING)
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
api.fail_on.add("api_get_group_link_str")
api.fail_on.add("api_create_group_link")
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
async def test_pushes_commands_when_they_differ(api):
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {"directMessages": {"enable": "on"}, "commands": []},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
await setup.ensure_roster_group(api, USER_ID, CONFIG)
assert len(api.profile_updates) == 1
group_id, sent = api.profile_updates[0]
assert group_id == 77
assert sent["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS)
async def test_pushed_profile_keeps_existing_display_name(api):
profile = {
"displayName": "renamed by a human",
"fullName": "",
"groupPreferences": {"directMessages": {"enable": "on"}, "commands": []},
}
api.groups.append(make_group(77, profile, custom_data=MARKER))
await setup.ensure_roster_group(api, USER_ID, CONFIG)
_, sent = api.profile_updates[0]
# Syncing commands must not silently rename a group the operator renamed.
assert sent["displayName"] == "renamed by a human"
async def test_ignores_groups_without_the_marker(api):
api.groups.append(make_group(88, {"displayName": "Invite roster", "fullName": ""}))
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
assert api.new_groups != [] # name match alone must not be trusted
async def test_ignores_groups_with_a_foreign_marker(api):
api.groups.append(
make_group(
88, {"displayName": "x", "fullName": ""}, custom_data={"otherBot": {"group": "roster"}}
)
)
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
assert api.new_groups != []
async def test_ignores_groups_with_the_wrong_marker_value(api):
# Right namespace, wrong marker.
api.groups.append(
make_group(
88,
{"displayName": "x", "fullName": ""},
custom_data={"supportBotLight": {"group": "archive"}},
)
)
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
assert api.new_groups != []
async def test_ignores_groups_with_a_non_dict_marker(api):
api.groups.append(
make_group(
88, {"displayName": "x", "fullName": ""}, custom_data={"supportBotLight": "roster"}
)
)
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
async def test_warns_and_picks_one_when_two_groups_are_marked(api, caplog):
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups += [
make_group(20, profile, custom_data=MARKER),
make_group(21, profile, custom_data=MARKER),
]
api.group_links[20] = "https://simplex.chat/#g20"
with caplog.at_level("WARNING"):
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 20
assert "2 groups carry the roster marker" in caplog.text
assert api.new_groups == []
async def test_a_group_the_bot_has_left_is_not_reused(api):
# Nothing would ever be delivered there, and the marker would keep a
# replacement from being created.
api.groups.append(
make_group(
77,
{"displayName": "old roster", "fullName": ""},
{"supportBotLight": {"group": "roster"}},
membership_status="removed",
)
)
group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG)
assert group_id != 77
assert api.new_groups # a live roster group was created instead
async def test_direct_messages_is_restored_when_an_owner_switches_it_off(api):
# api_create_member_contact fails without it, so /dm would fail forever with
# nothing to explain it.
profile = {
"displayName": "Invite roster",
"fullName": "",
"groupPreferences": {
"directMessages": {"enable": "off"},
"commands": commands.to_wire(commands.COMMANDS),
},
}
api.groups.append(make_group(77, profile, {"supportBotLight": {"group": "roster"}}))
await setup.ensure_roster_group(api, USER_ID, CONFIG)
pushed = api.profile_updates[-1][1]["groupPreferences"]
assert pushed["directMessages"] == {"enable": "on"}
assert pushed["commands"] == commands.to_wire(commands.COMMANDS)
async def test_a_profile_push_that_cannot_return_does_not_hang_startup(api, monkeypatch):
# The core's view queue is bounded and nothing drains it until the bot
# serves, so this write can block until it does.
monkeypatch.setattr(setup, "PROFILE_PUSH_TIMEOUT", 0.05)
async def never_returns(group_id, profile):
await asyncio.sleep(10)
monkeypatch.setattr(api, "api_update_group_profile", never_returns)
api.groups.append(make_group(77, {"displayName": "r", "fullName": ""}, MARKER))
group_id = await asyncio.wait_for(setup.ensure_roster_group(api, USER_ID, CONFIG), 2)
assert group_id == 77