Files
simplex-chat/apps/simplex-support-bot-light/src/support_bot_light/setup.py
T
sh 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

147 lines
5.7 KiB
Python

"""Find or create the roster group, and keep its command menu in sync."""
from __future__ import annotations
import asyncio
import logging
from simplex_chat import ChatApi, ChatError
from simplex_chat.types import T
from . import commands, roster
from .config import Config
log = logging.getLogger(__name__)
GROUP_MARKER = "roster"
JOIN_ROLE: T.GroupMemberRole = "member"
# api_update_group_profile broadcasts, and the core's view queue is bounded
# (tbqSize in Mobile.hs) with a blocking write. Nothing drains that queue until
# the bot serves, so after enough downtime this call cannot return. The bot must
# start anyway: the write completes once the queue drains, and a stale menu is a
# cosmetic problem next to a process that never gets there.
PROFILE_PUSH_TIMEOUT = 30.0
def is_roster_group(group: T.GroupInfo) -> bool:
"""Whether this is a roster group the bot is still in.
api_list_groups keeps groups the bot has left or been removed from. Without
the membership test the marker on a dead group would be chosen on every
start: no command would ever arrive, nothing could be posted, and the marker
would keep a replacement from being created.
"""
mark = (group.get("customData") or {}).get(roster.NAMESPACE)
if not isinstance(mark, dict) or mark.get("group") != GROUP_MARKER:
return False
return roster.in_group(group["membership"])
def _preferences() -> T.GroupPreferences:
return {
"directMessages": {"enable": "on"},
"commands": commands.to_wire(commands.COMMANDS),
}
async def _get_or_create_group_link(api: ChatApi, group_id: int) -> str | None:
"""The group's join link, creating one if it doesn't exist yet.
A link can be missing if the process died between marking the group and
creating the link on a previous run — that must not leave the group
permanently unjoinable.
`api_get_group_link_str` also fails for reasons other than "no link
exists" — if that happens while a link is actually present, the fallback
create hits the group's unique link index and raises too. A missing link
must never block startup, so that failure is logged and swallowed rather
than left to propagate out of `ensure_roster_group`.
"""
try:
return await api.api_get_group_link_str(group_id)
except ChatError:
pass
try:
return await api.api_create_group_link(group_id, JOIN_ROLE)
except ChatError:
log.warning(
"Could not get or create a join link for roster group %s", group_id, exc_info=True
)
return None
async def ensure_roster_group(api: ChatApi, user_id: int, config: Config) -> int:
"""Return the roster group id, creating the group on first run.
The group is identified by a marker in its custom data, not by name, so an
operator renaming it in the client doesn't cause a second group to appear.
"""
marked = [g for g in await api.api_list_groups(user_id) if is_roster_group(g)]
if len(marked) > 1:
# Reachable when two instances share a database, or after a database is
# restored. Members of the group not chosen here are talking to a bot
# that ignores them, so say which one won.
log.warning(
"%d groups carry the roster marker (%s); using %s",
len(marked),
", ".join(str(g["groupId"]) for g in marked),
marked[0]["groupId"],
)
if marked:
group = marked[0]
try:
await _sync_preferences(api, group)
except ChatError:
# The menu is a convenience; the commands work when typed. The core
# requires owner rights to update the profile, so an operator who
# demotes the bot would otherwise brick every later start.
log.warning("could not update the command menu", exc_info=True)
group_id = group["groupId"]
log.info("Roster group: %s:%s", group_id, group["localDisplayName"])
else:
profile: T.GroupProfile = {
"displayName": config.group_name,
"fullName": "",
"groupPreferences": _preferences(),
}
group = await api.api_new_group(user_id, profile)
group_id = group["groupId"]
await api.api_set_group_custom_data(group_id, {roster.NAMESPACE: {"group": GROUP_MARKER}})
log.info("Roster group created: %s", group_id)
link = await _get_or_create_group_link(api, group_id)
if link is not None:
log.info("Roster group link (share with the people who should answer):\n%s", link)
return group_id
async def _sync_preferences(api: ChatApi, group: T.GroupInfo) -> None:
"""Restore the preferences the roster group needs, only when they differ.
Both matter: without `commands` there is no menu, and without
`directMessages` the core refuses to create a member contact, so `/dm`
fails with nothing to explain it. An owner can switch either off in a
client, so neither can be assumed to survive from creation.
`api_update_group_profile` broadcasts to every member, so a no-op update is
traffic for everyone in the group.
"""
profile = group.get("groupProfile") or {}
prefs = profile.get("groupPreferences") or {}
desired = _preferences()
if all(prefs.get(key) == value for key, value in desired.items()):
return
updated: T.GroupProfile = {**profile, "groupPreferences": {**prefs, **desired}}
try:
await asyncio.wait_for(
api.api_update_group_profile(group["groupId"], updated), PROFILE_PUSH_TIMEOUT
)
except TimeoutError:
log.warning(
"Roster group preferences are still being written after %ss; continuing",
PROFILE_PUSH_TIMEOUT,
)
return
log.info("Restored roster group preferences on %s", group["groupId"])