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:
sh authored and GitHub committed 2026-08-17 09:12:47 +01:00
1 parent 55d18efe24
commit 68916aa90a
34 files changed
+5109

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,211 @@
"""Entry point: load config, start the bot, wire handlers, serve."""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import stat
import sys
from pathlib import Path
from simplex_chat import Bot, BotProfile, ChatError, SqliteDb
from simplex_chat.core import ChatInitError
from simplex_chat.types import CEvt
from . import business, commands, handlers, health, setup
from .config import Config, ConfigError, load_config
from .context import BotContext
log = logging.getLogger("support_bot_light")
# storeError tag the core returns when a display name is already in use.
DUPLICATE_NAME = "duplicateName"
# ChatInitError is raised only while opening the database, which is why it
# belongs here rather than in the per-command guards elsewhere.
STARTUP_ERRORS = (ChatError, ChatInitError)
def _register(bot: Bot, ctx: BotContext) -> None:
"""Register handlers. Commands are scoped to the roster group, so the same
keyword typed in a business chat falls through and is ignored."""
group_id = ctx.roster_group_id
@bot.on_command(commands.DM, group_id=group_id)
async def _dm(msg, _cmd):
await handlers.dm(ctx, msg)
@bot.on_command(commands.LIST, group_id=group_id)
async def _list(msg, _cmd):
await handlers.list_roster(ctx, msg)
@bot.on_command(commands.LEAVE, group_id=group_id)
async def _leave(msg, _cmd):
await handlers.leave(ctx, msg)
@bot.on_command(commands.HELP, group_id=group_id)
async def _help(msg, _cmd):
await handlers.help_cmd(ctx, msg)
@bot.on_event("acceptingBusinessRequest")
async def _business(evt: CEvt.AcceptingBusinessRequest):
await business.on_business_request(ctx, evt)
@bot.on_event("contactConnected")
async def _connected(evt: CEvt.ContactConnected):
await handlers.contact_ready(ctx, evt["contact"]["contactId"])
@bot.on_event("contactSndReady")
async def _snd_ready(evt: CEvt.ContactSndReady):
await handlers.contact_ready(ctx, evt["contact"]["contactId"])
@bot.on_event("deletedMember")
async def _deleted_member(evt: CEvt.DeletedMember):
await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["deletedMember"])
@bot.on_event("leftMember")
async def _left_member(evt: CEvt.LeftMember):
await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["member"])
def startup_error(e: Exception) -> str:
"""What the operator can act on, from an exception that names only a tag.
The core reports a display name already taken by a contact or group as a
bare `errorStore`, and the detail the bot needs is in the store error.
"""
if getattr(e, "store_error_type", None) == DUPLICATE_NAME:
return (
"bot.display_name is already taken in this database by a contact, a "
"group or a past customer; the core keeps every display name unique. "
"Choose another name."
)
command_error = getattr(e, "command_error", None)
if command_error is not None:
return command_error
chat_error = getattr(e, "chat_error", None)
return f"{e} {chat_error}" if chat_error else str(e)
def bot_profile(config: Config) -> BotProfile:
return BotProfile(display_name=config.display_name, image=config.image)
def build_bot(config: Config) -> Bot:
"""The bot's identity and address settings.
business_address is what makes a connection open a group the roster can be
added to; without it every customer would get a plain direct chat and the
bot would have nothing to do.
The profile is applied after the client starts, not by the startup sync, so
that a name the core refuses does not stop the bot. See `_apply_profile`.
"""
return Bot(
profile=bot_profile(config),
db=SqliteDb(file_prefix=config.db_prefix),
welcome=config.welcome,
business_address=True,
auto_accept=True,
update_profile=False,
# The library logs peer display names verbatim; the bot sanitises every
# name it renders itself, and this is the one path that bypasses it.
log_contacts=False,
)
async def _run(config: Config) -> None:
bot = build_bot(config)
# Before the client starts: a signal during migrations would otherwise hit
# the default disposition and kill the process mid-write.
bot.install_signal_handlers()
await _serve(config, bot)
async def _apply_profile(bot: Bot) -> None:
"""Apply the configured profile once the database can be reached.
The core refuses a display name another contact or group holds, and the
profile update broadcasts to every contact, so it is the startup step most
likely to fail. Answering customers matters more than a name or an avatar.
"""
try:
await bot.sync_profile()
except ChatError as e:
log.error("%s", startup_error(e))
log.warning("Serving without applying the profile change.")
async def _serve(config: Config, bot: Bot) -> None:
# Not bot.run(): handlers are scoped with group_id=, which is unknown until
# the roster group is resolved after start.
async with bot:
user = await bot.api.api_get_active_user()
if user is None:
raise RuntimeError("no active user after start")
user_id = user["userId"]
await _apply_profile(bot)
roster_group_id = await setup.ensure_roster_group(bot.api, user_id, config)
ctx = BotContext(
api=bot.api,
user_id=user_id,
roster_group_id=roster_group_id,
config=config,
)
_register(bot, ctx)
groups = await bot.api.api_list_groups(user_id)
await handlers.reconcile_roster(ctx, groups)
await business.reconcile_chats(ctx, groups)
if bot.stop_requested:
# A signal arrived during startup; unwind rather than begin serving.
log.info("stopped during startup")
return
server = await health.serve(ctx, config.health) if config.health else None
try:
await bot.serve_forever()
finally:
if server is not None:
server.close()
await server.wait_closed()
def main() -> int:
parser = argparse.ArgumentParser(prog="support-bot-light")
parser.add_argument("--config", type=Path, default=Path("config.toml"))
args = parser.parse_args()
if not logging.getLogger().handlers:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
)
# The core creates its databases with the process umask, and they hold the
# bot's identity keys. Docker mounts a 0700 directory; a manual install
# would otherwise put them in the working directory at 0644.
os.umask(stat.S_IRWXG | stat.S_IRWXO)
try:
config = load_config(args.config)
except ConfigError as e:
log.error("%s", e)
return 2
try:
asyncio.run(_run(config))
except ConfigError as e:
# Raised past load_config only by the health endpoint, which cannot know
# its port is taken until it binds.
log.error("%s", e)
return 2
except STARTUP_ERRORS as e:
# Startup rejections the core only reports at first use, such as a
# database it will not open.
log.error("%s", startup_error(e))
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,180 @@
"""Incoming business chats: add the roster, log the invite."""
from __future__ import annotations
import logging
from simplex_chat import ChatError
from simplex_chat.types import CEvt, T
from . import messages, roster
from .context import BotContext
from .text import safe_name
log = logging.getLogger(__name__)
# Written to a business chat's custom data once its roster pass has run.
ROSTERED = "rostered"
def _rostered(group: T.GroupInfo) -> bool:
mark = (group.get("customData") or {}).get(roster.NAMESPACE)
return isinstance(mark, dict) and mark.get(ROSTERED) is True
async def _mark_rostered(ctx: BotContext, group: T.GroupInfo) -> None:
"""Record that this chat has had its roster pass, preserving other keys.
Without this, startup repair cannot tell a chat a crash left half-finished
from one that was completed before the roster changed — and would add
people who joined the roster later to every conversation the bot has ever
handled.
"""
existing = (group.get("customData") or {}).get(roster.NAMESPACE)
mark: dict[str, object] = dict(existing) if isinstance(existing, dict) else {}
mark[ROSTERED] = True
await ctx.api.api_merge_group_custom_data(group, roster.NAMESPACE, mark)
async def _mark(ctx: BotContext, group: T.GroupInfo) -> None:
"""Mark the chat, containing the failure: the next start re-derives it."""
try:
await _mark_rostered(ctx, group)
except ChatError:
log.warning("could not mark business chat %s as rostered", group["groupId"], exc_info=True)
async def _add_missing(
ctx: BotContext, group_id: int, entries: list[roster.RosterEntry]
) -> tuple[list[str], list[str]]:
"""Add every entry not already in the group. Returns (added, failed)."""
present = await roster.contact_ids_in_group(ctx.api, group_id)
added: list[str] = []
failed: list[str] = []
for entry in entries:
if entry.contact_id in present:
continue
try:
# Final role in one call: promoting a pending invitee re-sends the
# invitation.
await ctx.api.api_add_member(group_id, entry.contact_id, ctx.config.member_role)
added.append(entry.name)
except ChatError:
log.exception("failed adding %s to business chat %s", entry.name, group_id)
failed.append(entry.name)
return added, failed
async def _roster_for_chats(ctx: BotContext) -> list[roster.RosterEntry]:
"""Active roster members who are still in the roster group.
Revocation is driven by an event, and the core delivers a queued business
request before a queued departure just as readily as after it, so the mark
alone would let somebody who has left read a conversation started after they
went. Membership of the roster group is the access-control boundary, so it
is what decides: read for each incoming chat, and once per startup pass.
"""
entries = await roster.active(ctx.api, ctx.user_id)
if not entries:
return []
present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id)
return [e for e in entries if e.contact_id in present]
async def reconcile_chats(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None:
"""Add active roster members to business chats that are missing them.
Adding members is the only step with no second chance: it is driven by an
event delivered once, so a crash part-way through the loop would leave that
customer permanently short of the roster.
Only chats whose roster pass never completed are touched. A chat that was
finished before someone joined the roster is left alone: `/dm` promises to
add you to chats "from now on", and back-filling would hand every past
customer conversation to whoever joined the roster most recently.
"""
try:
entries = await _roster_for_chats(ctx)
if groups is None:
groups = await ctx.api.api_list_groups(ctx.user_id)
except ChatError:
log.warning("could not reconcile business chats on startup", exc_info=True)
return
repaired = 0
for group in groups:
if "businessChat" not in group or not roster.in_group(group["membership"]):
continue
if _rostered(group):
continue
group_id = group["groupId"]
try:
added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries)
except ChatError:
log.warning("could not reconcile business chat %s", group_id, exc_info=True)
continue
repaired += 1
log.info("finished the roster pass for business chat %s on startup", group_id)
# Reported even when nobody had to be added: the chat was left unmarked,
# so the crash took the roster group's record of that customer with it.
await ctx.post_to_roster(_report(_customer_of(group), entries, added, failed))
if added or not failed:
# Left unmarked means the repair did not finish; the queued event
# should be allowed to retry it in this session.
ctx.repaired.add(group_id)
# Marked even with an empty roster: the pass has run for this chat,
# and leaving it unmarked would back-fill whoever joins later.
await _mark(ctx, group)
if repaired:
log.info("finished %d business chats left incomplete by a restart", repaired)
def _report(
customer: str, entries: list[roster.RosterEntry], added: list[str], failed: list[str]
) -> str:
"""The roster group's record of one business chat."""
if not entries:
return messages.EMPTY_ROSTER_LOG.format(customer=customer)
if not added and not failed:
return messages.NOBODY_NEW_LOG.format(customer=customer)
return messages.invite_log(customer, added, failed)
def _customer_of(group: T.GroupInfo) -> str:
return safe_name((group.get("groupProfile") or {}).get("displayName") or "")
async def on_business_request(ctx: BotContext, evt: CEvt.AcceptingBusinessRequest) -> None:
"""Add every active roster member to a new business chat, then log it."""
group = evt["groupInfo"]
group_id = group["groupId"]
if group_id in ctx.repaired:
# Startup repair already ran for this chat and reported it; the queued
# event would otherwise log the same customer a second time.
ctx.repaired.discard(group_id)
return
# For a business chat the group's display name is the customer's own
# profile string, which the core does not sanitise.
customer = _customer_of(group)
# A failure before anything is added must still reach the roster group,
# which is the operator's only visibility.
try:
entries = await _roster_for_chats(ctx)
added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries)
except ChatError:
log.exception("failed reading roster for business chat %s", group_id)
await ctx.post_to_roster(messages.BUSINESS_FAILED_LOG.format(customer=customer))
return
await ctx.post_to_roster(_report(customer, entries, added, failed))
# Marked even when the line above failed to send. The marker records that
# the pass ran, and an unmarked chat is repaired by every later start with
# the roster of the day — so withholding it to preserve one log line would
# hand a past customer's conversation to whoever joins the roster next.
# Not marked when every add failed and none succeeded: that chat has no
# roster at all, so the next start should retry rather than skip it.
if added or not failed:
await _mark(ctx, group)
@@ -0,0 +1,37 @@
"""The bot's command menu: declarations plus conversion to group-preference wire dicts."""
from __future__ import annotations
from collections.abc import Sequence
from simplex_chat import BotCommand
from simplex_chat.types import T
DM = "dm"
LIST = "list"
LEAVE = "leave"
HELP = "help"
COMMANDS: tuple[BotCommand, ...] = (
BotCommand(keyword=DM, label="Add me to incoming chats"),
BotCommand(keyword=LIST, label="Who gets invited"),
BotCommand(keyword=LEAVE, label="Stop adding me"),
BotCommand(keyword=HELP, label="How this works"),
)
def to_wire(commands: Sequence[BotCommand]) -> list[T.ChatBotCommand]:
"""Convert declarations to `groupPreferences.commands` entries."""
wire: list[T.ChatBotCommand] = []
for c in commands:
entry: T.ChatBotCommand_command = {
"type": "command",
"keyword": c.keyword,
"label": c.label,
}
# Omitted rather than empty: the client sends on tap for Nothing, but
# pastes for Just "".
if c.params is not None:
entry["params"] = c.params
wire.append(entry)
return wire
@@ -0,0 +1,207 @@
"""Load and validate `config.toml`."""
from __future__ import annotations
import base64
import stat
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, get_args
from simplex_chat.types import T
DEFAULT_MEMBER_ROLE: T.GroupMemberRole = "owner"
MEMBER_ROLES: tuple[str, ...] = get_args(T.GroupMemberRole)
# maxProfileImageSize in src/Simplex/Chat/Library/Commands.hs. Measured against
# the whole data URI, not the raw file.
MAX_PROFILE_IMAGE_SIZE = 12500
# Raw bytes that still fit once base64 and the "data:image/png;base64," prefix
# are added. Checked before the file is read.
MAX_IMAGE_BYTES = (MAX_PROFILE_IMAGE_SIZE - 22) // 4 * 3
# The welcome is sent as a chat message, so it is held below the core's wire
# limit (maxEncodedMsgLength) with room to spare rather than at it.
MAX_WELCOME_BYTES = 12000
# On unless switched off, so a deployment is monitorable without being
# configured for it. Loopback, because the endpoint has no authentication.
DEFAULT_HEALTH_HOST = "127.0.0.1"
DEFAULT_HEALTH_PORT = 8080
MAX_PORT = 65535
# Tag in the data:image/<tag>;base64, prefix. The core accepts any "data:" string;
# the clients strip only the png and jpg prefixes, so jpeg renders as nothing.
IMAGE_EXTENSION_TAGS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpg"}
class ConfigError(ValueError):
"""`config.toml` is missing, malformed, or has an invalid value."""
@dataclass(frozen=True, slots=True)
class Health:
"""Where the monitoring endpoint listens."""
host: str
port: int
# True when the config names the port. A port the operator chose has to
# work; the default must never be what keeps the bot from starting.
configured: bool = False
@dataclass(frozen=True, slots=True)
class Config:
"""Validated settings loaded from `config.toml`."""
display_name: str
db_prefix: str
welcome: str
group_name: str
member_role: T.GroupMemberRole
image: str | None = None
health: Health | None = None
def load_config(path: Path) -> Config:
"""Read and validate `config.toml` at `path`, raising `ConfigError` on any problem."""
try:
raw = tomllib.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as e:
# Names, not a command: under Docker this directory is mounted
# read-only, so the copy is made on the host.
template = path.with_name(path.name + ".example")
hint = f" — copy {template.name} to {path.name} and edit it" if template.exists() else ""
raise ConfigError(f"config file not found: {path}{hint}") from e
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"invalid TOML in {path}: {e}") from e
except UnicodeDecodeError as e:
raise ConfigError(f"config file is not UTF-8: {path}") from e
except OSError as e:
raise ConfigError(f"config file could not be read ({path}): {e}") from e
bot = _section(raw, "bot")
roster = _section(raw, "roster")
role = roster.get("member_role", DEFAULT_MEMBER_ROLE)
if role not in MEMBER_ROLES:
raise ConfigError(
f"roster.member_role must be one of {', '.join(MEMBER_ROLES)}, got {role!r}"
)
return Config(
display_name=_text(bot, "bot", "display_name"),
db_prefix=_text(bot, "bot", "db_prefix"),
welcome=_bounded_text(bot, "bot", "welcome", MAX_WELCOME_BYTES),
group_name=_text(roster, "roster", "group_name"),
member_role=role,
image=_image(bot, path.parent),
health=_health(raw),
)
def _health(raw: dict[str, Any]) -> Health | None:
"""Where the endpoint listens, or None when `health.enabled` switches it off."""
health = raw.get("health", {})
if not isinstance(health, dict):
raise ConfigError("[health] must be a section")
enabled = health.get("enabled", True)
if not isinstance(enabled, bool):
raise ConfigError(f"health.enabled must be true or false, got {enabled!r}")
if not enabled:
return None
port = health.get("port", DEFAULT_HEALTH_PORT)
# bool is an int, and TOML has booleans.
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= MAX_PORT:
raise ConfigError(f"health.port must be an integer between 1 and {MAX_PORT}, got {port!r}")
host = health.get("host", DEFAULT_HEALTH_HOST)
if not isinstance(host, str) or not host.strip():
raise ConfigError("health.host must be a non-empty string")
# Either key means the operator chose where it listens, and a bind failure
# there is a misconfiguration rather than a coincidence.
return Health(host=host, port=port, configured=bool({"host", "port"} & health.keys()))
def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:
section = raw.get(name)
if not isinstance(section, dict):
raise ConfigError(f"missing [{name}] section")
return section
def _text(section: dict[str, Any], section_name: str, key: str) -> str:
value = section.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{section_name}.{key} must be a non-empty string")
return value
def _bounded_text(section: dict[str, Any], section_name: str, key: str, max_bytes: int) -> str:
value = _text(section, section_name, key)
if len(value.encode()) > max_bytes:
raise ConfigError(
f"{section_name}.{key} is too long: {len(value.encode())} bytes exceeds "
f"the {max_bytes} the core will send; shorten it"
)
return value
def _image(bot: dict[str, Any], config_dir: Path) -> str | None:
"""Encode `bot.image` (a file path) as a profile-image data URI, or `None`
if the key is absent. Relative paths resolve against `config_dir` — the
directory containing `config.toml` — not the process's working directory.
"""
if "image" not in bot:
return None
value = _text(bot, "bot", "image")
image_path = Path(value)
if not image_path.is_absolute():
image_path = config_dir / image_path
extension = image_path.suffix.lower()
tag = IMAGE_EXTENSION_TAGS.get(extension)
if tag is None:
supported = ", ".join(sorted(IMAGE_EXTENSION_TAGS))
raise ConfigError(
f"bot.image has unsupported extension {extension!r} ({image_path}); "
f"supported extensions: {supported}"
)
# Inspect before reading: a FIFO would block startup indefinitely and a
# character device such as /dev/zero would exhaust memory.
try:
info = image_path.stat()
except FileNotFoundError as e:
raise ConfigError(f"bot.image file not found: {image_path}") from e
except OSError as e:
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
if not stat.S_ISREG(info.st_mode):
raise ConfigError(f"bot.image is not a regular file: {image_path}")
if info.st_size > MAX_IMAGE_BYTES:
raise ConfigError(
f"bot.image is too large: {info.st_size} bytes exceeds the {MAX_IMAGE_BYTES} "
f"a {MAX_PROFILE_IMAGE_SIZE}-character data URI can hold; shrink the image "
"(a 128x128 avatar) and try again"
)
try:
data = image_path.read_bytes()
except OSError as e:
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
# The core rejects an empty image file rather than broadcasting a profile
# with an undecodable data URI.
if not data:
raise ConfigError(f"bot.image file is empty: {image_path}")
encoded = base64.b64encode(data).decode("ascii")
data_uri = f"data:image/{tag};base64,{encoded}"
if len(data_uri) > MAX_PROFILE_IMAGE_SIZE:
raise ConfigError(
f"bot.image is too large: encoded size {len(data_uri)} exceeds the "
f"{MAX_PROFILE_IMAGE_SIZE}-character limit the core enforces on profile "
"images; shrink the image (e.g. to a 96x96 or 128x128 avatar) and try again"
)
return data_uri
@@ -0,0 +1,36 @@
"""Everything the handlers need, resolved once at startup."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from simplex_chat import ChatApi, ChatError
from .config import Config
log = logging.getLogger(__name__)
@dataclass(slots=True)
class BotContext:
"""API handle plus the ids and config resolved during startup."""
api: ChatApi
user_id: int
roster_group_id: int
config: Config
# Business chats repaired by the startup pass, so the queued event for the
# same chat does not report the customer a second time.
repaired: set[int] = field(default_factory=set)
async def post_to_roster(self, text: str) -> None:
"""Send a message to the roster group.
Never raises: this is the operator's visibility channel, and a failure
to report an event must not also discard the event that caused it.
"""
try:
await self.api.api_send_text_message(["group", self.roster_group_id], text)
except ChatError:
log.warning("could not post to the roster group: %s", text, exc_info=True)
@@ -0,0 +1,300 @@
"""Command, connection and membership handlers, plus the roster catch-up pass."""
from __future__ import annotations
import functools
import logging
from collections.abc import Awaitable, Callable
from simplex_chat import ChatError, Message
from simplex_chat.types import T
from . import messages, roster, setup
from .context import BotContext
from .text import safe_name
log = logging.getLogger(__name__)
def _reply_on_error(fn: Callable[[BotContext, Message], Awaitable[None]]):
"""Turn a failed command into a visible reply instead of silence."""
@functools.wraps(fn)
async def wrapper(ctx: BotContext, msg: Message) -> None:
try:
await fn(ctx, msg)
except ChatError:
log.exception("%s failed", fn.__name__)
await msg.reply(messages.COMMAND_FAILED)
return wrapper
async def _contact_of(ctx: BotContext, member: T.GroupMember) -> T.Contact | None:
"""The sender's direct contact, resolved against current state.
`memberContactId` in the message payload is a snapshot taken when the core
built the chat item. `api_create_member_contact` sets that column, so two
commands sent in quick succession both carry the pre-`/dm` value of `None`
and would otherwise be treated as having no contact at all.
"""
contact_id = member.get("memberContactId")
if contact_id is None:
for m in await ctx.api.api_list_members(ctx.roster_group_id):
if m["groupMemberId"] == member["groupMemberId"]:
contact_id = m.get("memberContactId")
break
if contact_id is None:
return None
return await roster.find_contact(ctx.api, ctx.user_id, contact_id)
def group_sender(msg: Message) -> T.GroupMember | None:
"""The member who sent a group message, or None if it isn't a group receive."""
chat_dir = msg.chat_item["chatItem"]["chatDir"]
if chat_dir.get("type") != "groupRcv":
return None
return chat_dir.get("groupMember")
@_reply_on_error
async def dm(ctx: BotContext, msg: Message) -> None:
"""Put the sender on the roster, sending a contact request first if needed."""
member = group_sender(msg)
if member is None:
return
contact = await _contact_of(ctx, member)
if contact is not None:
# api_create_member_contact sets memberContactId before the person has
# accepted, so an existing contact is not necessarily usable.
entry = roster.entry_of(contact)
if roster.contact_usable(contact):
if entry is not None and entry.state == roster.ACTIVE:
await msg.reply(messages.ALREADY_ACTIVE)
return
since = entry.since if entry else roster.utc_now()
await roster.mark(ctx.api, contact, roster.ACTIVE, since)
await msg.reply(messages.ADDED)
# Every other route to active announces; without this the operator's
# log misses arrivals that took the fast path.
name = roster.contact_name(contact)
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=name))
return
if contact.get("contactGroupMemberId") is None:
if roster.accept_started(contact):
# Already accepted; the connection is still completing. Marked
# here too: contact_ready promotes a pending mark and does
# nothing without one, so ACCEPTING would promise a roster place
# that never arrives.
since = entry.since if entry else roster.utc_now()
await roster.mark(ctx.api, contact, roster.PENDING, since)
await msg.reply(messages.ACCEPTING)
return
if roster.awaiting_accept(contact):
# They connected to us from the group rather than accepting our
# request. Accept it and mark them pending; contactConnected
# then promotes them exactly as it would the other way round.
await ctx.api.api_accept_member_contact(contact["contactId"])
since = entry.since if entry else roster.utc_now()
await roster.mark(ctx.api, contact, roster.PENDING, since)
await msg.reply(messages.ACCEPTING)
return
if roster.connecting(contact):
# The core clears contactGroupMemberId when the peer accepts,
# well before the connection reports ready, so this shape is
# also a handshake in progress. Reporting it as gone would send
# the member to CONNECTION_LOST's advice, and connecting
# directly there tears down the connection that was completing.
since = entry.since if entry else roster.utc_now()
await roster.mark(ctx.api, contact, roster.PENDING, since)
await msg.reply(messages.CONNECTING)
return
# The core clears this once a member contact has connected, and
# api_send_member_contact_invitation requires it, so the handshake
# cannot be re-driven from this side. The mark is left alone: an
# active one renders under "Not reachable", which is the truth.
await msg.reply(messages.CONNECTION_LOST)
return
# Reaching here means contactGroupMemberId is still set, which the core
# clears on connect: the person never completed the handshake, so an
# active mark is stale.
if entry is None or entry.state != roster.PENDING:
since = entry.since if entry else roster.utc_now()
await roster.mark(ctx.api, contact, roster.PENDING, since)
if contact.get("contactGrpInvSent"):
# The core rejects a second invitation; the person has simply not
# accepted the first one yet.
await msg.reply(messages.STILL_PENDING)
return
# First send failed. api_create_member_contact would raise "member
# contact already exists", so resend on the existing contact.
try:
await ctx.api.api_send_member_contact_invitation(
contact["contactId"], messages.INVITATION_TEXT
)
except ChatError:
log.warning("invitation resend to contact %s failed", contact["contactId"])
await msg.reply(messages.INVITATION_FAILED)
return
await msg.reply(messages.INVITATION_SENT)
return
contact = await ctx.api.api_create_member_contact(ctx.roster_group_id, member["groupMemberId"])
await roster.mark(ctx.api, contact, roster.PENDING, roster.utc_now())
new_contact_id = contact["contactId"]
try:
await ctx.api.api_send_member_contact_invitation(new_contact_id, messages.INVITATION_TEXT)
except ChatError:
log.warning("invitation to contact %s failed to send", new_contact_id)
await msg.reply(messages.INVITATION_FAILED)
return
await msg.reply(messages.INVITATION_SENT)
async def contact_ready(ctx: BotContext, contact_id: int) -> None:
"""Promote a pending contact once its connection is usable.
Shared by contactConnected and contactSndReady. Re-reads the contact rather
than trusting the event payload.
"""
try:
contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id)
if contact is None:
return
entry = roster.entry_of(contact)
if entry is None or entry.state != roster.PENDING:
return
if not entry.reachable:
# The event says the connection is up, but the record is what
# `active()` will consult, so promote only on what it will see.
return
await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since)
except ChatError:
# Nobody is waiting on a reply here, so without this the failure is a
# bare traceback from the library and the person is stranded pending.
log.warning("could not promote contact %s", contact_id, exc_info=True)
return
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name))
async def reconcile_roster(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None:
"""Catch up on what happened while the bot was stopped.
Both events this compensates for are delivered once and never replayed: an
acceptance (`contactConnected`) leaves someone stuck pending, and a removal
from the roster group leaves someone on the roster who should not be.
`groups` is passed in by startup so the two passes share one listing, which
is the largest thing startup reads and grows with every customer ever seen.
"""
try:
present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id)
contacts = await ctx.api.api_list_contacts(ctx.user_id)
if groups is None:
groups = await ctx.api.api_list_groups(ctx.user_id)
except ChatError:
# Startup must not fail because the catch-up pass could not run.
log.warning("could not reconcile the roster on startup", exc_info=True)
return
# Revocation deletes the bot's only durable state, so it runs only when the
# roster group is unambiguous. An empty member list is deliberately NOT a
# reason to skip: the last member leaving is when revoking matters most.
marked = sum(1 for g in groups if setup.is_roster_group(g))
revoke = marked == 1
if not revoke:
log.warning("%d groups carry the roster marker; skipping revocation", marked)
for contact in contacts:
entry = roster.entry_of(contact)
if entry is None:
continue
try:
if revoke and entry.contact_id not in present:
await roster.unmark(ctx.api, contact)
log.info("removed %s from the roster: no longer in the roster group", entry.name)
await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name))
elif entry.state == roster.PENDING and entry.reachable:
await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since)
log.info("promoted %s on startup: their connection is ready", entry.name)
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name))
except ChatError:
# One bad contact must not abandon the rest of the pass.
log.warning("could not reconcile contact %s", entry.contact_id, exc_info=True)
def _member_name(member: T.GroupMember) -> str:
return member.get("localDisplayName") or (member.get("memberProfile") or {}).get(
"displayName", ""
)
async def member_gone(ctx: BotContext, group_id: int, member: T.GroupMember) -> None:
"""Take someone off the roster when they leave or are removed from the group.
Membership of the roster group is the access-control boundary, so it has to
be revocable: without this, someone removed from the group keeps being added
to every business chat and cannot even run `/leave` to stop it.
"""
if group_id != ctx.roster_group_id:
return
contact_id = member.get("memberContactId")
if contact_id is None:
return
try:
contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id)
if contact is None:
return
entry = roster.entry_of(contact)
if entry is None:
return
await roster.unmark(ctx.api, contact)
except ChatError:
# The only failure in the bot that the roster group would not hear
# about, and it is the one on the access-control path. Access is not at
# risk — every add re-reads roster group membership — but the operator
# is owed the mark still being there until the next start repairs it.
log.warning("could not take contact %s off the roster", contact_id, exc_info=True)
await ctx.post_to_roster(
messages.REVOKE_FAILED.format(name=safe_name(_member_name(member)))
)
return
log.info("removed %s from the roster: no longer in the roster group", entry.name)
await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name))
@_reply_on_error
async def list_roster(ctx: BotContext, msg: Message) -> None:
"""Reply with the roster, active and pending."""
entries = await roster.load(ctx.api, ctx.user_id)
await msg.reply(messages.render_roster(entries))
@_reply_on_error
async def leave(ctx: BotContext, msg: Message) -> None:
"""Take the sender off the roster, keeping the direct contact."""
member = group_sender(msg)
if member is None:
return
contact = await _contact_of(ctx, member)
if contact is None or roster.entry_of(contact) is None:
await msg.reply(messages.NOT_ON_ROSTER)
return
await roster.unmark(ctx.api, contact)
await msg.reply(messages.LEFT)
@_reply_on_error
async def help_cmd(ctx: BotContext, msg: Message) -> None:
"""Reply with the help text."""
await msg.reply(messages.HELP)
@@ -0,0 +1,166 @@
"""Optional HTTP endpoint reporting whether the core still answers."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from simplex_chat import ChatError
from .config import ConfigError, Health
from .context import BotContext
log = logging.getLogger(__name__)
PATH = "/health"
# The probe issues a real command, so it has to give up before the monitor does.
PROBE_TIMEOUT = 5.0
# Larger than any request a monitor sends, and the cap on what is read.
MAX_REQUEST_BYTES = 4096
READ_TIMEOUT = 5.0
def _head(status: str, length: int) -> bytes:
return (
f"HTTP/1.1 {status}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {length}\r\n"
"Connection: close\r\n\r\n"
).encode()
def _response(status: str, payload: str) -> tuple[bytes, bytes]:
"""(head, body). HEAD answers with the head alone, as HTTP requires."""
body = f'{{"status":"{payload}"}}\n'.encode()
return _head(status, len(body)), body
OK = _response("200 OK", "ok")
UNAVAILABLE = _response("503 Service Unavailable", "unavailable")
NOT_FOUND = _response("404 Not Found", "not found")
NOT_ALLOWED = _response("405 Method Not Allowed", "method not allowed")
BAD_REQUEST = _response("400 Bad Request", "bad request")
class Probe:
"""One outstanding query at a time, however often the endpoint is polled.
`asyncio.wait_for` bounds the wait, not the work: the FFI call it abandons
keeps a worker thread in the loop's default executor until the core answers.
Starting a fresh one per poll would exhaust that executor — as few as six
threads on a small container — and the receive loop reads events through the
same executor, so a stalled core would take the bot's own traffic down with
it. The task is therefore reused rather than replaced, and never cancelled.
"""
def __init__(self, ctx: BotContext) -> None:
self._ctx = ctx
self._task: asyncio.Task[bool] | None = None
async def check(self) -> bool:
"""Whether the core answered within PROBE_TIMEOUT."""
task = self._task
if task is None or task.done():
task = asyncio.create_task(self._query())
self._task = task
done, _pending = await asyncio.wait({task}, timeout=PROBE_TIMEOUT)
if not done:
log.warning("health probe still waiting after %ss", PROBE_TIMEOUT)
return False
return task.result()
async def _query(self) -> bool:
"""Query the roster group. Never raises, whatever the core does.
Reaching the process proves only that the event loop runs. This reads
the database, so it also waits on the store lock every other operation
takes — unlike `/u`, which the core answers from memory and which would
report healthy while a transaction was wedged. It stays small: the
roster group holds the people who answer, not customers.
"""
try:
await self._ctx.api.api_list_members(self._ctx.roster_group_id)
except ChatError:
log.warning("health probe failed", exc_info=True)
return False
except Exception:
# A malformed reply or a controller that is gone are exactly what
# this endpoint exists to report, and both arrive as something other
# than a chat error.
log.warning("health probe could not reach the core", exc_info=True)
return False
return True
async def _handle(
probe: Probe,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
try:
try:
line = await asyncio.wait_for(reader.readline(), READ_TIMEOUT)
except ValueError:
# Longer than MAX_REQUEST_BYTES: answered rather than dropped, so a
# monitor sees a reason.
_write(writer, BAD_REQUEST, body=True)
await writer.drain()
return
request = line.decode("latin-1").split()
method = request[0] if request else ""
if len(request) < 2 or request[1].split("?")[0] != PATH:
_write(writer, NOT_FOUND, body=True)
elif method not in ("GET", "HEAD"):
_write(writer, NOT_ALLOWED, body=True)
else:
_write(writer, OK if await probe.check() else UNAVAILABLE, body=method == "GET")
await writer.drain()
except (TimeoutError, OSError):
# A client that stopped sending, or went away mid-response.
log.debug("health request dropped", exc_info=True)
finally:
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
def _write(writer: asyncio.StreamWriter, response: tuple[bytes, bytes], body: bool) -> None:
head, payload = response
writer.write(head + payload if body else head)
async def serve(ctx: BotContext, config: Health) -> asyncio.Server | None:
"""Start the endpoint, or None when the default port is already taken.
A port the config names has to work: monitoring that silently failed to
listen reads as health. The default port is different — nothing about it was
asked for, so an unrelated service on it must not keep the bot from running.
"""
probe = Probe(ctx)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
await _handle(probe, reader, writer)
try:
server = await asyncio.start_server(
handle, config.host, config.port, limit=MAX_REQUEST_BYTES
)
except OSError as e:
if config.configured:
raise ConfigError(
f"health endpoint cannot listen on {config.host}:{config.port}: {e}"
) from e
log.warning(
"No health endpoint: the default %s:%s could not be bound (%s). Set "
"health.host or health.port, or health.enabled = false.",
config.host,
config.port,
e,
)
return None
log.info("Health endpoint: http://%s:%s%s", config.host, config.port, PATH)
return server
@@ -0,0 +1,113 @@
"""Every user-visible string, and roster rendering."""
from __future__ import annotations
from collections.abc import Sequence
from .roster import ACTIVE, RosterEntry
ADDED = "You are on the roster. You will be added to new chats."
ALREADY_ACTIVE = "You are already on the roster."
INVITATION_SENT = "Contact request sent. Accept it to join the roster."
STILL_PENDING = (
"Contact request not accepted yet. If you declined it, leave this group, "
"join it again with the link, then run /dm."
)
INVITATION_FAILED = "Contact request could not be sent. Run /dm again."
ACCEPTING = "Accepting the connection you started. You will be on the roster shortly."
CONNECTING = "The connection is still completing. You will be on the roster shortly."
CONNECTION_LOST = (
"The direct connection is gone, so I cannot add you to chats. Open my "
"profile in this group, connect directly, then run /dm."
)
INVITATION_TEXT = "Accept this contact request to be added to incoming chats. Keep the contact."
NOW_ACTIVE = "Now on the roster: {name}"
REMOVED_FROM_GROUP = "Off the roster: {name} left the roster group."
LEFT = "You are off the roster. Chats you have already joined are unchanged."
NOT_ON_ROSTER = "You are not on the roster."
ROSTER_EMPTY = "The roster is empty."
# Keeps /list under the core's per-message size limit.
MAX_LISTED = 40
# Below the core's maxEncodedMsgLength (Protocol.hs).
MAX_REPLY_BYTES = 12000
TRUNCATED = "\n… truncated"
EMPTY_ROSTER_LOG = "Connected: {customer} → nobody on the roster to add"
NOBODY_NEW_LOG = "Connected: {customer} → everyone on the roster was already in the chat"
COMMAND_FAILED = "The command failed. Try again."
REVOKE_FAILED = "Could not take {name} off the roster — retrying on the next restart."
BUSINESS_FAILED_LOG = "Connected: {customer} → could not set up the chat, nobody added"
HELP = (
"I add roster members to chats started by anyone who connects to my address.\n\n"
"/dm — join the roster. Without a direct contact I send a contact request; "
"you join the roster once you accept it.\n"
"/list — roster members, and contact requests not yet accepted.\n"
"/leave — leave the roster. Chats you have already joined are unchanged."
)
def _since(label: str, since: str) -> str:
"""` — since 2026-08-13`, or empty when the entry has no timestamp."""
day = since[:10]
return f" — {label} {day}" if day else ""
def _section(title: str, label: str, entries: Sequence[RosterEntry]) -> list[str]:
"""A `/list` section, capped so the whole reply stays sendable.
A long enough roster would push `/list` past the core's wire limit
(maxEncodedMsgLength), so it is capped here and what is omitted is stated
rather than silently dropped.
"""
lines = [f"{title} ({len(entries)}):"]
lines += [f" • {e.name}{_since(label, e.since)}" for e in entries[:MAX_LISTED]]
if len(entries) > MAX_LISTED:
lines.append(f" … and {len(entries) - MAX_LISTED} more")
return lines
def render_roster(entries: Sequence[RosterEntry]) -> str:
"""Format the roster for `/list`, with a section per state."""
active = [e for e in entries if e.state == ACTIVE and e.reachable]
unreachable = [e for e in entries if e.state == ACTIVE and not e.reachable]
pending = [e for e in entries if e.state != ACTIVE]
lines: list[str] = []
if active:
lines += _section("On the roster", "since", active)
else:
lines.append(ROSTER_EMPTY)
if unreachable:
lines.append("")
lines += _section("Not reachable, not being added", "since", unreachable)
if pending:
lines.append("")
lines += _section("Contact request not accepted", "asked", pending)
return _bounded("\n".join(lines))
def _bounded(out: str) -> str:
"""Keep a message inside what the core will send."""
encoded = out.encode()
if len(encoded) > MAX_REPLY_BYTES:
# A last resort: names are capped in characters, so a section of CJK
# names can still overrun what the core will send. The suffix is inside
# the budget, so the result never exceeds MAX_REPLY_BYTES.
room = MAX_REPLY_BYTES - len(TRUNCATED.encode())
return encoded[:room].decode(errors="ignore") + TRUNCATED
return out
def invite_log(customer: str, added: Sequence[str], failed: Sequence[str]) -> str:
"""One line for the roster group recording who was pulled into a business chat."""
line = (
f"Connected: {customer} → added {', '.join(added)}"
if added
else f"Connected: {customer} → nobody added"
)
if failed:
line += f" (failed: {', '.join(failed)})"
return _bounded(line)
@@ -0,0 +1,170 @@
"""Roster membership, stored in contact custom data."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from simplex_chat import ChatApi, util
from simplex_chat.types import T
from .text import safe_name
NAMESPACE = "supportBotLight"
ACTIVE = "active"
PENDING = "pending"
RosterState = Literal["active", "pending"]
READY_STATUSES = frozenset({"ready", "sndReady"})
# Mirrors isInGroup in apps/simplex-support-bot/src/bot.ts.
TERMINAL_STATUSES = frozenset({"rejected", "removed", "left", "deleted", "unknown"})
# The connection is gone; nothing about it is still in progress.
DEAD_STATUSES = frozenset({"deleted", "failed"})
def in_group(member: T.GroupMember) -> bool:
return member["memberStatus"] not in TERMINAL_STATUSES
async def contact_ids_in_group(api: ChatApi, group_id: int) -> set[int]:
"""Contact ids of everyone currently in a group.
api_list_members keeps rows for people who left or were removed, so the
status filter is what makes this a membership test rather than a history of
everyone who was ever in the group.
"""
members = await api.api_list_members(group_id)
return {cid for m in members if (cid := m.get("memberContactId")) is not None and in_group(m)}
def connecting(contact: T.Contact) -> bool:
"""Whether a connection is still on its way up.
Between accepting and `ready` the core parks a member contact in `accepted`
with `contactGroupMemberId` cleared, which is indistinguishable by shape
from a connection the peer deleted. Only the status separates them.
"""
tag = util.conn_status(contact)
return tag is not None and tag not in DEAD_STATUSES
def awaiting_accept(contact: T.Contact) -> bool:
"""Whether the peer opened a direct connection we have not accepted.
A member who taps "connect directly" on the bot's profile in the roster
group produces this: a contact in `prepared` state with no
`contactGroupMemberId`, otherwise indistinguishable from one the peer
deleted.
"""
inv = contact.get("groupDirectInv")
if inv is not None:
# The record survives acceptance; only this flag moves, and the core
# rejects a second accept with "connection already started".
return not inv.get("groupDirectInvStartedConnection", False)
return util.conn_status(contact) == "prepared"
def accept_started(contact: T.Contact) -> bool:
"""Whether we accepted and the connection is still completing.
UPSTREAM BUG: `groupDirectInv` outlives the connection it describes. Nothing
clears the record when that connection dies, so the started flag alone
reports progress on a contact the peer deleted long ago.
Workaround: the connection status decides, and the flag only distinguishes
accepted from not yet accepted.
"""
inv = contact.get("groupDirectInv")
if inv is None or not inv.get("groupDirectInvStartedConnection", False):
return False
return util.conn_status(contact) not in DEAD_STATUSES
def contact_usable(contact: T.Contact) -> bool:
"""Whether the bot can actually add this contact to a group.
`api_create_member_contact` sets the member's contact id before the person
has accepted anything, so the contact merely existing proves nothing — only
a connected connection does.
"""
return util.conn_status(contact) in READY_STATUSES
@dataclass(frozen=True, slots=True)
class RosterEntry:
"""One person on the roster, as recorded in their contact's custom data."""
contact_id: int
name: str
state: RosterState
since: str
reachable: bool
def utc_now() -> str:
"""Current UTC time as an ISO-8601 string, second precision."""
return datetime.now(UTC).isoformat(timespec="seconds")
def contact_name(contact: T.Contact) -> str:
"""A contact's display name, sanitised for rendering."""
return safe_name(
contact.get("localDisplayName") or (contact.get("profile") or {}).get("displayName") or ""
)
def entry_of(contact: T.Contact) -> RosterEntry | None:
"""The roster entry for a contact, or None if it carries no roster mark."""
mark = (contact.get("customData") or {}).get(NAMESPACE)
if not isinstance(mark, dict):
return None
state = mark.get("roster")
if state != ACTIVE and state != PENDING:
return None
return RosterEntry(
contact_id=contact["contactId"],
name=contact_name(contact),
state=state,
since=str(mark.get("since", "")),
reachable=contact_usable(contact),
)
async def mark(api: ChatApi, contact: T.Contact, state: RosterState, since: str) -> None:
"""Write the roster mark, preserving any other keys in the blob."""
await api.api_merge_contact_custom_data(contact, NAMESPACE, {"roster": state, "since": since})
async def unmark(api: ChatApi, contact: T.Contact) -> None:
"""Remove the roster mark, leaving any other keys and the contact intact."""
await api.api_merge_contact_custom_data(contact, NAMESPACE, None)
async def load(api: ChatApi, user_id: int) -> list[RosterEntry]:
"""Every marked contact, sorted by display name."""
contacts = await api.api_list_contacts(user_id)
entries = [e for c in contacts if (e := entry_of(c)) is not None]
return sorted(entries, key=lambda e: e.name.lower())
async def active(api: ChatApi, user_id: int) -> list[RosterEntry]:
"""Marked active and still reachable — the ones added to business chats.
A contact marked active can stop being usable later, for instance when the
person deletes the bot. `api_add_member` always fails for such a contact, so
it is excluded here rather than failing once per business chat forever.
`/list` reports the same distinction under "Not reachable".
"""
return [e for e in await load(api, user_id) if e.state == ACTIVE and e.reachable]
async def find_contact(api: ChatApi, user_id: int, contact_id: int) -> T.Contact | None:
"""The contact with this id, or None if it no longer exists."""
for c in await api.api_list_contacts(user_id):
if c["contactId"] == contact_id:
return c
return None
@@ -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"])
@@ -0,0 +1,48 @@
"""Sanitising peer-controlled text before it is rendered."""
from __future__ import annotations
import unicodedata
# mkValidName in src/Simplex/Chat/Library/Commands.hs caps a locally entered
# name at 50 characters. It is not applied to inbound profiles.
MAX_NAME = 50
UNNAMED = "(unnamed)"
# Characters that render as nothing but are neither whitespace nor a control
# category, so `str.split` and `str.isprintable` both let them through. A stock
# client accepts them in a profile name, which makes "ㅤㅤAlice" a
# working impersonation of "Alice".
# Separators the bot's own messages use. A customer chooses their display name,
# and the roster group is the operator's only record of who was added.
SEPARATORS = frozenset("→")
INVISIBLE = frozenset(
"ᅟᅠㅤᅠ" # Hangul fillers
"⠀" # Braille pattern blank
"឴឵" # Khmer inherent vowels
"⁠" # word joiner, zero-width no-break space
)
def safe_name(name: str) -> str:
"""Collapse and truncate a display name for rendering.
The core does not sanitise inbound profiles: a peer's display name reaches
us verbatim and may contain newlines or run to kilobytes. Rendered as-is it
forges lines in the roster group and in the log, and can push a message past
the size the core will send.
"""
# NFKC folds compatibility forms, so a name cannot hide behind an exotic
# encoding of an ordinary character.
collapsed = " ".join(unicodedata.normalize("NFKC", name).split())
printable = "".join(
c for c in collapsed if c.isprintable() and c not in INVISIBLE and c not in SEPARATORS
)
stripped = printable.strip()
if not stripped:
return UNNAMED
if len(stripped) > MAX_NAME:
return stripped[: MAX_NAME - 1] + "…"
return stripped