mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 02:12:25 +00:00
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
This commit is contained in:
34 files changed
+5109
No files matched your search
@@ -0,0 +1,146 @@
|
||||
"""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"])
|
||||
Reference in new issue
Block a user