mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-04 12:44:48 +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
147 lines
5.7 KiB
Python
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"])
|