diff --git a/apps/simplex-support-bot-light/.gitignore b/apps/simplex-support-bot-light/.gitignore new file mode 100644 index 0000000000..b8fdfac347 --- /dev/null +++ b/apps/simplex-support-bot-light/.gitignore @@ -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 diff --git a/apps/simplex-support-bot-light/Dockerfile b/apps/simplex-support-bot-light/Dockerfile new file mode 100644 index 0000000000..66427be1a0 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile @@ -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"] diff --git a/apps/simplex-support-bot-light/Dockerfile.dockerignore b/apps/simplex-support-bot-light/Dockerfile.dockerignore new file mode 100644 index 0000000000..b27b4a7eb5 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile.dockerignore @@ -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 diff --git a/apps/simplex-support-bot-light/README.md b/apps/simplex-support-bot-light/README.md new file mode 100644 index 0000000000..058f539d3f --- /dev/null +++ b/apps/simplex-support-bot-light/README.md @@ -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 `_chat.db` and `_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) diff --git a/apps/simplex-support-bot-light/bot-config/config.toml.example b/apps/simplex-support-bot-light/bot-config/config.toml.example new file mode 100644 index 0000000000..08228a47b3 --- /dev/null +++ b/apps/simplex-support-bot-light/bot-config/config.toml.example @@ -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 _chat.db and +# _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 diff --git a/apps/simplex-support-bot-light/config.toml.example b/apps/simplex-support-bot-light/config.toml.example new file mode 100644 index 0000000000..7c7b3e6942 --- /dev/null +++ b/apps/simplex-support-bot-light/config.toml.example @@ -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 _chat.db and _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 diff --git a/apps/simplex-support-bot-light/docker-compose.yml b/apps/simplex-support-bot-light/docker-compose.yml new file mode 100644 index 0000000000..3928bf201b --- /dev/null +++ b/apps/simplex-support-bot-light/docker-compose.yml @@ -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" diff --git a/apps/simplex-support-bot-light/pyproject.toml b/apps/simplex-support-bot-light/pyproject.toml new file mode 100644 index 0000000000..5788403b66 --- /dev/null +++ b/apps/simplex-support-bot-light/pyproject.toml @@ -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*"] diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__init__.py b/apps/simplex-support-bot-light/src/support_bot_light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__main__.py b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py new file mode 100644 index 0000000000..a7476643a2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py @@ -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()) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/business.py b/apps/simplex-support-bot-light/src/support_bot_light/business.py new file mode 100644 index 0000000000..9e95c0cff2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/business.py @@ -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) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/commands.py b/apps/simplex-support-bot-light/src/support_bot_light/commands.py new file mode 100644 index 0000000000..c081a40e37 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/commands.py @@ -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 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/config.py b/apps/simplex-support-bot-light/src/support_bot_light/config.py new file mode 100644 index 0000000000..79dd3bfefb --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/config.py @@ -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/;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 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/context.py b/apps/simplex-support-bot-light/src/support_bot_light/context.py new file mode 100644 index 0000000000..d168763557 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/context.py @@ -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) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/handlers.py b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py new file mode 100644 index 0000000000..6870c57126 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py @@ -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) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/health.py b/apps/simplex-support-bot-light/src/support_bot_light/health.py new file mode 100644 index 0000000000..dbef16306e --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/health.py @@ -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 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/messages.py b/apps/simplex-support-bot-light/src/support_bot_light/messages.py new file mode 100644 index 0000000000..f6e136e7ba --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/messages.py @@ -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) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/roster.py b/apps/simplex-support-bot-light/src/support_bot_light/roster.py new file mode 100644 index 0000000000..1fc85369f4 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/roster.py @@ -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 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/setup.py b/apps/simplex-support-bot-light/src/support_bot_light/setup.py new file mode 100644 index 0000000000..b7be384abd --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/setup.py @@ -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"]) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/text.py b/apps/simplex-support-bot-light/src/support_bot_light/text.py new file mode 100644 index 0000000000..e66bb44c10 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/text.py @@ -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 diff --git a/apps/simplex-support-bot-light/state/.gitkeep b/apps/simplex-support-bot-light/state/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/__init__.py b/apps/simplex-support-bot-light/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/conftest.py b/apps/simplex-support-bot-light/tests/conftest.py new file mode 100644 index 0000000000..d13e012589 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/conftest.py @@ -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() diff --git a/apps/simplex-support-bot-light/tests/test_boundaries.py b/apps/simplex-support-bot-light/tests/test_boundaries.py new file mode 100644 index 0000000000..b34158c336 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_boundaries.py @@ -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, + ] diff --git a/apps/simplex-support-bot-light/tests/test_business.py b/apps/simplex-support-bot-light/tests/test_business.py new file mode 100644 index 0000000000..c555cfbf3f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_business.py @@ -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 diff --git a/apps/simplex-support-bot-light/tests/test_commands.py b/apps/simplex-support-bot-light/tests/test_commands.py new file mode 100644 index 0000000000..83c435aacb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_commands.py @@ -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="")]) + assert wire == [{"type": "command", "keyword": "x", "label": "X", "params": ""}] + + +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] diff --git a/apps/simplex-support-bot-light/tests/test_config.py b/apps/simplex-support-bot-light/tests/test_config.py new file mode 100644 index 0000000000..e3c3a7fed5 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_config.py @@ -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) diff --git a/apps/simplex-support-bot-light/tests/test_handlers.py b/apps/simplex-support-bot-light/tests/test_handlers.py new file mode 100644 index 0000000000..dca98dc2d7 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_handlers.py @@ -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 == [] diff --git a/apps/simplex-support-bot-light/tests/test_health.py b/apps/simplex-support-bot-light/tests/test_health.py new file mode 100644 index 0000000000..d818465175 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_health.py @@ -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() diff --git a/apps/simplex-support-bot-light/tests/test_main.py b/apps/simplex-support-bot-light/tests/test_main.py new file mode 100644 index 0000000000..c49b01dbcb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_main.py @@ -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"] diff --git a/apps/simplex-support-bot-light/tests/test_messages.py b/apps/simplex-support-bot-light/tests/test_messages.py new file mode 100644 index 0000000000..e9b73ab8af --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_messages.py @@ -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) diff --git a/apps/simplex-support-bot-light/tests/test_roster.py b/apps/simplex-support-bot-light/tests/test_roster.py new file mode 100644 index 0000000000..a4dd17f2b3 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_roster.py @@ -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" diff --git a/apps/simplex-support-bot-light/tests/test_setup.py b/apps/simplex-support-bot-light/tests/test_setup.py new file mode 100644 index 0000000000..41c383478f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_setup.py @@ -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 diff --git a/apps/simplex-support-bot-light/tests/test_text.py b/apps/simplex-support-bot-light/tests/test_text.py new file mode 100644 index 0000000000..b9a4602b58 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_text.py @@ -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