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
2026-08-17 09:12:47 +01:00
committed by GitHub
parent 55d18efe24
commit 68916aa90a
34 changed files with 5109 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
build/
dist/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.pyright_cache/
.venv/
.venv-*/
venv/
config.toml
.env
*.db
*.db-*
# Tracked as an empty directory: Docker creates a missing bind-mount source as
# root, which the unprivileged bot user cannot write to.
state/*
!state/.gitkeep
bot-config/*.png
bot-config/*.jpg
bot-config/*.jpeg
+130
View File
@@ -0,0 +1,130 @@
# syntax=docker/dockerfile:1
#
# Built from the repository root, not this directory: the image is made from the
# Haskell core and the Python library in this tree, neither of them released.
#
# docker compose build # from apps/simplex-support-bot-light
# docker build -f apps/simplex-support-bot-light/Dockerfile .
#
# The first stage compiles libsimplex from src/. That is a full GHC build of
# simplexmq and simplex-chat: hours on a cold cache, and it needs ~15 GB.
ARG UBUNTU=24.04
# The released libs are built on 22.04; a lib built here has to load on a runtime
# with the same glibc or newer, not the other way round.
ARG UBUNTU_LIBS=22.04
ARG GHC=9.6.3
ARG CABAL=3.10.2.0
# --------------------------------------------------------------------------- #
# libsimplex — the cabal invocation of scripts/desktop/build-lib-linux.sh, which
# is what produces the .so the published libs archive is repackaged from.
# --------------------------------------------------------------------------- #
FROM ubuntu:${UBUNTU_LIBS} AS libsimplex
ARG GHC
ARG CABAL
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git libgmp3-dev libnuma-dev \
libsqlite3-dev libssl-dev llvm pkg-config zlib1g-dev && \
rm -rf /var/lib/apt/lists/*
ENV BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} \
BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} \
BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true \
BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
ENV PATH="/root/.ghcup/bin:/root/.cabal/bin:$PATH"
# Explicit, so the cache mount below is where cabal actually keeps its store.
ENV CABAL_DIR=/root/.cabal
WORKDIR /src
COPY cabal.project simplex-chat.cabal README.md PRIVACY.md ./
COPY scripts/cabal.project.local.linux ./cabal.project.local
COPY src ./src
# Cache mounts, not layers: the Haskell store and the build tree survive a
# source change, which is the difference between minutes and hours. The RTS and
# package libraries are copied next to libsimplex.so because its rpath is $ORIGIN.
RUN --mount=type=cache,target=/root/.cabal \
--mount=type=cache,target=/src/dist-newstyle \
set -eu; \
cabal update; \
cabal build lib:simplex-chat \
--ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' \
--constraint 'simplexmq +client_library' \
--constraint 'simplex-chat +client_library'; \
lib=$(ls -t /src/dist-newstyle/build/*/ghc-${GHC}/simplex-chat-*/build/libHSsimplex-chat-*-inplace-ghc${GHC}.so | head -1); \
build_dir=$(dirname "$lib"); \
mv "$lib" "$build_dir/libsimplex.so"; \
mkdir -p /libs; \
ldd "$build_dir/libsimplex.so" | grep ghc | cut -d' ' -f 3 | xargs -I {} cp {} /libs/; \
cp "$build_dir/libsimplex.so" /libs/
# --------------------------------------------------------------------------- #
# the bot
# --------------------------------------------------------------------------- #
# libsimplex is a glibc build and will not load on musl, and it is compiled
# against this image's libraries in the stage above.
FROM ubuntu:${UBUNTU}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl dumb-init libffi8 libgmp10 libnuma1 && \
rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
mv /root/.local/bin/uv /usr/local/bin/uv
# The ids that must own ./state on the host; a bind mount keeps host ownership.
# Build with your own to avoid needing root to read the bot's state:
# USER_UID=$(id -u) USER_GID=$(id -g) docker compose build
ARG USER_UID=1000
ARG USER_GID=1000
# ubuntu:24.04 ships a default user at 1000, so drop whoever holds the ids.
RUN if existing_user=$(getent passwd ${USER_UID} | cut -d: -f1) && [ -n "${existing_user}" ]; then \
userdel -r "${existing_user}" 2>/dev/null || userdel "${existing_user}"; \
fi && \
if existing_group=$(getent group ${USER_GID} | cut -d: -f1) && [ -n "${existing_group}" ]; then \
groupdel "${existing_group}" 2>/dev/null || true; \
fi && \
groupadd -g ${USER_GID} supportbot && \
useradd -u ${USER_UID} -g ${USER_GID} -m -d /home/supportbot supportbot
# Applies only when /data is not bind-mounted; a bind mount keeps the host
# directory's ownership and mode.
RUN mkdir -p /data && chown supportbot:supportbot /data && chmod 0700 /data
# Read by simplex_chat._native instead of downloading a release, which is what
# makes the bot run against the core built above rather than the published one.
COPY --from=libsimplex /libs /opt/simplex/libs
ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs
USER supportbot
WORKDIR /home/supportbot
ENV VIRTUAL_ENV=/home/supportbot/.venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# The library is installed from this tree: the APIs the bot uses
# (install_signal_handlers, sync_profile, api_merge_*_custom_data) are unreleased.
COPY --chown=supportbot:supportbot packages/simplex-chat-python /home/supportbot/simplex-chat-python
RUN uv venv --python 3.12 "$VIRTUAL_ENV" && \
uv pip install /home/supportbot/simplex-chat-python
COPY --chown=supportbot:supportbot apps/simplex-support-bot-light /home/supportbot/app
RUN uv pip install -e /home/supportbot/app
ENV PYTHONUNBUFFERED=1
# No HEALTHCHECK: /health is published for an external monitor, and a container
# check would restart a bot whose chat controller is merely slow.
# Exec form: shell form would run under `sh -c`, which does not forward SIGTERM
# to the bot, so the graceful-stop path in __main__.py would never fire.
ENTRYPOINT ["dumb-init", "--", "support-bot-light", "--config", "/etc/support-bot-light/config.toml"]
@@ -0,0 +1,40 @@
# The build context is the repository root. Exclude everything, then add back
# what the image is built from, one directory level at a time.
*
!cabal.project
!simplex-chat.cabal
!README.md
!PRIVACY.md
!src
!scripts
scripts/*
!scripts/cabal.project.local.linux
!packages
packages/*
!packages/simplex-chat-python
!apps
apps/*
!apps/simplex-support-bot-light
# Build output, caches, and anything holding an identity or a secret.
**/__pycache__
**/*.py[cod]
**/*.egg-info
**/.venv
**/.venv-*
**/.pytest_cache
**/.ruff_cache
**/.pyright_cache
**/.mypy_cache
**/dist
**/*.db
**/*.db-*
apps/simplex-support-bot-light/plans
apps/simplex-support-bot-light/state
apps/simplex-support-bot-light/bot-config
apps/simplex-support-bot-light/config.toml
apps/simplex-support-bot-light/.env
+148
View File
@@ -0,0 +1,148 @@
# simplex-support-bot-light
A [SimpleX Chat](https://simplex.chat) bot that adds a roster of people to
incoming business chats.
Anyone who connects to the bot's address gets a business chat with a welcome
message, and every active roster member is added to it. People join the roster
themselves, from a command menu in a separate roster group.
## Docker
From `apps/simplex-support-bot-light`:
```bash
cp bot-config/config.toml.example bot-config/config.toml # required; edit before starting
printf 'USER_UID=%s\nUSER_GID=%s\n' "$(id -u)" "$(id -g)" > .env # see Ownership
chmod 0700 state
docker compose up --build -d
docker compose logs -f support-bot-light
```
Use the template in `bot-config/`, whose paths are container paths, not the
top-level one. Place the avatar beside it if `bot.image` is set.
| Path | Mount | Notes |
| --- | --- | --- |
| `./bot-config` | `/etc/support-bot-light` (read-only) | `bot.image` resolves against this directory. |
| `./state` | `/data` | All bot state. `bot.db_prefix` must point here. |
The monitoring endpoint is published on `127.0.0.1:8080`, and the container
config must set `health.host = "0.0.0.0"`, as the template does.
Run detached. Under an attached `docker compose up`, Ctrl+C stops the container
but compose re-attaches it; press Ctrl+C twice or use
`--abort-on-container-exit`.
### State directory
`./state` holds the bot's identity and address. Deleting it produces a new
address and a new roster group, and every roster member must repeat the
handshake. Back it up.
It must be owned by the uid the container runs as, set in `.env`. Both ids
default to 1000; root is not supported. `chmod 0700` it on a shared host, since
the databases hold the bot's identity keys.
## Manual installation
```bash
uv venv && uv pip install -e ../../packages/simplex-chat-python && uv pip install -e '.[dev]'
cp config.toml.example config.toml
uv run support-bot-light --config config.toml
```
The library is installed from this repository, since the APIs the bot uses are
unreleased. `libsimplex` is downloaded on first use unless `SIMPLEX_LIBS_DIR`
points at a local build.
`--config` defaults to `config.toml` in the working directory. `Ctrl+C` stops
the bot; a second `Ctrl+C` exits immediately.
## Configuration
`config.toml.example` is the committed template; `config.toml` is gitignored.
| Key | Required | Description |
| --- | --- | --- |
| `bot.display_name` | yes | Name shown to anyone who connects. |
| `bot.image` | no | Profile image path (`.png`, `.jpg`, `.jpeg`). Relative paths resolve against the directory containing `config.toml`. The encoded image must not exceed 12500 characters, roughly a 128x128 avatar. |
| `bot.db_prefix` | yes | SQLite path prefix. Creates `<prefix>_chat.db` and `<prefix>_agent.db`. Under Docker it must point inside `/data`. |
| `bot.welcome` | yes | Message posted into each new business chat, sent as the address auto-reply. Multi-line TOML strings are supported. |
| `roster.group_name` | yes | Name of the roster group, applied when it is created. |
| `roster.member_role` | no | Role roster members receive in business chats: `observer`, `author`, `member`, `moderator`, `admin` or `owner`. Defaults to `owner`. |
| `health.enabled` | no | Set `false` to switch the monitoring endpoint off. On by default. |
| `health.host` | no | Interface the endpoint binds. Defaults to `127.0.0.1`; `0.0.0.0` under Docker. |
| `health.port` | no | Port for the endpoint. Defaults to `8080`. Setting either key makes a bind failure fatal. |
Changing `bot.welcome` or `bot.image` applies on the next start.
The first start logs two links: the business address, for customers, and the
roster group link, for people who should answer. Anyone who joins the roster
group can add themselves to every incoming chat.
## Monitoring
The bot serves `GET /health` unless `health.enabled` is `false`:
| Status | Meaning |
| --- | --- |
| `200 {"status":"ok"}` | The core answered a query against the roster group. |
| `503 {"status":"unavailable"}` | It returned an error, or did not answer within 5 seconds. |
A bot whose messaging servers are unreachable still answers `200`.
There is no authentication. Bind it to `127.0.0.1`, or to an interface only the
monitoring system can reach. If `health.host` or `health.port` is set and the
address cannot be bound, the bot exits; otherwise a busy default port only logs
a warning.
## Commands
Available in the roster group.
| Command | Effect |
| --- | --- |
| `/dm` | Join the roster. If the bot has no direct contact, it sends a contact request first; membership becomes active once that request is accepted. |
| `/list` | List active members, members who are no longer reachable, and those pending a contact request. |
| `/leave` | Leave the roster. Chats already joined are unaffected. |
| `/help` | Summarise the above. |
Leaving the roster group, or being removed from it, also takes a member off the
roster. The bot is the group's only owner, so removing another member requires a
client signed in as the bot.
## State
All state is in the databases at `bot.db_prefix`. Roster membership is stored in
each contact's `custom_data`, and the roster group is found by a marker in the
group's `custom_data` rather than by name.
Startup reconciles what downtime missed: acceptances that arrived while the bot
was stopped, members who left the roster group, and business chats left without
their roster members.
## Development
```bash
source .venv/bin/activate
ruff check && ruff format --check src tests && pyright && pytest tests/ -v
```
Scope `ruff format` to `src tests`. An unscoped run also reformats Python
fenced inside markdown files.
## Limitations
- Joining the roster never grants access to earlier conversations, including
chats a returning customer reopens.
- `bot.display_name` cannot be changed to a name any contact, group or past
customer already holds. The bot logs this and keeps its current name.
- Every active member is added to every incoming chat. There is no routing or
per-customer selection.
- There is no command to remove someone else from the roster, and `/leave` does
not remove anyone from chats they have already joined.
## License
[AGPL-3.0](../../LICENSE)
@@ -0,0 +1,30 @@
# Copy to ./bot-config/config.toml. Paths here are container paths; use the
# top-level config.toml.example when running the bot directly on the host.
[bot]
display_name = "Support"
# Optional. .png, .jpg or .jpeg, resolved against the directory holding this
# file. The encoded data URI must not exceed 12500 characters, roughly a
# 128x128 avatar.
# image = "./avatar.png"
# Must be under /data (bind-mounted from ./state). Creates <prefix>_chat.db and
# <prefix>_agent.db, which hold the bot's identity.
db_prefix = "/data/support_bot_light"
welcome = "Hi! Someone from the team will join this chat in a moment."
[roster]
# Renaming the group later has no effect: it is found by a marker in its custom
# data, not by name.
group_name = "Invite roster"
# One of: observer, author, member, moderator, admin, owner. "relay" is also
# accepted by the core but is an infrastructure role.
member_role = "owner"
# Monitoring endpoint: GET /health answers 200 while the chat controller
# responds to a command, 503 when it does not. It must bind 0.0.0.0 to be
# reachable through the published port; docker-compose.yml publishes it on the
# host loopback, because the endpoint has no authentication.
[health]
# enabled = false
host = "0.0.0.0"
port = 8080
@@ -0,0 +1,25 @@
[bot]
display_name = "Support"
# Optional. .png, .jpg or .jpeg, resolved against the directory holding this
# file rather than the working directory. The encoded data URI must not exceed
# 12500 characters, roughly a 128x128 avatar.
# image = "./avatar.png"
# Creates <prefix>_chat.db and <prefix>_agent.db.
db_prefix = "./support_bot_light"
welcome = "Hi! Someone from the team will join this chat in a moment."
[roster]
# Renaming the group later has no effect: it is found by a marker in its custom
# data, not by name.
group_name = "Invite roster"
# One of: observer, author, member, moderator, admin, owner. "relay" is also
# accepted by the core but is an infrastructure role.
member_role = "owner"
# Monitoring endpoint: GET /health answers 200 while the chat controller
# responds to a command, 503 when it does not. On by default, at the values
# below. It has no authentication, so keep it off a public interface.
[health]
# enabled = false
host = "127.0.0.1"
port = 8080
@@ -0,0 +1,27 @@
services:
support-bot-light:
build:
# The repository root: the image is built from the Haskell core and the
# Python library in this tree, neither of them released.
context: ../..
dockerfile: apps/simplex-support-bot-light/Dockerfile
args:
# Defaults to 1000. Set both to your own ids to own ./state yourself.
USER_UID: ${USER_UID:-1000}
USER_GID: ${USER_GID:-1000}
# Bounded on purpose. A config that will not load is not fixed by retrying,
# and an unbounded policy turns it into a log flood that also makes Ctrl+C
# wait out the grace period. Five is enough to ride out a transient fault.
restart: on-failure:5
volumes:
# Directory, not a single file: bot.image resolves relative paths against
# the directory holding config.toml.
- ./bot-config:/etc/support-bot-light:ro
# Holds the bot's identity, address, roster group and roster. Deleting it
# means a new address and every roster member redoing the handshake.
- ./state:/data
stop_grace_period: 10s
ports:
# GET /health. Published on the host loopback because the endpoint has no
# authentication; widen it only for a monitoring system that needs it.
- "127.0.0.1:7777:8080"
@@ -0,0 +1,35 @@
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[project]
name = "simplex-support-bot-light"
version = "0.1.0"
description = "SimpleX bot that adds a self-service roster to incoming business chats"
readme = "README.md"
license = "AGPL-3.0-only"
requires-python = ">=3.11"
dependencies = ["simplex-chat>=7.1.0b0"]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"]
[project.scripts]
support-bot-light = "support_bot_light.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["src/support_bot_light"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pyright]
venvPath = "."
venv = ".venv"
include = ["src/support_bot_light"]
exclude = ["**/__pycache__", "**/.venv*"]
@@ -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
@@ -0,0 +1,275 @@
"""Fake ChatApi and wire-object factories. No libsimplex, no I/O."""
from __future__ import annotations
import copy
from types import SimpleNamespace
from typing import Any
import pytest
from simplex_chat import Message, util
from simplex_chat.core import ChatAPIError
USER_ID = 1
ROSTER_GROUP_ID = 10
class FakeChatApi:
"""Records calls; returns canned wire dicts.
`fail_on` is a set of method names that raise `ChatAPIError` when called,
used to drive the partial-failure paths.
"""
def __init__(self, contacts: list[dict] | None = None, fail_on: set[str] | None = None):
self.contacts = contacts or []
self.groups: list[dict] = []
self.members: dict[int, list[dict]] = {}
self.fail_on = fail_on or set()
self.custom_data: list[tuple[int, dict | None]] = []
self.group_custom_data: list[tuple[int, dict | None]] = []
self.replies: list[str] = []
self.sent: list[tuple[Any, str]] = []
self.added: list[tuple[int, int, str]] = []
self.created_member_contacts: list[tuple[int, int]] = []
self.invitations: list[tuple[int, str]] = []
self.new_groups: list[dict] = []
self.profile_updates: list[tuple[int, dict]] = []
self.links: list[int] = []
self.group_links: dict[int, str] = {}
self._next_contact_id = 100
self._next_item_id = 1000
self._member_contacts_created: set[tuple[int, int]] = set()
self.accepted_member_contacts: list[int] = []
def _check(self, name: str) -> None:
if name in self.fail_on:
raise ChatAPIError(f"fake failure in {name}", {"type": "chatCmdError"})
async def api_list_contacts(self, user_id: int) -> list[dict]:
self._check("api_list_contacts")
# Copies, like the core: a caller holding a contact does not see a later
# write to it, so stale reads show up in tests instead of in production.
return copy.deepcopy(self.contacts)
async def api_set_contact_custom_data(self, contact_id: int, custom_data=None) -> None:
self._check("api_set_contact_custom_data")
self.custom_data.append((contact_id, custom_data))
for c in self.contacts:
if c["contactId"] == contact_id:
if custom_data is None:
c.pop("customData", None)
else:
c["customData"] = custom_data
async def api_merge_contact_custom_data(self, contact: dict, key: str, value) -> None:
# Mirrors ChatApi: the column is replaced wholesale, so a merge is a
# read-modify-write through the same set command.
await self.api_set_contact_custom_data(
contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value)
)
async def api_merge_group_custom_data(self, group: dict, key: str, value) -> None:
await self.api_set_group_custom_data(
group["groupId"], util.merged_custom_data(group.get("customData"), key, value)
)
async def api_create_member_contact(self, group_id: int, group_member_id: int) -> dict:
self._check("api_create_member_contact")
key = (group_id, group_member_id)
if key in self._member_contacts_created:
raise ChatAPIError("member contact already exists", {"type": "chatCmdError"})
self._member_contacts_created.add(key)
self.created_member_contacts.append((group_id, group_member_id))
contact = make_contact(self._next_contact_id, f"member{group_member_id}")
self._next_contact_id += 1
self.contacts.append(contact)
return contact
async def api_send_member_contact_invitation(self, contact_id: int, message=None) -> dict:
self._check("api_send_member_contact_invitation")
for c in self.contacts:
if c["contactId"] == contact_id and c.get("contactGrpInvSent"):
raise ChatAPIError("x.grp.direct.inv already sent", {"type": "chatCmdError"})
self.invitations.append((contact_id, message))
for c in self.contacts:
if c["contactId"] == contact_id:
c["contactGrpInvSent"] = True
return make_contact(contact_id, "invited", grp_inv_sent=True)
async def api_accept_member_contact(self, contact_id: int) -> dict:
self._check("api_accept_member_contact")
self.accepted_member_contacts.append(contact_id)
for c in self.contacts:
if c["contactId"] == contact_id:
c.setdefault("groupDirectInv", {})["groupDirectInvStartedConnection"] = True
return c
return make_contact(contact_id, "accepted")
async def api_list_members(self, group_id: int) -> list[dict]:
self._check("api_list_members")
return list(self.members.get(group_id, []))
async def api_add_member(self, group_id: int, contact_id: int, member_role: str) -> dict:
self._check("api_add_member")
self.added.append((group_id, contact_id, member_role))
# Distinct id spaces; keep them apart so a mix-up shows up.
return make_member(group_member_id=contact_id + 1000, contact_id=contact_id)
async def api_send_text_message(self, chat, text: str, in_reply_to=None) -> list:
self._check("api_send_text_message")
self.sent.append((chat, text))
return []
async def api_send_text_reply(self, chat_item, text: str) -> list:
self._check("api_send_text_reply")
self.replies.append(text)
# Message.reply indexes items[0], so this cannot return [].
self._next_item_id += 1
sent_item = {
"chatInfo": chat_item["chatInfo"],
"chatItem": {
"chatDir": {"type": "direct"},
"meta": {"itemId": self._next_item_id},
"content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": text}},
},
}
return [sent_item]
async def api_list_groups(self, user_id: int, contact_id=None, search=None) -> list[dict]:
self._check("api_list_groups")
return list(self.groups)
async def api_new_group(self, user_id: int, group_profile: dict) -> dict:
self._check("api_new_group")
self.new_groups.append(group_profile)
group = make_group(ROSTER_GROUP_ID, group_profile)
self.groups.append(group)
return group
async def api_set_group_custom_data(self, group_id: int, custom_data=None) -> None:
self._check("api_set_group_custom_data")
self.group_custom_data.append((group_id, custom_data))
for g in self.groups:
if g["groupId"] == group_id:
g["customData"] = custom_data
async def api_update_group_profile(self, group_id: int, group_profile: dict) -> dict:
self._check("api_update_group_profile")
self.profile_updates.append((group_id, group_profile))
return make_group(group_id, group_profile)
async def api_create_group_link(self, group_id: int, member_role: str) -> str:
self._check("api_create_group_link")
self.links.append(group_id)
link = f"https://simplex.chat/contact#/?v=2&group={group_id}"
self.group_links[group_id] = link
return link
async def api_get_group_link_str(self, group_id: int) -> str:
self._check("api_get_group_link_str")
try:
return self.group_links[group_id]
except KeyError:
raise ChatAPIError("no group link", {"type": "chatCmdError"}) from None
def make_contact(
contact_id: int,
name: str,
custom_data: dict | None = None,
connected: bool = False,
grp_inv_sent: bool = False,
grp_member_id: int | None = -1,
conn_status: str | None = None,
) -> dict:
contact: dict = {
"contactId": contact_id,
"localDisplayName": name,
"profile": {"profileId": contact_id, "displayName": name, "fullName": ""},
"contactGrpInvSent": grp_inv_sent,
}
if custom_data is not None:
contact["customData"] = custom_data
if conn_status is not None:
contact["activeConn"] = {"connStatus": {"type": conn_status}}
elif connected:
contact["activeConn"] = {"connStatus": {"type": "ready"}}
# The core sets contactGroupMemberId when a member contact is created and
# clears it once that contact connects (resetMemberContactFields). -1 means
# "use whichever of those matches `connected`".
if grp_member_id == -1:
grp_member_id = None if connected else contact_id
if grp_member_id is not None:
contact["contactGroupMemberId"] = grp_member_id
return contact
def make_member(
group_member_id: int = 1,
contact_id: int | None = None,
name: str = "someone",
status: str = "complete",
) -> dict:
member: dict = {
"groupMemberId": group_member_id,
"localDisplayName": name,
"memberProfile": {"displayName": name, "fullName": ""},
"memberStatus": status,
}
if contact_id is not None:
member["memberContactId"] = contact_id
return member
def make_group(
group_id: int,
profile: dict,
custom_data: dict | None = None,
membership_status: str = "creator",
) -> dict:
# The core always sends membership; discovery reads it to skip groups the
# bot has left.
group: dict = {
"groupId": group_id,
"groupProfile": profile,
"localDisplayName": "g",
"membership": make_member(1, name="bot", status=membership_status),
}
if custom_data is not None:
group["customData"] = custom_data
return group
def join_roster_group(api: FakeChatApi) -> None:
"""Put every contact in the roster group.
Being on the roster means being in that group; the bot re-checks it before
adding anyone to a customer's chat, so tests have to model it.
"""
api.members[ROSTER_GROUP_ID] = [
make_member(1000 + c["contactId"], contact_id=c["contactId"], name=c["localDisplayName"])
for c in api.contacts
]
def make_group_message(api: FakeChatApi, member: dict, text: str, group_id: int = ROSTER_GROUP_ID):
"""A `Message` as delivered from a group, wired to the fake api."""
chat_item = {
"chatInfo": {"type": "group", "groupInfo": make_group(group_id, {"displayName": "r"})},
"chatItem": {
"chatDir": {"type": "groupRcv", "groupMember": member},
"meta": {"itemId": 1},
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}},
},
}
return Message(
chat_item=chat_item,
content={"type": "text", "text": text},
client=SimpleNamespace(api=api),
)
@pytest.fixture
def api() -> FakeChatApi:
return FakeChatApi()
@@ -0,0 +1,124 @@
"""Constants and boundaries pinned at their exact edge.
Each of these was a surviving mutant: the value could be moved by one, or a
member of a set removed, with the whole suite still green.
"""
import pytest
from support_bot_light import commands, config, messages, roster, setup, text
from support_bot_light.config import ConfigError, load_config
from tests.conftest import make_contact, make_member
from tests.test_config import VALID, write
def entry(name: str, state: str = "active", reachable: bool = True) -> roster.RosterEntry:
return roster.RosterEntry(
contact_id=1, name=name, state=state, since="2026-08-13", reachable=reachable
)
def test_the_roster_group_link_hands_out_the_member_role():
# An owner could remove the bot from its own roster group.
assert setup.JOIN_ROLE == "member"
def test_both_ready_statuses_make_a_contact_usable():
# contactSndReady is a distinct event from contactConnected, and a member
# promoted by one must not be treated as unreachable by the other.
for status in ("ready", "sndReady"):
assert roster.contact_usable(make_contact(1, "sh", conn_status=status)) is True
assert roster.contact_usable(make_contact(1, "sh", conn_status="accepted")) is False
@pytest.mark.parametrize("status", ["deleted", "failed"])
def test_a_dead_connection_is_not_accepted_or_connecting(status):
contact = make_contact(1, "sh", conn_status=status)
contact["groupDirectInv"] = {"groupDirectInvLink": "x", "groupDirectInvStartedConnection": True}
assert roster.accept_started(contact) is False
assert roster.connecting(contact) is False
def test_a_member_of_the_roster_group_is_in_it_until_a_terminal_status():
assert roster.in_group(make_member(1, status="pending_approval")) is True
assert roster.in_group(make_member(1, status="invited")) is True
assert roster.in_group(make_member(1, status="left")) is False
def test_list_shows_forty_before_it_summarises():
# 40 keeps the reply inside the core's wire limit with room for two more
# sections; the literal is the point, so a change has to be deliberate.
assert messages.MAX_LISTED == 40
at_cap = messages.render_roster([entry(f"n{i}") for i in range(40)])
assert at_cap.count("•") == 40
assert "more" not in at_cap
over_cap = messages.render_roster([entry(f"n{i}") for i in range(41)])
assert over_cap.count("•") == 40
assert "… and 1 more" in over_cap
def test_a_reply_at_the_byte_cap_is_not_truncated():
room = messages.MAX_REPLY_BYTES - len("On the roster (1):\n • ") - len(" — since 2026-08-13")
assert messages.render_roster([entry("a" * min(room, text.MAX_NAME))]).endswith("2026-08-13")
over = [entry("漢" * text.MAX_NAME) for _ in range(messages.MAX_LISTED)]
over += [entry("漢" * text.MAX_NAME, state="pending") for _ in range(messages.MAX_LISTED)]
rendered = messages.render_roster(over)
assert len(rendered.encode()) <= messages.MAX_REPLY_BYTES
assert rendered.endswith(messages.TRUNCATED)
def test_a_name_of_fifty_is_kept_whole():
# mkValidName caps a locally entered name at 50; inbound profiles are not
# capped at all, which is why this exists.
assert text.MAX_NAME == 50
assert text.safe_name("a" * 50) == "a" * 50
over = text.safe_name("a" * 51)
assert len(over) == 50 and over.endswith("…")
def test_a_welcome_of_twelve_thousand_bytes_is_accepted(tmp_path):
assert config.MAX_WELCOME_BYTES == 12000
at_cap = "w" * 12000
text_at = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{at_cap}"')
assert load_config(write(tmp_path, text_at)).welcome == at_cap
over = "w" * 12001
text_over = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{over}"')
with pytest.raises(ConfigError, match="too long"):
load_config(write(tmp_path, text_over))
def test_an_image_of_9357_bytes_is_accepted(tmp_path):
# 9357 raw bytes is what a 12500-character data URI holds once base64 and
# the "data:image/png;base64," prefix are added. The pre-read check must
# admit everything the encoded cap can hold, and no more.
assert config.MAX_IMAGE_BYTES == 9357
at_cap = tmp_path / "a.png"
at_cap.write_bytes(b"\x89PNG" + b"x" * (9357 - 4))
conf = VALID.replace("[roster]", f'image = "{at_cap}"\n\n[roster]')
assert load_config(write(tmp_path, conf)).image is not None
over = tmp_path / "b.png"
over.write_bytes(b"\x89PNG" + b"x" * (9358 - 4))
conf_over = VALID.replace("[roster]", f'image = "{over}"\n\n[roster]')
with pytest.raises(ConfigError, match="too large"):
load_config(write(tmp_path, conf_over))
@pytest.mark.parametrize("port", [1, 65535])
def test_the_port_range_ends_are_accepted(tmp_path, port):
assert config.MAX_PORT == 65535
conf = load_config(write(tmp_path, VALID + f"\n[health]\nport = {port}\n"))
assert conf.health is not None and conf.health.port == port
def test_the_command_menu_carries_every_command():
wire = commands.to_wire(commands.COMMANDS)
assert [c["keyword"] for c in wire] == [
commands.DM,
commands.LIST,
commands.LEAVE,
commands.HELP,
]
@@ -0,0 +1,534 @@
import pytest
from simplex_chat import ChatCommandError
from support_bot_light import business, messages
from support_bot_light.config import Config
from support_bot_light.context import BotContext
from tests.conftest import (
ROSTER_GROUP_ID,
USER_ID,
join_roster_group,
make_contact,
make_group,
make_member,
)
BUSINESS_GROUP_ID = 42
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
@pytest.fixture
def ctx(api):
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
def event(name="Alex"):
return {
"type": "acceptingBusinessRequest",
"groupInfo": make_group(BUSINESS_GROUP_ID, {"displayName": name, "fullName": ""}),
}
async def test_adds_active_roster_members(ctx, api):
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(3, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}),
]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [
(BUSINESS_GROUP_ID, 2, "owner"),
(BUSINESS_GROUP_ID, 1, "owner"),
]
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added Narasimha, sh")]
@pytest.mark.parametrize("status", ["rejected", "removed", "left", "deleted", "unknown"])
async def test_does_not_add_someone_who_has_left_the_roster_group(ctx, api, status):
# The departure event and a queued business request arrive in whatever order
# the core dispatches them, so an active mark is not authority on its own:
# this is what stops a departed member reading a conversation started after
# they went.
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
join_roster_group(api)
api.members[ROSTER_GROUP_ID][1]["memberStatus"] = status
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added sh")]
async def test_reconcile_does_not_add_someone_who_has_left_the_roster_group(ctx, api):
api.contacts.append(
make_contact(
1, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.members[ROSTER_GROUP_ID][0]["memberStatus"] = "removed"
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
await business.reconcile_chats(ctx)
assert api.added == []
async def test_skips_members_already_in_the_group(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="invited")]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
async def test_does_not_skip_members_who_left(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
async def test_empty_roster_logs_and_adds_nobody(ctx, api):
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
async def test_pending_only_roster_counts_as_empty(ctx, api):
api.contacts.append(
make_contact(1, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}})
)
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
async def test_one_failure_does_not_block_the_rest(ctx, api):
api.contacts += [
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
calls: list[int] = []
original = api.api_add_member
async def flaky(group_id, contact_id, member_role):
calls.append(contact_id)
if contact_id == 2:
raise ChatCommandError("nope", {"type": "chatCmdError"})
return await original(group_id, contact_id, member_role)
api.api_add_member = flaky
join_roster_group(api)
await business.on_business_request(ctx, event())
assert sorted(calls) == [1, 2] # both attempted
assert api.sent[-1][1] == "Connected: Alex → added sh (failed: Narasimha)"
async def test_roster_read_failure_logs_and_adds_nobody(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_contacts")
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
async def test_member_list_failure_logs_and_adds_nobody(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_members")
await business.on_business_request(ctx, event())
assert api.added == []
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
async def test_uses_configured_member_role(api):
ctx = BotContext(
api=api,
user_id=USER_ID,
roster_group_id=ROSTER_GROUP_ID,
config=Config("S", "./x", "hi", "R", "admin"),
)
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "admin")]
async def test_reconcile_repairs_a_chat_left_half_added_by_a_crash(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_reconcile_is_idempotent_when_everyone_is_present(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="complete")]
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
# The chat was left unmarked, so a crash took the roster group's record of
# this customer with it; the repair puts it back even with nothing to add.
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
async def test_reconcile_skips_non_business_groups(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": ROSTER_GROUP_ID,
"groupProfile": {"displayName": "roster", "fullName": ""},
"localDisplayName": "roster",
"membership": make_member(99, name="bot", status="complete"),
}
)
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
async def test_reconcile_skips_a_chat_the_bot_has_left(ctx, api):
# The core keeps the group row after removal; adding into it would fail on
# every start, and the customer is no longer the bot's to serve.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="removed"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
assert api.group_custom_data == []
async def test_reconcile_failure_does_not_stop_startup(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_list_groups")
join_roster_group(api)
await business.reconcile_chats(ctx) # must not raise
async def test_reconcile_skips_a_chat_whose_roster_pass_already_ran(ctx, api):
# Someone who joins the roster later must not be back-filled into every
# conversation the bot has ever handled.
api.contacts.append(
make_contact(
1, "newbie", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": {"supportBotLight": {"rostered": True}},
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
assert api.sent == []
async def test_reconcile_does_not_re_invite_someone_who_left_a_chat(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": {"supportBotLight": {"rostered": True}},
}
)
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
join_roster_group(api)
await business.reconcile_chats(ctx)
assert api.added == []
async def test_on_business_request_marks_the_chat_as_rostered(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.group_custom_data[-1] == (
BUSINESS_GROUP_ID,
{"supportBotLight": {"rostered": True}},
)
async def test_reconcile_marks_chats_even_with_an_empty_roster(ctx, api):
# Otherwise the chat stays unmarked and a later restart back-fills whoever
# joined the roster in the meantime.
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
await business.reconcile_chats(ctx)
assert api.group_custom_data[-1] == (
BUSINESS_GROUP_ID,
{"supportBotLight": {"rostered": True}},
)
assert api.added == []
async def test_a_failed_mark_does_not_report_nobody_added(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_set_group_custom_data")
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_a_chat_where_every_add_failed_is_retried_next_start(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_add_member")
join_roster_group(api)
await business.on_business_request(ctx, event())
assert api.group_custom_data == [] # not marked, so repair will revisit it
async def test_a_chat_left_unmarked_is_not_back_filled_with_a_later_roster(ctx, api):
# The one bit that keeps a new roster member out of old conversations.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.fail_on.add("api_send_text_message")
await business.on_business_request(ctx, event())
api.fail_on.clear()
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
"customData": api.group_custom_data[-1][1],
}
)
api.contacts.append(
make_contact(
2, "newbie", {"supportBotLight": {"roster": "active", "since": "y"}}, connected=True
)
)
join_roster_group(api)
api.added.clear()
await business.reconcile_chats(ctx)
assert api.added == []
async def test_a_chat_is_marked_even_when_its_line_never_went_out(ctx, api):
# An unmarked chat is repaired by every later start with the roster of the
# day, so withholding the marker to preserve a log line would hand this
# customer's conversation to whoever joins the roster next.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
join_roster_group(api)
api.fail_on.add("api_send_text_message")
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.group_custom_data[-1][1] == {"supportBotLight": {"rostered": True}}
async def test_a_failed_mark_still_reports_the_repaired_chat(ctx, api):
# The marker is re-derived on the next start; the report is not, because
# the event that would have produced it was consumed before the crash.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
api.fail_on.add("api_set_group_custom_data")
await business.reconcile_chats(ctx)
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
assert api.sent[-1][1] == "Connected: Alex → added sh"
async def test_an_unfinished_repair_is_retried_by_the_queued_event(ctx, api):
# Nothing was added and the chat was left unmarked, so the event that the
# startup pass raced is the only remaining chance to finish it.
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
api.fail_on.add("api_add_member")
await business.reconcile_chats(ctx)
assert api.group_custom_data == [] # not marked: the repair failed
api.fail_on.clear()
await business.on_business_request(ctx, event())
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
async def test_repair_does_not_re_report_a_chat_to_the_event_handler(ctx, api):
api.contacts.append(
make_contact(
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
api.groups.append(
{
"groupId": BUSINESS_GROUP_ID,
"groupProfile": {"displayName": "Alex", "fullName": ""},
"localDisplayName": "Alex",
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
"membership": make_member(99, name="bot", status="complete"),
}
)
api.members[BUSINESS_GROUP_ID] = []
join_roster_group(api)
await business.reconcile_chats(ctx)
posts_after_repair = len(api.sent)
await business.on_business_request(ctx, event())
assert len(api.sent) == posts_after_repair # the queued event adds no line
# Only that one event is swallowed: the same customer coming back later
# must be handled like anyone else.
api.members[BUSINESS_GROUP_ID] = []
await business.on_business_request(ctx, event())
assert len(api.sent) == posts_after_repair + 1
@@ -0,0 +1,32 @@
from simplex_chat import BotCommand
from support_bot_light.commands import COMMANDS, to_wire
def test_declares_four_commands():
assert tuple(c.keyword for c in COMMANDS) == ("dm", "list", "leave", "help")
def test_no_command_takes_params():
# Zero-argument commands send on tap instead of pasting a placeholder.
assert all(c.params is None for c in COMMANDS)
def test_to_wire_omits_params_when_none():
wire = to_wire([BotCommand(keyword="list", label="Who gets invited")])
assert wire == [{"type": "command", "keyword": "list", "label": "Who gets invited"}]
assert "params" not in wire[0]
def test_to_wire_includes_params_when_set():
wire = to_wire([BotCommand(keyword="x", label="X", params="<n>")])
assert wire == [{"type": "command", "keyword": "x", "label": "X", "params": "<n>"}]
def test_to_wire_distinguishes_none_from_empty_string():
assert "params" not in to_wire([BotCommand("a", "A")])[0]
assert to_wire([BotCommand("b", "B", params="")])[0]["params"] == ""
def test_to_wire_preserves_declaration_order():
assert [c["keyword"] for c in to_wire(COMMANDS)] == [c.keyword for c in COMMANDS]
@@ -0,0 +1,286 @@
import base64
import pytest
from support_bot_light.config import Config, ConfigError, Health, load_config
VALID = """
[bot]
display_name = "Support"
db_prefix = "./support_bot_light"
welcome = "Hi! Someone will join shortly."
[roster]
group_name = "Invite roster"
member_role = "admin"
"""
# Minimal 1x1 PNG. The loader never parses it, only encodes the bytes.
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
def write(tmp_path, text):
p = tmp_path / "config.toml"
p.write_text(text, encoding="utf-8")
return p
def test_loads_all_fields(tmp_path):
cfg = load_config(write(tmp_path, VALID))
assert cfg == Config(
display_name="Support",
db_prefix="./support_bot_light",
welcome="Hi! Someone will join shortly.",
group_name="Invite roster",
member_role="admin",
health=Health(host="127.0.0.1", port=8080),
)
def test_member_role_defaults_to_owner(tmp_path):
text = VALID.replace('member_role = "admin"\n', "")
assert load_config(write(tmp_path, text)).member_role == "owner"
def test_rejects_unknown_member_role(tmp_path):
text = VALID.replace('"admin"', '"chief"')
with pytest.raises(ConfigError, match="member_role"):
load_config(write(tmp_path, text))
def test_rejects_missing_key(tmp_path):
text = VALID.replace('welcome = "Hi! Someone will join shortly."\n', "")
with pytest.raises(ConfigError, match="bot.welcome"):
load_config(write(tmp_path, text))
def test_rejects_missing_bot_section(tmp_path):
with pytest.raises(ConfigError, match=r"missing \[bot\] section"):
load_config(write(tmp_path, '[roster]\ngroup_name = "R"\n'))
def test_rejects_missing_roster_section(tmp_path):
with pytest.raises(ConfigError, match=r"missing \[roster\] section"):
load_config(write(tmp_path, "[bot]\n"))
def test_rejects_empty_string(tmp_path):
text = VALID.replace('"Invite roster"', '" "')
with pytest.raises(ConfigError, match="roster.group_name"):
load_config(write(tmp_path, text))
def test_missing_file(tmp_path):
with pytest.raises(ConfigError, match="not found"):
load_config(tmp_path / "nope.toml")
def test_invalid_toml(tmp_path):
with pytest.raises(ConfigError, match="invalid TOML"):
load_config(write(tmp_path, "[bot"))
def test_image_defaults_to_none(tmp_path):
assert load_config(write(tmp_path, VALID)).image is None
def test_png_image_encodes_with_prefix_and_roundtrips(tmp_path):
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
prefix = "data:image/png;base64,"
assert image.startswith(prefix)
assert base64.b64decode(image[len(prefix) :]) == PNG_BYTES
@pytest.mark.parametrize("ext", ["jpg", "jpeg"])
def test_jpg_and_jpeg_extensions_encode_as_jpg(tmp_path, ext):
(tmp_path / f"avatar.{ext}").write_bytes(b"not really a jpeg, just bytes")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
f'db_prefix = "./support_bot_light"\nimage = "avatar.{ext}"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert image.startswith("data:image/jpg;base64,")
def test_uppercase_extension_accepted(tmp_path):
(tmp_path / "avatar.PNG").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.PNG"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert image.startswith("data:image/png;base64,")
def test_rejects_unsupported_extension(tmp_path):
(tmp_path / "avatar.gif").write_bytes(b"gif bytes")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.gif"',
)
with pytest.raises(ConfigError, match=r"\.gif"):
load_config(write(tmp_path, text))
def test_rejects_missing_image_file(tmp_path):
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "missing.png"',
)
resolved = tmp_path / "missing.png"
with pytest.raises(ConfigError, match=r"not found.*missing\.png|missing\.png.*not found"):
load_config(write(tmp_path, text))
assert not resolved.exists()
def test_rejects_oversized_image(tmp_path):
# 12500 caps the whole data URI, prefix included.
(tmp_path / "avatar.png").write_bytes(b"\x00" * 20000)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="12500"):
load_config(write(tmp_path, text))
def test_rejects_empty_image_string(tmp_path):
text = VALID.replace(
'db_prefix = "./support_bot_light"', 'db_prefix = "./support_bot_light"\nimage = " "'
)
with pytest.raises(ConfigError, match="bot.image"):
load_config(write(tmp_path, text))
def test_relative_image_path_resolves_against_config_dir(tmp_path, monkeypatch):
other_dir = tmp_path / "elsewhere"
other_dir.mkdir()
monkeypatch.chdir(other_dir)
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
def test_absolute_image_path_works(tmp_path):
image_path = tmp_path / "avatar.png"
image_path.write_bytes(PNG_BYTES)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
f'db_prefix = "./support_bot_light"\nimage = "{image_path}"',
)
image = load_config(write(tmp_path, text)).image
assert image is not None
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
def test_rejects_empty_image_file(tmp_path):
(tmp_path / "avatar.png").write_bytes(b"")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="empty"):
load_config(write(tmp_path, text))
def test_rejects_a_non_regular_image_file(tmp_path):
import os
os.mkfifo(tmp_path / "avatar.png")
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="not a regular file"):
load_config(write(tmp_path, text))
def test_rejects_an_oversized_image_before_reading_it(tmp_path):
(tmp_path / "avatar.png").write_bytes(b"A" * 20000)
text = VALID.replace(
'db_prefix = "./support_bot_light"',
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
)
with pytest.raises(ConfigError, match="bytes exceeds"):
load_config(write(tmp_path, text))
def test_rejects_an_over_long_welcome(tmp_path):
text = VALID.replace(
'welcome = "Hi! Someone will join shortly."', 'welcome = "' + "x" * 20000 + '"'
)
with pytest.raises(ConfigError, match="too long"):
load_config(write(tmp_path, text))
def test_health_is_on_without_configuration(tmp_path):
assert load_config(write(tmp_path, VALID)).health == Health(host="127.0.0.1", port=8080)
def test_health_can_be_switched_off(tmp_path):
assert load_config(write(tmp_path, VALID + "\n[health]\nenabled = false\n")).health is None
def test_health_port_can_be_set(tmp_path):
config = load_config(write(tmp_path, VALID + "\n[health]\nport = 9999\n"))
assert config.health == Health(host="127.0.0.1", port=9999, configured=True)
def test_the_default_port_is_not_treated_as_chosen(tmp_path):
# A port nobody asked for must not be able to stop the bot from starting.
assert load_config(write(tmp_path, VALID)).health == Health("127.0.0.1", 8080)
assert load_config(write(tmp_path, VALID)).health.configured is False
def test_health_host_can_be_set(tmp_path):
config = load_config(write(tmp_path, VALID + '\n[health]\nhost = "0.0.0.0"\nport = 9000\n'))
assert config.health == Health(host="0.0.0.0", port=9000, configured=True)
@pytest.mark.parametrize(
"section",
[
'[health]\nenabled = "yes"\n',
"[health]\nport = 0\n",
"[health]\nport = 65536\n",
"[health]\nport = true\n", # TOML booleans are ints in Python
'[health]\nport = "8080"\n',
'[health]\nport = 8080\nhost = " "\n',
],
)
def test_invalid_health_settings_are_rejected(tmp_path, section):
with pytest.raises(ConfigError):
load_config(write(tmp_path, VALID + "\n" + section))
def test_a_missing_config_points_at_the_template(tmp_path):
# Under Docker this is a restart loop until the operator acts, so the error
# has to say what the action is.
(tmp_path / "config.toml.example").write_text(VALID, encoding="utf-8")
with pytest.raises(ConfigError, match="copy config.toml.example to config.toml"):
load_config(tmp_path / "config.toml")
def test_a_missing_config_without_a_template_says_only_that(tmp_path):
with pytest.raises(ConfigError, match="not found") as raised:
load_config(tmp_path / "config.toml")
assert "copy" not in str(raised.value)
@@ -0,0 +1,720 @@
import pytest
from simplex_chat import ChatCommandError
from support_bot_light import handlers, messages, roster
from support_bot_light.config import Config
from support_bot_light.context import BotContext
from tests.conftest import (
ROSTER_GROUP_ID,
USER_ID,
make_contact,
make_group,
make_group_message,
make_member,
)
CONFIG = Config(
display_name="Support",
db_prefix="./x",
welcome="hi",
group_name="Invite roster",
member_role="owner",
)
@pytest.fixture
def ctx(api):
# The bot always has its own marked roster group; reconcile checks for it.
api.groups.append(
make_group(
ROSTER_GROUP_ID,
{"displayName": "Invite roster", "fullName": ""},
custom_data={"supportBotLight": {"group": "roster"}},
)
)
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
async def test_dm_with_existing_contact_marks_active(ctx, api):
api.contacts.append(make_contact(7, "sh", connected=True))
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active"
assert api.replies == [messages.ADDED]
assert api.created_member_contacts == []
async def test_dm_promotes_pending_contact_keeps_original_since(ctx, api):
# Self-heal after a missed contactConnected; the ask date must survive.
api.contacts.append(
make_contact(
7,
"Alex",
{"supportBotLight": {"roster": "pending", "since": "2026-08-01T00:00:00+00:00"}},
connected=True,
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"] == {
"roster": "active",
"since": "2026-08-01T00:00:00+00:00",
}
assert api.replies == [messages.ADDED]
async def test_dm_promotes_usable_contact_even_if_invitation_was_sent(ctx, api):
# The invitation is what made the contact usable, so both flags are set.
api.contacts.append(
make_contact(
7,
"Alex",
{"supportBotLight": {"roster": "pending", "since": "x"}},
connected=True,
grp_inv_sent=True,
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active"
assert api.replies == [messages.ADDED]
async def test_dm_without_contact_creates_and_invites(ctx, api):
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
assert api.invitations == [(100, messages.INVITATION_TEXT)]
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
assert api.replies == [messages.INVITATION_SENT]
async def test_dm_replies_invitation_failed_when_send_fails(ctx, api):
api.fail_on.add("api_send_member_contact_invitation")
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
assert api.replies == [messages.INVITATION_FAILED]
async def test_dm_while_pending_and_invitation_sent_is_a_noop(ctx, api):
# The core rejects a second invitation.
api.contacts.append(
make_contact(
7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.invitations == []
assert api.custom_data == [] # already pending, mark untouched
assert api.replies == [messages.STILL_PENDING]
async def test_dm_while_pending_and_invitation_never_sent_resends_it(ctx, api):
api.contacts.append(
make_contact(7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}})
)
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.invitations == [(7, messages.INVITATION_TEXT)]
assert api.custom_data == [] # already pending, mark untouched
assert api.replies == [messages.INVITATION_SENT]
async def test_dm_when_already_active_is_a_noop(ctx, api):
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data == []
assert api.replies == [messages.ALREADY_ACTIVE]
async def test_dm_ignores_non_group_message(ctx, api):
msg = make_group_message(api, make_member(1), "/dm")
msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"}
await handlers.dm(ctx, msg)
assert api.replies == [] and api.custom_data == []
async def test_dm_recovers_when_contact_vanished(ctx, api):
# memberContactId points at a contact that no longer exists.
msg = make_group_message(api, make_member(1, contact_id=404, name="ghost"), "/dm")
await handlers.dm(ctx, msg)
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
async def test_dm_replies_command_failed_when_api_fails(ctx, api):
# api_create_member_contact has no try/except of its own.
api.fail_on.add("api_create_member_contact")
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
await handlers.dm(ctx, msg)
assert api.replies == [messages.COMMAND_FAILED]
async def test_dm_lets_unexpected_errors_propagate(ctx, api):
async def boom(contact_id, message=None):
raise RuntimeError("network on fire")
api.api_send_member_contact_invitation = boom
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
with pytest.raises(RuntimeError):
await handlers.dm(ctx, msg)
async def test_dm_after_leave_does_not_promote_unconnected_contact(ctx, api):
member = make_member(1, name="Alex")
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
created_contact_id = api.contacts[-1]["contactId"]
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
# The core sets memberContactId as soon as the contact exists.
member["memberContactId"] = created_contact_id
await handlers.leave(ctx, make_group_message(api, member, "/leave"))
api.custom_data.clear()
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
written = api.custom_data[-1][1]["supportBotLight"]["roster"] if api.custom_data else None
assert written != "active", "unconnected contact must never be marked active"
async def test_dm_after_leave_still_promotes_on_accept(ctx, api):
"""/dm -> /leave -> /dm -> accept must end up active.
/leave clears our roster mark, but the core's contactGrpInvSent survives and
cannot be unset, so the second /dm must re-establish the pending mark
or the eventual acceptance has nothing to promote.
"""
member = make_member(1, name="Alex")
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
contact_id = api.contacts[-1]["contactId"]
# The fake names the contact after the group member id, not the member.
contact_name = api.contacts[-1]["profile"]["displayName"]
member["memberContactId"] = contact_id
await handlers.leave(ctx, make_group_message(api, member, "/leave"))
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
assert api.replies[-1] == messages.STILL_PENDING
for c in api.contacts:
if c["contactId"] == contact_id:
c["activeConn"] = {"connStatus": {"type": "ready"}}
await handlers.contact_ready(ctx, contact_id)
assert [e.name for e in await roster.active(api, USER_ID)] == [contact_name]
async def test_contact_connected_promotes_pending(ctx, api):
api.contacts.append(
make_contact(
7,
"Alex",
{"supportBotLight": {"roster": "pending", "since": "2026-08-13"}},
connected=True,
)
)
await handlers.contact_ready(ctx, 7)
assert api.custom_data[-1][1]["supportBotLight"] == {
"roster": "active",
"since": "2026-08-13", # original ask time preserved
}
assert api.sent == [(["group", ROSTER_GROUP_ID], "Now on the roster: Alex")]
async def test_contact_connected_ignores_unmarked_contact(ctx, api):
api.contacts.append(make_contact(7, "stranger"))
await handlers.contact_ready(ctx, 7)
assert api.custom_data == [] and api.sent == []
async def test_contact_connected_ignores_already_active(ctx, api):
api.contacts.append(
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
)
await handlers.contact_ready(ctx, 7)
assert api.custom_data == [] and api.sent == []
async def test_contact_connected_does_not_promote_an_unusable_connection(ctx, api):
# The event says the connection is up; the contact record says otherwise.
# active() consults the record, so promoting here would list somebody the
# bot cannot reach.
api.contacts.append(
make_contact(
7,
"Alex",
{"supportBotLight": {"roster": "pending", "since": "x"}},
conn_status="deleted",
)
)
await handlers.contact_ready(ctx, 7)
assert api.custom_data == [] and api.sent == []
async def test_contact_connected_for_unknown_contact_is_a_noop(ctx, api):
await handlers.contact_ready(ctx, 999)
assert api.custom_data == [] and api.sent == []
async def test_a_failed_revocation_is_reported_to_the_roster_group(ctx, api):
# Revocation is the access-control path; a silent failure would leave the
# operator reading /list as the truth.
api.contacts.append(
make_contact(7, "Alex", {"supportBotLight": {"roster": "active", "since": "x"}})
)
api.fail_on.add("api_set_contact_custom_data")
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="Alex"))
assert api.sent[-1][1] == messages.REVOKE_FAILED.format(name="Alex")
async def test_list_renders_both_states(ctx, api):
api.contacts += [
make_contact(
1,
"sh",
{"supportBotLight": {"roster": "active", "since": "2026-08-13"}},
connected=True,
),
make_contact(2, "Alex", {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}),
]
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
assert "On the roster (1):" in api.replies[0]
assert "Contact request not accepted (1):" in api.replies[0]
async def test_list_when_empty(ctx, api):
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
assert api.replies == [messages.ROSTER_EMPTY]
async def test_list_replies_command_failed_when_api_fails(ctx, api):
api.fail_on.add("api_list_contacts")
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
assert api.replies == [messages.COMMAND_FAILED]
async def test_leave_clears_the_mark(ctx, api):
api.contacts.append(
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
await handlers.leave(ctx, msg)
assert api.custom_data[-1] == (7, None)
assert api.replies == [messages.LEFT]
async def test_leave_when_not_on_roster(ctx, api):
api.contacts.append(make_contact(7, "sh"))
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
await handlers.leave(ctx, msg)
assert api.custom_data == []
assert api.replies == [messages.NOT_ON_ROSTER]
async def test_leave_without_any_contact(ctx, api):
msg = make_group_message(api, make_member(1, name="stranger"), "/leave")
await handlers.leave(ctx, msg)
assert api.replies == [messages.NOT_ON_ROSTER]
assert api.created_member_contacts == [] # /leave never creates a contact
async def test_leave_ignores_non_group_message(ctx, api):
msg = make_group_message(api, make_member(1, contact_id=7), "/leave")
msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"}
await handlers.leave(ctx, msg)
assert api.replies == [] and api.custom_data == []
async def test_leave_replies_command_failed_when_api_fails(ctx, api):
api.contacts.append(
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
)
api.fail_on.add("api_list_contacts")
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
await handlers.leave(ctx, msg)
assert api.replies == [messages.COMMAND_FAILED]
async def test_help_replies_with_help_text(ctx, api):
await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help"))
assert api.replies == [messages.HELP]
async def test_help_replies_command_failed_when_send_fails(ctx, api):
# help_cmd's only action is the reply, so the first send must fail alone.
calls = 0
original = api.api_send_text_reply
async def flaky_once(chat_item, text):
nonlocal calls
calls += 1
if calls == 1:
raise ChatCommandError("boom", {"type": "chatCmdError"})
return await original(chat_item, text)
api.api_send_text_reply = flaky_once
await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help"))
assert api.replies == [messages.COMMAND_FAILED]
async def test_dm_redrives_a_contact_that_never_connected(ctx, api):
# Marked active but never usable: the member contact still exists, so the
# invitation can be re-sent.
api.contacts.append(
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-01-01"}})
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
assert api.invitations == [(7, messages.INVITATION_TEXT)]
assert api.replies == [messages.INVITATION_SENT]
async def test_dm_reports_a_connection_that_is_gone_for_good(ctx, api):
# The person deleted the bot after connecting. The core cleared
# contactGroupMemberId, so no invitation can be sent and telling them to
# retry would be false.
api.contacts.append(
make_contact(
7,
"sh",
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
grp_member_id=None,
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.invitations == []
assert api.replies == [messages.CONNECTION_LOST]
async def test_dm_on_a_dead_contact_with_an_invitation_outstanding_waits(ctx, api):
api.contacts.append(
make_contact(
7,
"sh",
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
grp_inv_sent=True,
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
assert api.invitations == []
assert api.replies == [messages.STILL_PENDING]
async def test_dm_finds_a_contact_created_since_the_message_was_built(ctx, api):
# Two commands sent in quick succession both carry the pre-/dm snapshot, in
# which memberContactId is still None.
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True
)
)
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")]
stale = make_member(1, name="sh") # no memberContactId
await handlers.dm(ctx, make_group_message(api, stale, "/dm"))
assert api.created_member_contacts == []
assert api.replies == [messages.STILL_PENDING]
async def test_leave_finds_a_contact_created_since_the_message_was_built(ctx, api):
api.contacts.append(
make_contact(7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}})
)
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")]
stale = make_member(1, name="sh")
await handlers.leave(ctx, make_group_message(api, stale, "/leave"))
assert api.custom_data[-1] == (7, None)
assert api.replies == [messages.LEFT]
async def test_member_gone_takes_them_off_the_roster(ctx, api):
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh"))
assert api.custom_data[-1] == (7, None)
assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="sh")
async def test_member_gone_ignores_other_groups(ctx, api):
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
await handlers.member_gone(ctx, 999, make_member(1, contact_id=7, name="sh"))
assert api.custom_data == [] and api.sent == []
async def test_member_gone_ignores_someone_not_on_the_roster(ctx, api):
api.contacts.append(make_contact(7, "sh", connected=True))
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh"))
assert api.custom_data == [] and api.sent == []
async def test_reconcile_promotes_an_acceptance_missed_while_stopped(ctx, api):
api.members[ROSTER_GROUP_ID] = [
make_member(1, contact_id=1),
make_member(2, contact_id=2),
make_member(3, contact_id=3),
]
api.contacts += [
make_contact(
1,
"accepted",
{"supportBotLight": {"roster": "pending", "since": "2026-01-01"}},
connected=True,
),
make_contact(
2, "waiting", {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}}
),
make_contact(
3,
"already",
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
connected=True,
),
]
await handlers.reconcile_roster(ctx)
assert api.custom_data == [
(1, {"supportBotLight": {"roster": "active", "since": "2026-01-01"}})
]
assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="accepted")
async def test_contact_ready_failure_does_not_escape(ctx, api):
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, connected=True
)
)
api.fail_on.add("api_set_contact_custom_data")
await handlers.contact_ready(ctx, 7) # must not raise
assert api.sent == []
async def test_reconcile_removes_someone_who_left_while_stopped(ctx, api):
# api_list_members keeps the row and only changes its status.
api.members[ROSTER_GROUP_ID] = [
make_member(1, contact_id=5, name="stays"),
make_member(2, contact_id=7, name="gone", status="left"),
]
api.contacts += [
make_contact(
5, "stays", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
await handlers.reconcile_roster(ctx)
assert api.custom_data[-1] == (7, None)
assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="gone")
async def test_reconcile_failure_does_not_stop_startup(ctx, api):
api.fail_on.add("api_list_members")
await handlers.reconcile_roster(ctx) # must not raise
async def test_reconcile_continues_past_a_failing_contact(ctx, api):
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=5, name="stays")]
api.contacts += [
make_contact(
7, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(
8, "b", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
]
attempts: list[int] = []
async def flaky(contact_id, custom_data=None):
attempts.append(contact_id)
raise ChatCommandError("nope", {"type": "chatCmdError"})
api.api_set_contact_custom_data = flaky
await handlers.reconcile_roster(ctx)
assert attempts == [7, 8], "a failure on one contact must not abandon the rest"
async def test_dm_on_a_dead_contact_leaves_the_mark_alone(ctx, api):
api.contacts.append(
make_contact(
7,
"sh",
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
grp_member_id=None,
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.custom_data == []
assert api.replies == [messages.CONNECTION_LOST]
async def test_reconcile_skips_revocation_when_the_marker_is_ambiguous(ctx, api):
# A second marked group means ensure_roster_group may have picked the wrong
# one; deleting every mark on that basis is not recoverable.
api.groups.append(
make_group(
99,
{"displayName": "Invite roster", "fullName": ""},
custom_data={"supportBotLight": {"group": "roster"}},
)
)
api.members[ROSTER_GROUP_ID] = []
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
await handlers.reconcile_roster(ctx)
assert api.custom_data == []
async def test_reconcile_revokes_even_when_the_last_member_leaves(ctx, api):
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="gone", status="left")]
api.contacts.append(
make_contact(
7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
await handlers.reconcile_roster(ctx)
assert api.custom_data[-1] == (7, None)
async def test_dm_accepts_a_connection_the_member_started(ctx, api):
# Tapping "connect directly" on the bot's profile leaves a prepared contact
# with no contactGroupMemberId, which looks identical to a dead one.
api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="prepared"))
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert api.accepted_member_contacts == [7]
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
assert api.replies == [messages.ACCEPTING]
async def test_dm_still_reports_a_genuinely_dead_contact(ctx, api):
# No prepared connection and no groupDirectInv: nothing to accept.
api.contacts.append(make_contact(7, "sh", grp_member_id=None, conn_status="deleted"))
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.accepted_member_contacts == []
assert api.replies == [messages.CONNECTION_LOST]
async def test_dm_does_not_re_accept_a_connection_already_started(ctx, api):
# The core keeps groupDirectInv after acceptance and rejects a second
# accept with "connection already started".
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")
contact["groupDirectInv"] = {
"groupDirectInvLink": "x",
"groupDirectInvStartedConnection": True,
}
api.contacts.append(contact)
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert api.accepted_member_contacts == []
assert api.replies == [messages.ACCEPTING]
async def test_dm_accepts_an_invitation_not_yet_started(ctx, api):
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")
contact["groupDirectInv"] = {
"groupDirectInvLink": "x",
"groupDirectInvStartedConnection": False,
}
api.contacts.append(contact)
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert api.accepted_member_contacts == [7]
async def test_dm_reports_a_handshake_in_progress_as_connecting(ctx, api):
# The core clears contactGroupMemberId when the peer accepts and only later
# reports ready. Calling that gone would send the member to advice that
# tears the completing connection down.
api.contacts.append(
make_contact(
7,
"Kit",
{"supportBotLight": {"roster": "pending", "since": "x"}},
grp_member_id=None,
conn_status="accepted",
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert api.replies == [messages.CONNECTING]
assert api.accepted_member_contacts == []
async def test_dm_marks_an_unmarked_member_whose_connection_is_completing(ctx, api):
# ACCEPTING and CONNECTING both promise a roster place, and contact_ready
# delivers it only for a pending mark.
api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="accepted"))
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert roster.entry_of(api.contacts[0]) is not None
for c in api.contacts:
c["activeConn"] = {"connStatus": {"type": "ready"}}
await handlers.contact_ready(ctx, 7)
assert [e.name for e in await roster.active(api, USER_ID)] == ["Kit"]
async def test_dm_marks_an_unmarked_member_whose_accept_already_started(ctx, api):
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="joined")
contact["groupDirectInv"] = {
"groupDirectInvLink": "x",
"groupDirectInvStartedConnection": True,
}
api.contacts.append(contact)
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
await handlers.dm(ctx, msg)
assert api.replies == [messages.ACCEPTING]
assert roster.entry_of(api.contacts[0]) is not None
async def test_dm_reports_a_dead_connection_even_after_we_accepted(ctx, api):
# The core never clears groupDirectInv, so the started flag alone would
# promise progress on a connection the peer has since deleted.
contact = make_contact(7, "Alice", grp_member_id=None, conn_status="deleted")
contact["groupDirectInv"] = {
"groupDirectInvLink": "x",
"groupDirectInvStartedConnection": True,
}
api.contacts.append(contact)
msg = make_group_message(api, make_member(1, contact_id=7, name="Alice"), "/dm")
await handlers.dm(ctx, msg)
assert api.accepted_member_contacts == []
assert api.replies == [messages.CONNECTION_LOST]
async def test_dm_fast_path_announces_the_arrival(ctx, api):
api.contacts.append(make_contact(7, "sh", connected=True))
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.replies == [messages.ADDED]
assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="sh")
async def test_dm_on_an_already_active_member_announces_nothing(ctx, api):
api.contacts.append(
make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
)
)
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
await handlers.dm(ctx, msg)
assert api.sent == []
@@ -0,0 +1,236 @@
"""The monitoring endpoint: real sockets, no libsimplex."""
import asyncio
import pytest
from simplex_chat.core import ChatAPIError
from support_bot_light import health
from support_bot_light.config import Config, ConfigError, Health
from support_bot_light.context import BotContext
from tests.conftest import ROSTER_GROUP_ID, USER_ID
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
class ProbeApi:
"""The one call the probe makes, with the outcomes it has to distinguish."""
def __init__(self, error: bool = False, delay: float = 0.0):
self.error = error
self.delay = delay
self.calls = 0
async def api_list_members(self, group_id: int) -> list[dict]:
self.calls += 1
assert group_id == ROSTER_GROUP_ID
if self.delay:
await asyncio.sleep(self.delay)
if self.error:
raise ChatAPIError("core is unhappy", {"type": "chatCmdError"})
return []
def context(api) -> BotContext:
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
async def request(server: asyncio.Server, line: str) -> str:
"""Send one request line to a running endpoint and read the whole reply."""
port = server.sockets[0].getsockname()[1]
reader, writer = await asyncio.open_connection("127.0.0.1", port)
try:
writer.write(f"{line}\r\nHost: localhost\r\n\r\n".encode())
await writer.drain()
return (await reader.read()).decode("latin-1")
finally:
writer.close()
await writer.wait_closed()
async def endpoint(api) -> asyncio.Server:
# Port 0: the OS picks a free one, so tests never collide.
return await health.serve(context(api), Health(host="127.0.0.1", port=0))
async def test_reports_ok_while_the_core_answers():
api = ProbeApi()
server = await endpoint(api)
try:
reply = await request(server, "GET /health HTTP/1.1")
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 200 OK")
assert reply.endswith('{"status":"ok"}\n')
assert api.calls == 1
async def test_reports_unavailable_when_the_core_errors():
server = await endpoint(ProbeApi(error=True))
try:
reply = await request(server, "GET /health HTTP/1.1")
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 503 Service Unavailable")
async def test_a_slow_core_times_out_rather_than_hanging(monkeypatch):
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
api = ProbeApi(delay=5)
server = await endpoint(api)
try:
reply = await asyncio.wait_for(request(server, "GET /health HTTP/1.1"), 2)
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 503")
async def test_a_concurrent_request_does_not_start_a_second_probe(monkeypatch):
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.5)
api = ProbeApi(delay=0.3)
server = await endpoint(api)
try:
first = asyncio.create_task(request(server, "GET /health HTTP/1.1"))
await asyncio.sleep(0.05)
second = await request(server, "GET /health HTTP/1.1")
assert (await first).startswith("HTTP/1.1 200 OK")
finally:
server.close()
await server.wait_closed()
assert second.startswith("HTTP/1.1 200 OK") # it waits on the same probe
assert api.calls == 1
async def test_polling_a_stalled_core_never_starts_a_second_query(monkeypatch):
# Each abandoned query keeps a worker in the loop's default executor, which
# the receive loop also uses: a query per poll would take the bot's own
# traffic down with the core.
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
api = ProbeApi(delay=3)
server = await endpoint(api)
try:
for _ in range(5):
reply = await request(server, "GET /health HTTP/1.1")
assert reply.startswith("HTTP/1.1 503")
assert api.calls == 1 # one query outstanding, not five
finally:
server.close()
await server.wait_closed()
async def test_the_next_poll_after_recovery_starts_a_fresh_query(monkeypatch):
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
api = ProbeApi(delay=0.2)
server = await endpoint(api)
try:
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 503")
await asyncio.sleep(0.3) # the abandoned query completes
api.delay = 0 # the core recovers
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 200")
finally:
server.close()
await server.wait_closed()
assert api.calls == 2
async def test_a_core_that_raises_anything_reports_unavailable():
# A malformed reply or a missing controller is what this exists to report,
# and neither arrives as a chat error.
class Broken(ProbeApi):
async def api_list_members(self, group_id: int) -> list[dict]:
self.calls += 1
raise RuntimeError("controller not initialized")
api = Broken()
server = await endpoint(api)
try:
reply = await request(server, "GET /health HTTP/1.1")
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 503")
async def test_an_oversized_request_line_is_answered():
api = ProbeApi()
server = await endpoint(api)
try:
reply = await request(server, "GET /" + "x" * (health.MAX_REQUEST_BYTES + 10))
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 400")
assert api.calls == 0
async def test_head_is_answered_without_a_body():
api = ProbeApi()
server = await endpoint(api)
try:
reply = await request(server, "HEAD /health HTTP/1.1")
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 200 OK")
assert "{" not in reply
assert api.calls == 1
@pytest.mark.parametrize(
("line", "status"),
[
("GET / HTTP/1.1", "404"),
("GET /healthz HTTP/1.1", "404"),
("POST /health HTTP/1.1", "405"),
("nonsense", "404"),
],
)
async def test_only_get_on_the_health_path_is_answered(line, status):
api = ProbeApi()
server = await endpoint(api)
try:
reply = await request(server, line)
finally:
server.close()
await server.wait_closed()
assert reply.startswith(f"HTTP/1.1 {status}")
assert api.calls == 0
async def test_a_query_string_still_matches_the_path():
api = ProbeApi()
server = await endpoint(api)
try:
reply = await request(server, "GET /health?from=monitor HTTP/1.1")
finally:
server.close()
await server.wait_closed()
assert reply.startswith("HTTP/1.1 200 OK")
async def test_a_configured_port_already_in_use_stops_the_bot():
api = ProbeApi()
taken = await endpoint(api)
port = taken.sockets[0].getsockname()[1]
try:
with pytest.raises(ConfigError, match="cannot listen"):
await health.serve(context(api), Health("127.0.0.1", port, configured=True))
finally:
taken.close()
await taken.wait_closed()
async def test_the_default_port_being_in_use_does_not_stop_the_bot():
# Nothing asked for port 8080; an unrelated service on it is not a reason to
# refuse to answer chats.
api = ProbeApi()
taken = await endpoint(api)
port = taken.sockets[0].getsockname()[1]
try:
assert await health.serve(context(api), Health("127.0.0.1", port)) is None
finally:
taken.close()
await taken.wait_closed()
@@ -0,0 +1,312 @@
import pytest
from simplex_chat import Bot, BotProfile, SqliteDb
from simplex_chat.core import ChatAPIError
from support_bot_light import handlers, health
from support_bot_light.__main__ import (
_register,
_run,
_serve,
bot_profile,
build_bot,
startup_error,
)
from support_bot_light.config import Config, Health
from support_bot_light.context import BotContext
from tests.conftest import (
ROSTER_GROUP_ID,
USER_ID,
make_group,
make_group_message,
make_member,
)
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
OTHER_GROUP_ID = 99
def plain_bot() -> Bot:
return Bot(
profile=BotProfile(display_name="Support"),
db=SqliteDb(file_prefix="./unused"),
welcome="hi",
)
def registered(api) -> Bot:
bot = plain_bot()
ctx = BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
_register(bot, ctx)
return bot
def test_registers_all_four_commands(api):
bot = registered(api)
keywords = [names for names, _predicate, _handler in bot._command_handlers]
assert keywords == [("dm",), ("list",), ("leave",), ("help",)]
def test_registers_connection_and_business_events(api):
bot = registered(api)
assert set(bot._event_handlers) == {
"acceptingBusinessRequest",
"contactConnected",
"contactSndReady",
"deletedMember",
"leftMember",
}
def test_commands_match_in_the_roster_group(api):
bot = registered(api)
msg = make_group_message(api, make_member(1), "/dm", group_id=ROSTER_GROUP_ID)
_names, predicate, _handler = bot._command_handlers[0]
assert predicate(msg) is True
def test_commands_do_not_match_in_other_groups(api):
# A /dm typed inside a business chat must not be acted on.
bot = registered(api)
msg = make_group_message(api, make_member(1), "/dm", group_id=OTHER_GROUP_ID)
_names, predicate, _handler = bot._command_handlers[0]
assert predicate(msg) is False
def test_bot_profile_carries_display_name_and_image():
profile = bot_profile(
Config("Support", "./x", "hi", "R", "owner", image="data:image/png;base64,AAA")
)
assert profile.display_name == "Support"
assert profile.image == "data:image/png;base64,AAA"
def test_bot_profile_without_image():
assert bot_profile(CONFIG).image is None
@pytest.mark.parametrize("index,keyword", [(0, "dm"), (1, "list"), (2, "leave"), (3, "help")])
def test_every_command_is_scoped_to_the_roster_group(api, index, keyword):
# A /list answered in a business chat would show the roster to a customer.
bot = registered(api)
names, predicate, _handler = bot._command_handlers[index]
assert names == (keyword,)
inside = make_group_message(api, make_member(1), f"/{keyword}", group_id=ROSTER_GROUP_ID)
outside = make_group_message(api, make_member(1), f"/{keyword}", group_id=OTHER_GROUP_ID)
assert predicate(inside) is True
assert predicate(outside) is False
async def test_registered_handlers_call_the_matching_handler(api, monkeypatch):
# Registration bookkeeping alone would not catch /dm being wired to leave().
bot = registered(api)
called: list[str] = []
def spy(name):
async def handler(_ctx, _msg):
called.append(name)
return handler
for name in ("dm", "list_roster", "leave", "help_cmd"):
monkeypatch.setattr(handlers, name, spy(name))
for keywords, _predicate, handler in bot._command_handlers:
await handler(make_group_message(api, make_member(1), f"/{keywords[0]}"), None)
assert called == ["dm", "list_roster", "leave", "help_cmd"]
def test_a_taken_display_name_is_explained():
# The core reports it as a bare errorStore; the cause is in the store error.
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "duplicateName"}})
assert "bot.display_name" in startup_error(e)
def test_any_other_chat_error_keeps_its_detail():
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "userNotFound"}})
assert "userNotFound" in startup_error(e)
def test_a_rejected_command_is_quoted_as_the_core_wrote_it():
# The core puts what the caller did wrong in the message, and the tag says
# nothing; printing the raw dict instead would bury it.
e = ChatAPIError(
"chat command error: error",
{"type": "error", "errorType": {"type": "commandError", "message": "Profile image"}},
)
assert startup_error(e) == "Profile image"
def test_an_error_without_detail_is_rendered_plainly():
assert startup_error(ValueError("no active user after start")) == "no active user after start"
def test_the_bot_opens_a_business_address():
# Without these two the address yields direct chats that nothing handles:
# acceptingBusinessRequest never fires and no roster is ever added.
bot = build_bot(CONFIG)
assert bot._business_address is True
assert bot._auto_accept is True
assert bot._welcome == "hi"
def test_the_bot_does_not_apply_its_profile_while_starting():
# The name the core will accept is only knowable from the database, which
# nothing can read until the client has started. _apply_profile does it.
assert build_bot(CONFIG)._update_profile is False
class FakeBot:
"""A Bot stand-in for _serve: an async context manager with an api."""
def __init__(self, api, sync_error: Exception | None = None):
self.api = api
self.profile = BotProfile(display_name="Support")
self.served = 0
self.syncs = 0
self.sync_error = sync_error
self.signal_handlers = 0
self._command_handlers = []
self._event_handlers = {}
self.stop_requested = False
self.stopped = False
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
def install_signal_handlers(self):
self.signal_handlers += 1
async def sync_profile(self) -> bool:
self.syncs += 1
if self.sync_error is not None:
raise self.sync_error
return True
def on_command(self, *_names, **_kw):
def register(handler):
self._command_handlers.append(handler)
return handler
return register
def on_event(self, tag):
def register(handler):
self._event_handlers.setdefault(tag, []).append(handler)
return handler
return register
async def serve_forever(self):
self.served += 1
def stop(self):
self.stopped = True
def serve_api(api):
"""The fake api with the calls _serve makes before serving."""
async def api_get_active_user():
return {"userId": USER_ID, "localDisplayName": "Support"}
api.api_get_active_user = api_get_active_user
api.group_links[ROSTER_GROUP_ID] = "https://example.invalid/g#x"
api.groups.append(
make_group(
ROSTER_GROUP_ID,
{"displayName": "Invite roster", "fullName": ""},
{"supportBotLight": {"group": "roster"}},
)
)
return api
async def test_serve_wires_the_handlers_and_serves(api):
bot = FakeBot(serve_api(api))
await _serve(CONFIG, bot)
assert bot.served == 1
assert len(bot._command_handlers) == 4 # nothing is delivered without these
assert set(bot._event_handlers) == {
"acceptingBusinessRequest",
"contactConnected",
"contactSndReady",
"deletedMember",
"leftMember",
}
async def test_serve_reads_the_group_listing_once(api):
# It is the largest thing startup marshals and grows with every customer.
bot = FakeBot(serve_api(api))
calls = {"n": 0}
original = api.api_list_groups
async def counted(user_id, **kw):
calls["n"] += 1
return await original(user_id, **kw)
api.api_list_groups = counted
await _serve(CONFIG, bot)
assert calls["n"] == 2 # one for discovery, one shared by both passes
async def test_serve_does_not_begin_serving_after_a_signal(api):
bot = FakeBot(serve_api(api))
bot.stop_requested = True
await _serve(CONFIG, bot)
assert bot.served == 0
async def test_serve_closes_the_health_endpoint_afterwards(api):
config = Config("Support", "./x", "hi", "Invite roster", "owner", health=Health("127.0.0.1", 0))
bot = FakeBot(serve_api(api))
servers: list = []
original = health.serve
async def spy(ctx, cfg):
server = await original(ctx, cfg)
servers.append(server)
return server
health.serve = spy
try:
await _serve(config, bot)
finally:
health.serve = original
assert servers and not servers[0].is_serving()
async def test_the_bot_serves_after_a_refused_rename(api, caplog):
# The core keeps display names unique; a refused one is not a reason to
# leave customers unanswered.
refused = ChatAPIError("x", {"storeError": {"type": "duplicateName"}})
bot = FakeBot(serve_api(api), sync_error=refused)
await _serve(CONFIG, bot)
assert bot.served == 1
assert "bot.display_name" in caplog.text
async def test_the_profile_is_applied_after_start(api, monkeypatch):
bot = FakeBot(serve_api(api))
await _serve(CONFIG, bot)
assert bot.syncs == 1
async def test_run_installs_signal_handlers_before_starting(monkeypatch):
# Startup runs migrations and address creation; a signal there would
# otherwise kill the process mid-write.
order: list[str] = []
bot = FakeBot(None)
def build(_config):
return bot
async def serve(_config, b):
order.append(f"serve:{b.signal_handlers}")
monkeypatch.setattr("support_bot_light.__main__.build_bot", build)
monkeypatch.setattr("support_bot_light.__main__._serve", serve)
await _run(CONFIG)
assert order == ["serve:1"]
@@ -0,0 +1,85 @@
from support_bot_light import messages
from support_bot_light.roster import RosterEntry
def entry(name, state, since="2026-08-13T09:00:00+00:00", reachable=True):
return RosterEntry(contact_id=1, name=name, state=state, since=since, reachable=reachable)
def test_render_roster_lists_active_and_pending():
out = messages.render_roster([entry("sh", "active"), entry("Alex", "pending")])
assert "On the roster (1):" in out
assert "• sh — since 2026-08-13" in out
assert "Contact request not accepted (1):" in out
assert "• Alex — asked 2026-08-13" in out
def test_render_roster_empty():
assert messages.render_roster([]) == messages.ROSTER_EMPTY
def test_render_roster_omits_pending_section_when_none():
out = messages.render_roster([entry("sh", "active")])
assert "Waiting" not in out
def test_render_roster_omits_date_suffix_when_since_is_empty():
out = messages.render_roster([entry("sh", "active", since="")])
assert out == "On the roster (1):\n • sh"
assert "since" not in out
def test_render_roster_formats_date_suffix():
out = messages.render_roster([entry("sh", "active")])
assert out == "On the roster (1):\n • sh — since 2026-08-13"
def test_invite_log_lists_added_names():
assert messages.invite_log("Alex", ["sh", "Narasimha"], []) == (
"Connected: Alex → added sh, Narasimha"
)
def test_invite_log_reports_failures():
line = messages.invite_log("Alex", ["sh"], ["Narasimha"])
assert line == "Connected: Alex → added sh (failed: Narasimha)"
def test_help_mentions_every_command():
for keyword in ("dm", "list", "leave"):
assert f"/{keyword}" in messages.HELP
def test_render_roster_separates_unreachable_members():
out = messages.render_roster(
[entry("live", "active"), entry("dead", "active", reachable=False)]
)
assert "On the roster (1):" in out
assert "Not reachable, not being added (1):" in out
assert "• dead" in out
def test_render_roster_caps_long_sections():
entries = [entry(f"n{i}", "active") for i in range(messages.MAX_LISTED + 12)]
out = messages.render_roster(entries)
assert f"On the roster ({messages.MAX_LISTED + 12}):" in out
assert "… and 12 more" in out
assert out.count("•") == messages.MAX_LISTED
assert len(out.encode()) < 15000
def test_render_roster_bounds_the_whole_reply_in_bytes():
# Names are capped in characters, so CJK can overrun a byte limit even with
# every section capped.
entries = [entry("漢" * 50, "active") for _ in range(messages.MAX_LISTED)]
entries += [entry("漢" * 50, "pending") for _ in range(messages.MAX_LISTED)]
out = messages.render_roster(entries)
assert len(out.encode()) <= messages.MAX_REPLY_BYTES
assert out.endswith(messages.TRUNCATED)
def test_invite_log_is_bounded_in_bytes():
names = ["漢" * 50 for _ in range(100)]
out = messages.invite_log("Alex", names, [])
assert len(out.encode()) <= messages.MAX_REPLY_BYTES
assert out.endswith(messages.TRUNCATED)
@@ -0,0 +1,154 @@
from support_bot_light import roster
from tests.conftest import USER_ID, make_contact
def test_entry_of_reads_active_mark():
contact = make_contact(
7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-08-13"}}
)
entry = roster.entry_of(contact)
assert entry is not None
assert (entry.contact_id, entry.name, entry.state, entry.since) == (
7,
"sh",
"active",
"2026-08-13",
)
def test_entry_of_returns_none_without_custom_data():
assert roster.entry_of(make_contact(7, "sh")) is None
def test_entry_of_ignores_other_namespaces():
assert roster.entry_of(make_contact(7, "sh", {"otherBot": {"roster": "active"}})) is None
def test_entry_of_ignores_unknown_state():
contact = make_contact(7, "sh", {"supportBotLight": {"roster": "banned"}})
assert roster.entry_of(contact) is None
def test_entry_of_ignores_non_dict_mark():
assert roster.entry_of(make_contact(7, "sh", {"supportBotLight": "oops"})) is None
async def test_mark_preserves_other_keys(api):
contact = make_contact(7, "sh", {"otherBot": {"keep": 1}})
api.contacts.append(contact)
await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00")
contact_id, data = api.custom_data[-1]
assert contact_id == 7
assert data["otherBot"] == {"keep": 1}
assert data["supportBotLight"] == {"roster": "active", "since": "2026-08-13T09:00:00+00:00"}
async def test_mark_does_not_mutate_the_callers_contact(api):
original = {"otherBot": {"keep": 1}}
contact = make_contact(7, "sh", original)
api.contacts.append(contact)
await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00")
# mark() builds a new blob; the caller's dict must be untouched.
assert original == {"otherBot": {"keep": 1}}
assert "supportBotLight" not in original
async def test_unmark_removes_only_our_key(api):
contact = make_contact(
7, "sh", {"supportBotLight": {"roster": "active"}, "otherBot": {"keep": 1}}
)
api.contacts.append(contact)
await roster.unmark(api, contact)
assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}})
async def test_unmark_clears_blob_when_nothing_left(api):
contact = make_contact(7, "sh", {"supportBotLight": {"roster": "active"}})
api.contacts.append(contact)
await roster.unmark(api, contact)
# None clears the column rather than writing an empty object.
assert api.custom_data[-1] == (7, None)
async def test_unmark_of_an_unmarked_contact_takes_nothing_away(api):
contact = make_contact(7, "sh", {"otherBot": {"keep": 1}})
api.contacts.append(contact)
await roster.unmark(api, contact)
assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}})
async def test_load_returns_marked_contacts_sorted_case_insensitively(api):
api.contacts += [
make_contact(1, "Zoe", {"supportBotLight": {"roster": "active", "since": "x"}}),
make_contact(2, "bob", {"supportBotLight": {"roster": "pending", "since": "x"}}),
make_contact(3, "unmarked"),
]
entries = await roster.load(api, USER_ID)
assert [e.name for e in entries] == ["bob", "Zoe"]
async def test_active_filters_pending(api):
api.contacts += [
make_contact(
1, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(2, "b", {"supportBotLight": {"roster": "pending", "since": "x"}}),
]
assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1]
async def test_find_contact_returns_none_when_absent(api):
assert await roster.find_contact(api, USER_ID, 99) is None
async def test_find_contact_returns_match(api):
api.contacts.append(make_contact(7, "sh"))
found = await roster.find_contact(api, USER_ID, 7)
assert found is not None and found["contactId"] == 7
def test_utc_now_is_iso_with_offset():
now = roster.utc_now()
assert now.endswith("+00:00") and "T" in now
async def test_active_excludes_a_marked_contact_that_is_no_longer_usable(api):
# The person deleted the bot: the mark survives but api_add_member would
# fail for them on every business chat.
api.contacts += [
make_contact(
1, "live", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
),
make_contact(2, "dead", {"supportBotLight": {"roster": "active", "since": "x"}}),
]
assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1]
def test_entry_name_is_sanitised():
contact = make_contact(7, "a\nb", {"supportBotLight": {"roster": "active", "since": "x"}})
entry = roster.entry_of(contact)
assert entry is not None
assert entry.name == "a b"
def test_entry_of_survives_a_contact_with_no_profile():
entry = roster.entry_of(
{"contactId": 7, "customData": {"supportBotLight": {"roster": "active", "since": "x"}}}
)
assert entry is not None
assert entry.name == "(unnamed)"
def test_contact_name_prefers_the_local_display_name():
# The core makes localDisplayName unique per user; two peers calling
# themselves "sh" render as "sh" and "sh_1", which is what the roster and
# the log must show.
contact = make_contact(1, "sh_1")
contact["profile"]["displayName"] = "sh"
assert roster.contact_name(contact) == "sh_1"
def test_contact_name_falls_back_to_the_profile():
contact = make_contact(1, "sh")
del contact["localDisplayName"]
assert roster.contact_name(contact) == "sh"
@@ -0,0 +1,224 @@
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
@@ -0,0 +1,50 @@
from support_bot_light.text import MAX_NAME, UNNAMED, safe_name
def test_collapses_newlines_so_a_name_cannot_forge_a_line():
assert safe_name("AAA\n • ceo@example.com — since 2020-01-01") == (
"AAA • ceo@example.com — since 2020-01-01"
)
assert "\n" not in safe_name("a\r\nb\tc")
def test_truncates_a_long_name():
out = safe_name("X" * 14000)
assert len(out) == MAX_NAME
assert out.endswith("…")
def test_strips_non_printable_characters():
assert safe_name("bob\x00\x07") == "bob"
def test_blank_and_whitespace_only_names():
assert safe_name("") == UNNAMED
assert safe_name(" \n ") == UNNAMED
def test_leaves_an_ordinary_name_alone():
assert safe_name("Narasimha") == "Narasimha"
def test_strips_invisible_but_printable_characters():
# Hangul fillers and Braille blanks are Lo/So, so isprintable() lets them
# through while they render as nothing.
assert safe_name("\u3164\u3164Alice") == "Alice"
assert safe_name("\u115f\u1160Alice") == "Alice"
assert safe_name("\u2800Alice") == "Alice"
assert safe_name("\u3164" * 10) == UNNAMED
def test_normalises_compatibility_forms():
assert safe_name("\uff21lice") == "Alice"
def test_leaves_names_in_other_scripts_alone():
for name in (
"\uae40\ucca0\uc218",
"Nguy\u1ec5n",
"\u0645\u062d\u0645\u062f",
"Jos\u00e9 M\u00fcller",
):
assert safe_name(name) == name