mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 02:21:08 +00:00
feat: add simplex-support-bot-light (#7378)
* feat: add simplex-support-bot-light * feat: package simplex-support-bot-light for docker * docs: document simplex-support-bot-light * fix: make a missing config actionable and bounded * docs: note the attached compose Ctrl+C behaviour * refactor: use the library for startup and custom data * refactor: drop the display-name workaround * style: shorten the startup comment * build: build the core and the library into the image * docs: correct where the image type rule comes from * fix: log what the core said a command got wrong * build: run the container as the operator's uid * docs: keep the container uid in .env * docs: tighten the README
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,130 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Built from the repository root, not this directory: the image is made from the
|
||||
# Haskell core and the Python library in this tree, neither of them released.
|
||||
#
|
||||
# docker compose build # from apps/simplex-support-bot-light
|
||||
# docker build -f apps/simplex-support-bot-light/Dockerfile .
|
||||
#
|
||||
# The first stage compiles libsimplex from src/. That is a full GHC build of
|
||||
# simplexmq and simplex-chat: hours on a cold cache, and it needs ~15 GB.
|
||||
|
||||
ARG UBUNTU=24.04
|
||||
# The released libs are built on 22.04; a lib built here has to load on a runtime
|
||||
# with the same glibc or newer, not the other way round.
|
||||
ARG UBUNTU_LIBS=22.04
|
||||
ARG GHC=9.6.3
|
||||
ARG CABAL=3.10.2.0
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# libsimplex — the cabal invocation of scripts/desktop/build-lib-linux.sh, which
|
||||
# is what produces the .so the published libs archive is repackaged from.
|
||||
# --------------------------------------------------------------------------- #
|
||||
FROM ubuntu:${UBUNTU_LIBS} AS libsimplex
|
||||
|
||||
ARG GHC
|
||||
ARG CABAL
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl git libgmp3-dev libnuma-dev \
|
||||
libsqlite3-dev libssl-dev llvm pkg-config zlib1g-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
|
||||
BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} \
|
||||
BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} \
|
||||
BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true \
|
||||
BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
|
||||
ENV PATH="/root/.ghcup/bin:/root/.cabal/bin:$PATH"
|
||||
# Explicit, so the cache mount below is where cabal actually keeps its store.
|
||||
ENV CABAL_DIR=/root/.cabal
|
||||
|
||||
WORKDIR /src
|
||||
COPY cabal.project simplex-chat.cabal README.md PRIVACY.md ./
|
||||
COPY scripts/cabal.project.local.linux ./cabal.project.local
|
||||
COPY src ./src
|
||||
|
||||
# Cache mounts, not layers: the Haskell store and the build tree survive a
|
||||
# source change, which is the difference between minutes and hours. The RTS and
|
||||
# package libraries are copied next to libsimplex.so because its rpath is $ORIGIN.
|
||||
RUN --mount=type=cache,target=/root/.cabal \
|
||||
--mount=type=cache,target=/src/dist-newstyle \
|
||||
set -eu; \
|
||||
cabal update; \
|
||||
cabal build lib:simplex-chat \
|
||||
--ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' \
|
||||
--constraint 'simplexmq +client_library' \
|
||||
--constraint 'simplex-chat +client_library'; \
|
||||
lib=$(ls -t /src/dist-newstyle/build/*/ghc-${GHC}/simplex-chat-*/build/libHSsimplex-chat-*-inplace-ghc${GHC}.so | head -1); \
|
||||
build_dir=$(dirname "$lib"); \
|
||||
mv "$lib" "$build_dir/libsimplex.so"; \
|
||||
mkdir -p /libs; \
|
||||
ldd "$build_dir/libsimplex.so" | grep ghc | cut -d' ' -f 3 | xargs -I {} cp {} /libs/; \
|
||||
cp "$build_dir/libsimplex.so" /libs/
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the bot
|
||||
# --------------------------------------------------------------------------- #
|
||||
# libsimplex is a glibc build and will not load on musl, and it is compiled
|
||||
# against this image's libraries in the stage above.
|
||||
FROM ubuntu:${UBUNTU}
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl dumb-init libffi8 libgmp10 libnuma1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# The ids that must own ./state on the host; a bind mount keeps host ownership.
|
||||
# Build with your own to avoid needing root to read the bot's state:
|
||||
# USER_UID=$(id -u) USER_GID=$(id -g) docker compose build
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
# ubuntu:24.04 ships a default user at 1000, so drop whoever holds the ids.
|
||||
RUN if existing_user=$(getent passwd ${USER_UID} | cut -d: -f1) && [ -n "${existing_user}" ]; then \
|
||||
userdel -r "${existing_user}" 2>/dev/null || userdel "${existing_user}"; \
|
||||
fi && \
|
||||
if existing_group=$(getent group ${USER_GID} | cut -d: -f1) && [ -n "${existing_group}" ]; then \
|
||||
groupdel "${existing_group}" 2>/dev/null || true; \
|
||||
fi && \
|
||||
groupadd -g ${USER_GID} supportbot && \
|
||||
useradd -u ${USER_UID} -g ${USER_GID} -m -d /home/supportbot supportbot
|
||||
|
||||
# Applies only when /data is not bind-mounted; a bind mount keeps the host
|
||||
# directory's ownership and mode.
|
||||
RUN mkdir -p /data && chown supportbot:supportbot /data && chmod 0700 /data
|
||||
|
||||
# Read by simplex_chat._native instead of downloading a release, which is what
|
||||
# makes the bot run against the core built above rather than the published one.
|
||||
COPY --from=libsimplex /libs /opt/simplex/libs
|
||||
ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs
|
||||
|
||||
USER supportbot
|
||||
WORKDIR /home/supportbot
|
||||
|
||||
ENV VIRTUAL_ENV=/home/supportbot/.venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# The library is installed from this tree: the APIs the bot uses
|
||||
# (install_signal_handlers, sync_profile, api_merge_*_custom_data) are unreleased.
|
||||
COPY --chown=supportbot:supportbot packages/simplex-chat-python /home/supportbot/simplex-chat-python
|
||||
RUN uv venv --python 3.12 "$VIRTUAL_ENV" && \
|
||||
uv pip install /home/supportbot/simplex-chat-python
|
||||
|
||||
COPY --chown=supportbot:supportbot apps/simplex-support-bot-light /home/supportbot/app
|
||||
RUN uv pip install -e /home/supportbot/app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# No HEALTHCHECK: /health is published for an external monitor, and a container
|
||||
# check would restart a bot whose chat controller is merely slow.
|
||||
|
||||
# Exec form: shell form would run under `sh -c`, which does not forward SIGTERM
|
||||
# to the bot, so the graceful-stop path in __main__.py would never fire.
|
||||
ENTRYPOINT ["dumb-init", "--", "support-bot-light", "--config", "/etc/support-bot-light/config.toml"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# The build context is the repository root. Exclude everything, then add back
|
||||
# what the image is built from, one directory level at a time.
|
||||
*
|
||||
|
||||
!cabal.project
|
||||
!simplex-chat.cabal
|
||||
!README.md
|
||||
!PRIVACY.md
|
||||
!src
|
||||
|
||||
!scripts
|
||||
scripts/*
|
||||
!scripts/cabal.project.local.linux
|
||||
|
||||
!packages
|
||||
packages/*
|
||||
!packages/simplex-chat-python
|
||||
|
||||
!apps
|
||||
apps/*
|
||||
!apps/simplex-support-bot-light
|
||||
|
||||
# Build output, caches, and anything holding an identity or a secret.
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/*.egg-info
|
||||
**/.venv
|
||||
**/.venv-*
|
||||
**/.pytest_cache
|
||||
**/.ruff_cache
|
||||
**/.pyright_cache
|
||||
**/.mypy_cache
|
||||
**/dist
|
||||
**/*.db
|
||||
**/*.db-*
|
||||
apps/simplex-support-bot-light/plans
|
||||
apps/simplex-support-bot-light/state
|
||||
apps/simplex-support-bot-light/bot-config
|
||||
apps/simplex-support-bot-light/config.toml
|
||||
apps/simplex-support-bot-light/.env
|
||||
@@ -0,0 +1,148 @@
|
||||
# simplex-support-bot-light
|
||||
|
||||
A [SimpleX Chat](https://simplex.chat) bot that adds a roster of people to
|
||||
incoming business chats.
|
||||
|
||||
Anyone who connects to the bot's address gets a business chat with a welcome
|
||||
message, and every active roster member is added to it. People join the roster
|
||||
themselves, from a command menu in a separate roster group.
|
||||
|
||||
## Docker
|
||||
|
||||
From `apps/simplex-support-bot-light`:
|
||||
|
||||
```bash
|
||||
cp bot-config/config.toml.example bot-config/config.toml # required; edit before starting
|
||||
printf 'USER_UID=%s\nUSER_GID=%s\n' "$(id -u)" "$(id -g)" > .env # see Ownership
|
||||
chmod 0700 state
|
||||
docker compose up --build -d
|
||||
docker compose logs -f support-bot-light
|
||||
```
|
||||
|
||||
Use the template in `bot-config/`, whose paths are container paths, not the
|
||||
top-level one. Place the avatar beside it if `bot.image` is set.
|
||||
|
||||
| Path | Mount | Notes |
|
||||
| --- | --- | --- |
|
||||
| `./bot-config` | `/etc/support-bot-light` (read-only) | `bot.image` resolves against this directory. |
|
||||
| `./state` | `/data` | All bot state. `bot.db_prefix` must point here. |
|
||||
|
||||
The monitoring endpoint is published on `127.0.0.1:8080`, and the container
|
||||
config must set `health.host = "0.0.0.0"`, as the template does.
|
||||
|
||||
Run detached. Under an attached `docker compose up`, Ctrl+C stops the container
|
||||
but compose re-attaches it; press Ctrl+C twice or use
|
||||
`--abort-on-container-exit`.
|
||||
|
||||
### State directory
|
||||
|
||||
`./state` holds the bot's identity and address. Deleting it produces a new
|
||||
address and a new roster group, and every roster member must repeat the
|
||||
handshake. Back it up.
|
||||
|
||||
It must be owned by the uid the container runs as, set in `.env`. Both ids
|
||||
default to 1000; root is not supported. `chmod 0700` it on a shared host, since
|
||||
the databases hold the bot's identity keys.
|
||||
|
||||
## Manual installation
|
||||
|
||||
```bash
|
||||
uv venv && uv pip install -e ../../packages/simplex-chat-python && uv pip install -e '.[dev]'
|
||||
cp config.toml.example config.toml
|
||||
uv run support-bot-light --config config.toml
|
||||
```
|
||||
|
||||
The library is installed from this repository, since the APIs the bot uses are
|
||||
unreleased. `libsimplex` is downloaded on first use unless `SIMPLEX_LIBS_DIR`
|
||||
points at a local build.
|
||||
|
||||
`--config` defaults to `config.toml` in the working directory. `Ctrl+C` stops
|
||||
the bot; a second `Ctrl+C` exits immediately.
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.toml.example` is the committed template; `config.toml` is gitignored.
|
||||
|
||||
| Key | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `bot.display_name` | yes | Name shown to anyone who connects. |
|
||||
| `bot.image` | no | Profile image path (`.png`, `.jpg`, `.jpeg`). Relative paths resolve against the directory containing `config.toml`. The encoded image must not exceed 12500 characters, roughly a 128x128 avatar. |
|
||||
| `bot.db_prefix` | yes | SQLite path prefix. Creates `<prefix>_chat.db` and `<prefix>_agent.db`. Under Docker it must point inside `/data`. |
|
||||
| `bot.welcome` | yes | Message posted into each new business chat, sent as the address auto-reply. Multi-line TOML strings are supported. |
|
||||
| `roster.group_name` | yes | Name of the roster group, applied when it is created. |
|
||||
| `roster.member_role` | no | Role roster members receive in business chats: `observer`, `author`, `member`, `moderator`, `admin` or `owner`. Defaults to `owner`. |
|
||||
| `health.enabled` | no | Set `false` to switch the monitoring endpoint off. On by default. |
|
||||
| `health.host` | no | Interface the endpoint binds. Defaults to `127.0.0.1`; `0.0.0.0` under Docker. |
|
||||
| `health.port` | no | Port for the endpoint. Defaults to `8080`. Setting either key makes a bind failure fatal. |
|
||||
|
||||
Changing `bot.welcome` or `bot.image` applies on the next start.
|
||||
|
||||
The first start logs two links: the business address, for customers, and the
|
||||
roster group link, for people who should answer. Anyone who joins the roster
|
||||
group can add themselves to every incoming chat.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The bot serves `GET /health` unless `health.enabled` is `false`:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200 {"status":"ok"}` | The core answered a query against the roster group. |
|
||||
| `503 {"status":"unavailable"}` | It returned an error, or did not answer within 5 seconds. |
|
||||
|
||||
A bot whose messaging servers are unreachable still answers `200`.
|
||||
|
||||
There is no authentication. Bind it to `127.0.0.1`, or to an interface only the
|
||||
monitoring system can reach. If `health.host` or `health.port` is set and the
|
||||
address cannot be bound, the bot exits; otherwise a busy default port only logs
|
||||
a warning.
|
||||
|
||||
## Commands
|
||||
|
||||
Available in the roster group.
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `/dm` | Join the roster. If the bot has no direct contact, it sends a contact request first; membership becomes active once that request is accepted. |
|
||||
| `/list` | List active members, members who are no longer reachable, and those pending a contact request. |
|
||||
| `/leave` | Leave the roster. Chats already joined are unaffected. |
|
||||
| `/help` | Summarise the above. |
|
||||
|
||||
Leaving the roster group, or being removed from it, also takes a member off the
|
||||
roster. The bot is the group's only owner, so removing another member requires a
|
||||
client signed in as the bot.
|
||||
|
||||
## State
|
||||
|
||||
All state is in the databases at `bot.db_prefix`. Roster membership is stored in
|
||||
each contact's `custom_data`, and the roster group is found by a marker in the
|
||||
group's `custom_data` rather than by name.
|
||||
|
||||
Startup reconciles what downtime missed: acceptances that arrived while the bot
|
||||
was stopped, members who left the roster group, and business chats left without
|
||||
their roster members.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
ruff check && ruff format --check src tests && pyright && pytest tests/ -v
|
||||
```
|
||||
|
||||
Scope `ruff format` to `src tests`. An unscoped run also reformats Python
|
||||
fenced inside markdown files.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Joining the roster never grants access to earlier conversations, including
|
||||
chats a returning customer reopens.
|
||||
- `bot.display_name` cannot be changed to a name any contact, group or past
|
||||
customer already holds. The bot logs this and keeps its current name.
|
||||
- Every active member is added to every incoming chat. There is no routing or
|
||||
per-customer selection.
|
||||
- There is no command to remove someone else from the roster, and `/leave` does
|
||||
not remove anyone from chats they have already joined.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../LICENSE)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copy to ./bot-config/config.toml. Paths here are container paths; use the
|
||||
# top-level config.toml.example when running the bot directly on the host.
|
||||
|
||||
[bot]
|
||||
display_name = "Support"
|
||||
# Optional. .png, .jpg or .jpeg, resolved against the directory holding this
|
||||
# file. The encoded data URI must not exceed 12500 characters, roughly a
|
||||
# 128x128 avatar.
|
||||
# image = "./avatar.png"
|
||||
# Must be under /data (bind-mounted from ./state). Creates <prefix>_chat.db and
|
||||
# <prefix>_agent.db, which hold the bot's identity.
|
||||
db_prefix = "/data/support_bot_light"
|
||||
welcome = "Hi! Someone from the team will join this chat in a moment."
|
||||
|
||||
[roster]
|
||||
# Renaming the group later has no effect: it is found by a marker in its custom
|
||||
# data, not by name.
|
||||
group_name = "Invite roster"
|
||||
# One of: observer, author, member, moderator, admin, owner. "relay" is also
|
||||
# accepted by the core but is an infrastructure role.
|
||||
member_role = "owner"
|
||||
|
||||
# Monitoring endpoint: GET /health answers 200 while the chat controller
|
||||
# responds to a command, 503 when it does not. It must bind 0.0.0.0 to be
|
||||
# reachable through the published port; docker-compose.yml publishes it on the
|
||||
# host loopback, because the endpoint has no authentication.
|
||||
[health]
|
||||
# enabled = false
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
@@ -0,0 +1,25 @@
|
||||
[bot]
|
||||
display_name = "Support"
|
||||
# Optional. .png, .jpg or .jpeg, resolved against the directory holding this
|
||||
# file rather than the working directory. The encoded data URI must not exceed
|
||||
# 12500 characters, roughly a 128x128 avatar.
|
||||
# image = "./avatar.png"
|
||||
# Creates <prefix>_chat.db and <prefix>_agent.db.
|
||||
db_prefix = "./support_bot_light"
|
||||
welcome = "Hi! Someone from the team will join this chat in a moment."
|
||||
|
||||
[roster]
|
||||
# Renaming the group later has no effect: it is found by a marker in its custom
|
||||
# data, not by name.
|
||||
group_name = "Invite roster"
|
||||
# One of: observer, author, member, moderator, admin, owner. "relay" is also
|
||||
# accepted by the core but is an infrastructure role.
|
||||
member_role = "owner"
|
||||
|
||||
# Monitoring endpoint: GET /health answers 200 while the chat controller
|
||||
# responds to a command, 503 when it does not. On by default, at the values
|
||||
# below. It has no authentication, so keep it off a public interface.
|
||||
[health]
|
||||
# enabled = false
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
support-bot-light:
|
||||
build:
|
||||
# The repository root: the image is built from the Haskell core and the
|
||||
# Python library in this tree, neither of them released.
|
||||
context: ../..
|
||||
dockerfile: apps/simplex-support-bot-light/Dockerfile
|
||||
args:
|
||||
# Defaults to 1000. Set both to your own ids to own ./state yourself.
|
||||
USER_UID: ${USER_UID:-1000}
|
||||
USER_GID: ${USER_GID:-1000}
|
||||
# Bounded on purpose. A config that will not load is not fixed by retrying,
|
||||
# and an unbounded policy turns it into a log flood that also makes Ctrl+C
|
||||
# wait out the grace period. Five is enough to ride out a transient fault.
|
||||
restart: on-failure:5
|
||||
volumes:
|
||||
# Directory, not a single file: bot.image resolves relative paths against
|
||||
# the directory holding config.toml.
|
||||
- ./bot-config:/etc/support-bot-light:ro
|
||||
# Holds the bot's identity, address, roster group and roster. Deleting it
|
||||
# means a new address and every roster member redoing the handshake.
|
||||
- ./state:/data
|
||||
stop_grace_period: 10s
|
||||
ports:
|
||||
# GET /health. Published on the host loopback because the endpoint has no
|
||||
# authentication; widen it only for a monitoring system that needs it.
|
||||
- "127.0.0.1:7777:8080"
|
||||
@@ -0,0 +1,35 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.24"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "simplex-support-bot-light"
|
||||
version = "0.1.0"
|
||||
description = "SimpleX bot that adds a self-service roster to incoming business chats"
|
||||
readme = "README.md"
|
||||
license = "AGPL-3.0-only"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["simplex-chat>=7.1.0b0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"]
|
||||
|
||||
[project.scripts]
|
||||
support-bot-light = "support_bot_light.__main__:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/support_bot_light"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
include = ["src/support_bot_light"]
|
||||
exclude = ["**/__pycache__", "**/.venv*"]
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Entry point: load config, start the bot, wire handlers, serve."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from simplex_chat import Bot, BotProfile, ChatError, SqliteDb
|
||||
from simplex_chat.core import ChatInitError
|
||||
from simplex_chat.types import CEvt
|
||||
|
||||
from . import business, commands, handlers, health, setup
|
||||
from .config import Config, ConfigError, load_config
|
||||
from .context import BotContext
|
||||
|
||||
log = logging.getLogger("support_bot_light")
|
||||
|
||||
# storeError tag the core returns when a display name is already in use.
|
||||
DUPLICATE_NAME = "duplicateName"
|
||||
|
||||
# ChatInitError is raised only while opening the database, which is why it
|
||||
# belongs here rather than in the per-command guards elsewhere.
|
||||
STARTUP_ERRORS = (ChatError, ChatInitError)
|
||||
|
||||
|
||||
def _register(bot: Bot, ctx: BotContext) -> None:
|
||||
"""Register handlers. Commands are scoped to the roster group, so the same
|
||||
keyword typed in a business chat falls through and is ignored."""
|
||||
group_id = ctx.roster_group_id
|
||||
|
||||
@bot.on_command(commands.DM, group_id=group_id)
|
||||
async def _dm(msg, _cmd):
|
||||
await handlers.dm(ctx, msg)
|
||||
|
||||
@bot.on_command(commands.LIST, group_id=group_id)
|
||||
async def _list(msg, _cmd):
|
||||
await handlers.list_roster(ctx, msg)
|
||||
|
||||
@bot.on_command(commands.LEAVE, group_id=group_id)
|
||||
async def _leave(msg, _cmd):
|
||||
await handlers.leave(ctx, msg)
|
||||
|
||||
@bot.on_command(commands.HELP, group_id=group_id)
|
||||
async def _help(msg, _cmd):
|
||||
await handlers.help_cmd(ctx, msg)
|
||||
|
||||
@bot.on_event("acceptingBusinessRequest")
|
||||
async def _business(evt: CEvt.AcceptingBusinessRequest):
|
||||
await business.on_business_request(ctx, evt)
|
||||
|
||||
@bot.on_event("contactConnected")
|
||||
async def _connected(evt: CEvt.ContactConnected):
|
||||
await handlers.contact_ready(ctx, evt["contact"]["contactId"])
|
||||
|
||||
@bot.on_event("contactSndReady")
|
||||
async def _snd_ready(evt: CEvt.ContactSndReady):
|
||||
await handlers.contact_ready(ctx, evt["contact"]["contactId"])
|
||||
|
||||
@bot.on_event("deletedMember")
|
||||
async def _deleted_member(evt: CEvt.DeletedMember):
|
||||
await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["deletedMember"])
|
||||
|
||||
@bot.on_event("leftMember")
|
||||
async def _left_member(evt: CEvt.LeftMember):
|
||||
await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["member"])
|
||||
|
||||
|
||||
def startup_error(e: Exception) -> str:
|
||||
"""What the operator can act on, from an exception that names only a tag.
|
||||
|
||||
The core reports a display name already taken by a contact or group as a
|
||||
bare `errorStore`, and the detail the bot needs is in the store error.
|
||||
"""
|
||||
if getattr(e, "store_error_type", None) == DUPLICATE_NAME:
|
||||
return (
|
||||
"bot.display_name is already taken in this database by a contact, a "
|
||||
"group or a past customer; the core keeps every display name unique. "
|
||||
"Choose another name."
|
||||
)
|
||||
command_error = getattr(e, "command_error", None)
|
||||
if command_error is not None:
|
||||
return command_error
|
||||
chat_error = getattr(e, "chat_error", None)
|
||||
return f"{e} {chat_error}" if chat_error else str(e)
|
||||
|
||||
|
||||
def bot_profile(config: Config) -> BotProfile:
|
||||
return BotProfile(display_name=config.display_name, image=config.image)
|
||||
|
||||
|
||||
def build_bot(config: Config) -> Bot:
|
||||
"""The bot's identity and address settings.
|
||||
|
||||
business_address is what makes a connection open a group the roster can be
|
||||
added to; without it every customer would get a plain direct chat and the
|
||||
bot would have nothing to do.
|
||||
|
||||
The profile is applied after the client starts, not by the startup sync, so
|
||||
that a name the core refuses does not stop the bot. See `_apply_profile`.
|
||||
"""
|
||||
return Bot(
|
||||
profile=bot_profile(config),
|
||||
db=SqliteDb(file_prefix=config.db_prefix),
|
||||
welcome=config.welcome,
|
||||
business_address=True,
|
||||
auto_accept=True,
|
||||
update_profile=False,
|
||||
# The library logs peer display names verbatim; the bot sanitises every
|
||||
# name it renders itself, and this is the one path that bypasses it.
|
||||
log_contacts=False,
|
||||
)
|
||||
|
||||
|
||||
async def _run(config: Config) -> None:
|
||||
bot = build_bot(config)
|
||||
# Before the client starts: a signal during migrations would otherwise hit
|
||||
# the default disposition and kill the process mid-write.
|
||||
bot.install_signal_handlers()
|
||||
await _serve(config, bot)
|
||||
|
||||
|
||||
async def _apply_profile(bot: Bot) -> None:
|
||||
"""Apply the configured profile once the database can be reached.
|
||||
|
||||
The core refuses a display name another contact or group holds, and the
|
||||
profile update broadcasts to every contact, so it is the startup step most
|
||||
likely to fail. Answering customers matters more than a name or an avatar.
|
||||
"""
|
||||
try:
|
||||
await bot.sync_profile()
|
||||
except ChatError as e:
|
||||
log.error("%s", startup_error(e))
|
||||
log.warning("Serving without applying the profile change.")
|
||||
|
||||
|
||||
async def _serve(config: Config, bot: Bot) -> None:
|
||||
# Not bot.run(): handlers are scoped with group_id=, which is unknown until
|
||||
# the roster group is resolved after start.
|
||||
async with bot:
|
||||
user = await bot.api.api_get_active_user()
|
||||
if user is None:
|
||||
raise RuntimeError("no active user after start")
|
||||
user_id = user["userId"]
|
||||
await _apply_profile(bot)
|
||||
roster_group_id = await setup.ensure_roster_group(bot.api, user_id, config)
|
||||
ctx = BotContext(
|
||||
api=bot.api,
|
||||
user_id=user_id,
|
||||
roster_group_id=roster_group_id,
|
||||
config=config,
|
||||
)
|
||||
_register(bot, ctx)
|
||||
groups = await bot.api.api_list_groups(user_id)
|
||||
await handlers.reconcile_roster(ctx, groups)
|
||||
await business.reconcile_chats(ctx, groups)
|
||||
|
||||
if bot.stop_requested:
|
||||
# A signal arrived during startup; unwind rather than begin serving.
|
||||
log.info("stopped during startup")
|
||||
return
|
||||
|
||||
server = await health.serve(ctx, config.health) if config.health else None
|
||||
try:
|
||||
await bot.serve_forever()
|
||||
finally:
|
||||
if server is not None:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="support-bot-light")
|
||||
parser.add_argument("--config", type=Path, default=Path("config.toml"))
|
||||
args = parser.parse_args()
|
||||
|
||||
if not logging.getLogger().handlers:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
)
|
||||
# The core creates its databases with the process umask, and they hold the
|
||||
# bot's identity keys. Docker mounts a 0700 directory; a manual install
|
||||
# would otherwise put them in the working directory at 0644.
|
||||
os.umask(stat.S_IRWXG | stat.S_IRWXO)
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as e:
|
||||
log.error("%s", e)
|
||||
return 2
|
||||
try:
|
||||
asyncio.run(_run(config))
|
||||
except ConfigError as e:
|
||||
# Raised past load_config only by the health endpoint, which cannot know
|
||||
# its port is taken until it binds.
|
||||
log.error("%s", e)
|
||||
return 2
|
||||
except STARTUP_ERRORS as e:
|
||||
# Startup rejections the core only reports at first use, such as a
|
||||
# database it will not open.
|
||||
log.error("%s", startup_error(e))
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Incoming business chats: add the roster, log the invite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from simplex_chat import ChatError
|
||||
from simplex_chat.types import CEvt, T
|
||||
|
||||
from . import messages, roster
|
||||
from .context import BotContext
|
||||
from .text import safe_name
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Written to a business chat's custom data once its roster pass has run.
|
||||
ROSTERED = "rostered"
|
||||
|
||||
|
||||
def _rostered(group: T.GroupInfo) -> bool:
|
||||
mark = (group.get("customData") or {}).get(roster.NAMESPACE)
|
||||
return isinstance(mark, dict) and mark.get(ROSTERED) is True
|
||||
|
||||
|
||||
async def _mark_rostered(ctx: BotContext, group: T.GroupInfo) -> None:
|
||||
"""Record that this chat has had its roster pass, preserving other keys.
|
||||
|
||||
Without this, startup repair cannot tell a chat a crash left half-finished
|
||||
from one that was completed before the roster changed — and would add
|
||||
people who joined the roster later to every conversation the bot has ever
|
||||
handled.
|
||||
"""
|
||||
existing = (group.get("customData") or {}).get(roster.NAMESPACE)
|
||||
mark: dict[str, object] = dict(existing) if isinstance(existing, dict) else {}
|
||||
mark[ROSTERED] = True
|
||||
await ctx.api.api_merge_group_custom_data(group, roster.NAMESPACE, mark)
|
||||
|
||||
|
||||
async def _mark(ctx: BotContext, group: T.GroupInfo) -> None:
|
||||
"""Mark the chat, containing the failure: the next start re-derives it."""
|
||||
try:
|
||||
await _mark_rostered(ctx, group)
|
||||
except ChatError:
|
||||
log.warning("could not mark business chat %s as rostered", group["groupId"], exc_info=True)
|
||||
|
||||
|
||||
async def _add_missing(
|
||||
ctx: BotContext, group_id: int, entries: list[roster.RosterEntry]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Add every entry not already in the group. Returns (added, failed)."""
|
||||
present = await roster.contact_ids_in_group(ctx.api, group_id)
|
||||
|
||||
added: list[str] = []
|
||||
failed: list[str] = []
|
||||
for entry in entries:
|
||||
if entry.contact_id in present:
|
||||
continue
|
||||
try:
|
||||
# Final role in one call: promoting a pending invitee re-sends the
|
||||
# invitation.
|
||||
await ctx.api.api_add_member(group_id, entry.contact_id, ctx.config.member_role)
|
||||
added.append(entry.name)
|
||||
except ChatError:
|
||||
log.exception("failed adding %s to business chat %s", entry.name, group_id)
|
||||
failed.append(entry.name)
|
||||
return added, failed
|
||||
|
||||
|
||||
async def _roster_for_chats(ctx: BotContext) -> list[roster.RosterEntry]:
|
||||
"""Active roster members who are still in the roster group.
|
||||
|
||||
Revocation is driven by an event, and the core delivers a queued business
|
||||
request before a queued departure just as readily as after it, so the mark
|
||||
alone would let somebody who has left read a conversation started after they
|
||||
went. Membership of the roster group is the access-control boundary, so it
|
||||
is what decides: read for each incoming chat, and once per startup pass.
|
||||
"""
|
||||
entries = await roster.active(ctx.api, ctx.user_id)
|
||||
if not entries:
|
||||
return []
|
||||
present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id)
|
||||
return [e for e in entries if e.contact_id in present]
|
||||
|
||||
|
||||
async def reconcile_chats(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None:
|
||||
"""Add active roster members to business chats that are missing them.
|
||||
|
||||
Adding members is the only step with no second chance: it is driven by an
|
||||
event delivered once, so a crash part-way through the loop would leave that
|
||||
customer permanently short of the roster.
|
||||
|
||||
Only chats whose roster pass never completed are touched. A chat that was
|
||||
finished before someone joined the roster is left alone: `/dm` promises to
|
||||
add you to chats "from now on", and back-filling would hand every past
|
||||
customer conversation to whoever joined the roster most recently.
|
||||
"""
|
||||
try:
|
||||
entries = await _roster_for_chats(ctx)
|
||||
if groups is None:
|
||||
groups = await ctx.api.api_list_groups(ctx.user_id)
|
||||
except ChatError:
|
||||
log.warning("could not reconcile business chats on startup", exc_info=True)
|
||||
return
|
||||
|
||||
repaired = 0
|
||||
for group in groups:
|
||||
if "businessChat" not in group or not roster.in_group(group["membership"]):
|
||||
continue
|
||||
if _rostered(group):
|
||||
continue
|
||||
group_id = group["groupId"]
|
||||
try:
|
||||
added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries)
|
||||
except ChatError:
|
||||
log.warning("could not reconcile business chat %s", group_id, exc_info=True)
|
||||
continue
|
||||
repaired += 1
|
||||
log.info("finished the roster pass for business chat %s on startup", group_id)
|
||||
# Reported even when nobody had to be added: the chat was left unmarked,
|
||||
# so the crash took the roster group's record of that customer with it.
|
||||
await ctx.post_to_roster(_report(_customer_of(group), entries, added, failed))
|
||||
if added or not failed:
|
||||
# Left unmarked means the repair did not finish; the queued event
|
||||
# should be allowed to retry it in this session.
|
||||
ctx.repaired.add(group_id)
|
||||
# Marked even with an empty roster: the pass has run for this chat,
|
||||
# and leaving it unmarked would back-fill whoever joins later.
|
||||
await _mark(ctx, group)
|
||||
if repaired:
|
||||
log.info("finished %d business chats left incomplete by a restart", repaired)
|
||||
|
||||
|
||||
def _report(
|
||||
customer: str, entries: list[roster.RosterEntry], added: list[str], failed: list[str]
|
||||
) -> str:
|
||||
"""The roster group's record of one business chat."""
|
||||
if not entries:
|
||||
return messages.EMPTY_ROSTER_LOG.format(customer=customer)
|
||||
if not added and not failed:
|
||||
return messages.NOBODY_NEW_LOG.format(customer=customer)
|
||||
return messages.invite_log(customer, added, failed)
|
||||
|
||||
|
||||
def _customer_of(group: T.GroupInfo) -> str:
|
||||
return safe_name((group.get("groupProfile") or {}).get("displayName") or "")
|
||||
|
||||
|
||||
async def on_business_request(ctx: BotContext, evt: CEvt.AcceptingBusinessRequest) -> None:
|
||||
"""Add every active roster member to a new business chat, then log it."""
|
||||
group = evt["groupInfo"]
|
||||
group_id = group["groupId"]
|
||||
if group_id in ctx.repaired:
|
||||
# Startup repair already ran for this chat and reported it; the queued
|
||||
# event would otherwise log the same customer a second time.
|
||||
ctx.repaired.discard(group_id)
|
||||
return
|
||||
# For a business chat the group's display name is the customer's own
|
||||
# profile string, which the core does not sanitise.
|
||||
customer = _customer_of(group)
|
||||
|
||||
# A failure before anything is added must still reach the roster group,
|
||||
# which is the operator's only visibility.
|
||||
try:
|
||||
entries = await _roster_for_chats(ctx)
|
||||
added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries)
|
||||
except ChatError:
|
||||
log.exception("failed reading roster for business chat %s", group_id)
|
||||
await ctx.post_to_roster(messages.BUSINESS_FAILED_LOG.format(customer=customer))
|
||||
return
|
||||
|
||||
await ctx.post_to_roster(_report(customer, entries, added, failed))
|
||||
|
||||
# Marked even when the line above failed to send. The marker records that
|
||||
# the pass ran, and an unmarked chat is repaired by every later start with
|
||||
# the roster of the day — so withholding it to preserve one log line would
|
||||
# hand a past customer's conversation to whoever joins the roster next.
|
||||
# Not marked when every add failed and none succeeded: that chat has no
|
||||
# roster at all, so the next start should retry rather than skip it.
|
||||
if added or not failed:
|
||||
await _mark(ctx, group)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""The bot's command menu: declarations plus conversion to group-preference wire dicts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from simplex_chat import BotCommand
|
||||
from simplex_chat.types import T
|
||||
|
||||
DM = "dm"
|
||||
LIST = "list"
|
||||
LEAVE = "leave"
|
||||
HELP = "help"
|
||||
|
||||
COMMANDS: tuple[BotCommand, ...] = (
|
||||
BotCommand(keyword=DM, label="Add me to incoming chats"),
|
||||
BotCommand(keyword=LIST, label="Who gets invited"),
|
||||
BotCommand(keyword=LEAVE, label="Stop adding me"),
|
||||
BotCommand(keyword=HELP, label="How this works"),
|
||||
)
|
||||
|
||||
|
||||
def to_wire(commands: Sequence[BotCommand]) -> list[T.ChatBotCommand]:
|
||||
"""Convert declarations to `groupPreferences.commands` entries."""
|
||||
wire: list[T.ChatBotCommand] = []
|
||||
for c in commands:
|
||||
entry: T.ChatBotCommand_command = {
|
||||
"type": "command",
|
||||
"keyword": c.keyword,
|
||||
"label": c.label,
|
||||
}
|
||||
# Omitted rather than empty: the client sends on tap for Nothing, but
|
||||
# pastes for Just "".
|
||||
if c.params is not None:
|
||||
entry["params"] = c.params
|
||||
wire.append(entry)
|
||||
return wire
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Load and validate `config.toml`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import stat
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, get_args
|
||||
|
||||
from simplex_chat.types import T
|
||||
|
||||
DEFAULT_MEMBER_ROLE: T.GroupMemberRole = "owner"
|
||||
MEMBER_ROLES: tuple[str, ...] = get_args(T.GroupMemberRole)
|
||||
|
||||
# maxProfileImageSize in src/Simplex/Chat/Library/Commands.hs. Measured against
|
||||
# the whole data URI, not the raw file.
|
||||
MAX_PROFILE_IMAGE_SIZE = 12500
|
||||
|
||||
# Raw bytes that still fit once base64 and the "data:image/png;base64," prefix
|
||||
# are added. Checked before the file is read.
|
||||
MAX_IMAGE_BYTES = (MAX_PROFILE_IMAGE_SIZE - 22) // 4 * 3
|
||||
|
||||
# The welcome is sent as a chat message, so it is held below the core's wire
|
||||
# limit (maxEncodedMsgLength) with room to spare rather than at it.
|
||||
MAX_WELCOME_BYTES = 12000
|
||||
|
||||
# On unless switched off, so a deployment is monitorable without being
|
||||
# configured for it. Loopback, because the endpoint has no authentication.
|
||||
DEFAULT_HEALTH_HOST = "127.0.0.1"
|
||||
DEFAULT_HEALTH_PORT = 8080
|
||||
MAX_PORT = 65535
|
||||
|
||||
# Tag in the data:image/<tag>;base64, prefix. The core accepts any "data:" string;
|
||||
# the clients strip only the png and jpg prefixes, so jpeg renders as nothing.
|
||||
IMAGE_EXTENSION_TAGS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpg"}
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""`config.toml` is missing, malformed, or has an invalid value."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Health:
|
||||
"""Where the monitoring endpoint listens."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
# True when the config names the port. A port the operator chose has to
|
||||
# work; the default must never be what keeps the bot from starting.
|
||||
configured: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Config:
|
||||
"""Validated settings loaded from `config.toml`."""
|
||||
|
||||
display_name: str
|
||||
db_prefix: str
|
||||
welcome: str
|
||||
group_name: str
|
||||
member_role: T.GroupMemberRole
|
||||
image: str | None = None
|
||||
health: Health | None = None
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
"""Read and validate `config.toml` at `path`, raising `ConfigError` on any problem."""
|
||||
try:
|
||||
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as e:
|
||||
# Names, not a command: under Docker this directory is mounted
|
||||
# read-only, so the copy is made on the host.
|
||||
template = path.with_name(path.name + ".example")
|
||||
hint = f" — copy {template.name} to {path.name} and edit it" if template.exists() else ""
|
||||
raise ConfigError(f"config file not found: {path}{hint}") from e
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise ConfigError(f"invalid TOML in {path}: {e}") from e
|
||||
except UnicodeDecodeError as e:
|
||||
raise ConfigError(f"config file is not UTF-8: {path}") from e
|
||||
except OSError as e:
|
||||
raise ConfigError(f"config file could not be read ({path}): {e}") from e
|
||||
|
||||
bot = _section(raw, "bot")
|
||||
roster = _section(raw, "roster")
|
||||
role = roster.get("member_role", DEFAULT_MEMBER_ROLE)
|
||||
if role not in MEMBER_ROLES:
|
||||
raise ConfigError(
|
||||
f"roster.member_role must be one of {', '.join(MEMBER_ROLES)}, got {role!r}"
|
||||
)
|
||||
return Config(
|
||||
display_name=_text(bot, "bot", "display_name"),
|
||||
db_prefix=_text(bot, "bot", "db_prefix"),
|
||||
welcome=_bounded_text(bot, "bot", "welcome", MAX_WELCOME_BYTES),
|
||||
group_name=_text(roster, "roster", "group_name"),
|
||||
member_role=role,
|
||||
image=_image(bot, path.parent),
|
||||
health=_health(raw),
|
||||
)
|
||||
|
||||
|
||||
def _health(raw: dict[str, Any]) -> Health | None:
|
||||
"""Where the endpoint listens, or None when `health.enabled` switches it off."""
|
||||
health = raw.get("health", {})
|
||||
if not isinstance(health, dict):
|
||||
raise ConfigError("[health] must be a section")
|
||||
enabled = health.get("enabled", True)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ConfigError(f"health.enabled must be true or false, got {enabled!r}")
|
||||
if not enabled:
|
||||
return None
|
||||
port = health.get("port", DEFAULT_HEALTH_PORT)
|
||||
# bool is an int, and TOML has booleans.
|
||||
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= MAX_PORT:
|
||||
raise ConfigError(f"health.port must be an integer between 1 and {MAX_PORT}, got {port!r}")
|
||||
host = health.get("host", DEFAULT_HEALTH_HOST)
|
||||
if not isinstance(host, str) or not host.strip():
|
||||
raise ConfigError("health.host must be a non-empty string")
|
||||
# Either key means the operator chose where it listens, and a bind failure
|
||||
# there is a misconfiguration rather than a coincidence.
|
||||
return Health(host=host, port=port, configured=bool({"host", "port"} & health.keys()))
|
||||
|
||||
|
||||
def _section(raw: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
section = raw.get(name)
|
||||
if not isinstance(section, dict):
|
||||
raise ConfigError(f"missing [{name}] section")
|
||||
return section
|
||||
|
||||
|
||||
def _text(section: dict[str, Any], section_name: str, key: str) -> str:
|
||||
value = section.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ConfigError(f"{section_name}.{key} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(section: dict[str, Any], section_name: str, key: str, max_bytes: int) -> str:
|
||||
value = _text(section, section_name, key)
|
||||
if len(value.encode()) > max_bytes:
|
||||
raise ConfigError(
|
||||
f"{section_name}.{key} is too long: {len(value.encode())} bytes exceeds "
|
||||
f"the {max_bytes} the core will send; shorten it"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _image(bot: dict[str, Any], config_dir: Path) -> str | None:
|
||||
"""Encode `bot.image` (a file path) as a profile-image data URI, or `None`
|
||||
if the key is absent. Relative paths resolve against `config_dir` — the
|
||||
directory containing `config.toml` — not the process's working directory.
|
||||
"""
|
||||
if "image" not in bot:
|
||||
return None
|
||||
value = _text(bot, "bot", "image")
|
||||
|
||||
image_path = Path(value)
|
||||
if not image_path.is_absolute():
|
||||
image_path = config_dir / image_path
|
||||
|
||||
extension = image_path.suffix.lower()
|
||||
tag = IMAGE_EXTENSION_TAGS.get(extension)
|
||||
if tag is None:
|
||||
supported = ", ".join(sorted(IMAGE_EXTENSION_TAGS))
|
||||
raise ConfigError(
|
||||
f"bot.image has unsupported extension {extension!r} ({image_path}); "
|
||||
f"supported extensions: {supported}"
|
||||
)
|
||||
|
||||
# Inspect before reading: a FIFO would block startup indefinitely and a
|
||||
# character device such as /dev/zero would exhaust memory.
|
||||
try:
|
||||
info = image_path.stat()
|
||||
except FileNotFoundError as e:
|
||||
raise ConfigError(f"bot.image file not found: {image_path}") from e
|
||||
except OSError as e:
|
||||
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
|
||||
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ConfigError(f"bot.image is not a regular file: {image_path}")
|
||||
if info.st_size > MAX_IMAGE_BYTES:
|
||||
raise ConfigError(
|
||||
f"bot.image is too large: {info.st_size} bytes exceeds the {MAX_IMAGE_BYTES} "
|
||||
f"a {MAX_PROFILE_IMAGE_SIZE}-character data URI can hold; shrink the image "
|
||||
"(a 128x128 avatar) and try again"
|
||||
)
|
||||
|
||||
try:
|
||||
data = image_path.read_bytes()
|
||||
except OSError as e:
|
||||
raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e
|
||||
|
||||
# The core rejects an empty image file rather than broadcasting a profile
|
||||
# with an undecodable data URI.
|
||||
if not data:
|
||||
raise ConfigError(f"bot.image file is empty: {image_path}")
|
||||
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
data_uri = f"data:image/{tag};base64,{encoded}"
|
||||
if len(data_uri) > MAX_PROFILE_IMAGE_SIZE:
|
||||
raise ConfigError(
|
||||
f"bot.image is too large: encoded size {len(data_uri)} exceeds the "
|
||||
f"{MAX_PROFILE_IMAGE_SIZE}-character limit the core enforces on profile "
|
||||
"images; shrink the image (e.g. to a 96x96 or 128x128 avatar) and try again"
|
||||
)
|
||||
return data_uri
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Everything the handlers need, resolved once at startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from simplex_chat import ChatApi, ChatError
|
||||
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BotContext:
|
||||
"""API handle plus the ids and config resolved during startup."""
|
||||
|
||||
api: ChatApi
|
||||
user_id: int
|
||||
roster_group_id: int
|
||||
config: Config
|
||||
# Business chats repaired by the startup pass, so the queued event for the
|
||||
# same chat does not report the customer a second time.
|
||||
repaired: set[int] = field(default_factory=set)
|
||||
|
||||
async def post_to_roster(self, text: str) -> None:
|
||||
"""Send a message to the roster group.
|
||||
|
||||
Never raises: this is the operator's visibility channel, and a failure
|
||||
to report an event must not also discard the event that caused it.
|
||||
"""
|
||||
try:
|
||||
await self.api.api_send_text_message(["group", self.roster_group_id], text)
|
||||
except ChatError:
|
||||
log.warning("could not post to the roster group: %s", text, exc_info=True)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Command, connection and membership handlers, plus the roster catch-up pass."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from simplex_chat import ChatError, Message
|
||||
from simplex_chat.types import T
|
||||
|
||||
from . import messages, roster, setup
|
||||
from .context import BotContext
|
||||
from .text import safe_name
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reply_on_error(fn: Callable[[BotContext, Message], Awaitable[None]]):
|
||||
"""Turn a failed command into a visible reply instead of silence."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(ctx: BotContext, msg: Message) -> None:
|
||||
try:
|
||||
await fn(ctx, msg)
|
||||
except ChatError:
|
||||
log.exception("%s failed", fn.__name__)
|
||||
await msg.reply(messages.COMMAND_FAILED)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def _contact_of(ctx: BotContext, member: T.GroupMember) -> T.Contact | None:
|
||||
"""The sender's direct contact, resolved against current state.
|
||||
|
||||
`memberContactId` in the message payload is a snapshot taken when the core
|
||||
built the chat item. `api_create_member_contact` sets that column, so two
|
||||
commands sent in quick succession both carry the pre-`/dm` value of `None`
|
||||
and would otherwise be treated as having no contact at all.
|
||||
"""
|
||||
contact_id = member.get("memberContactId")
|
||||
if contact_id is None:
|
||||
for m in await ctx.api.api_list_members(ctx.roster_group_id):
|
||||
if m["groupMemberId"] == member["groupMemberId"]:
|
||||
contact_id = m.get("memberContactId")
|
||||
break
|
||||
if contact_id is None:
|
||||
return None
|
||||
return await roster.find_contact(ctx.api, ctx.user_id, contact_id)
|
||||
|
||||
|
||||
def group_sender(msg: Message) -> T.GroupMember | None:
|
||||
"""The member who sent a group message, or None if it isn't a group receive."""
|
||||
chat_dir = msg.chat_item["chatItem"]["chatDir"]
|
||||
if chat_dir.get("type") != "groupRcv":
|
||||
return None
|
||||
return chat_dir.get("groupMember")
|
||||
|
||||
|
||||
@_reply_on_error
|
||||
async def dm(ctx: BotContext, msg: Message) -> None:
|
||||
"""Put the sender on the roster, sending a contact request first if needed."""
|
||||
member = group_sender(msg)
|
||||
if member is None:
|
||||
return
|
||||
|
||||
contact = await _contact_of(ctx, member)
|
||||
|
||||
if contact is not None:
|
||||
# api_create_member_contact sets memberContactId before the person has
|
||||
# accepted, so an existing contact is not necessarily usable.
|
||||
entry = roster.entry_of(contact)
|
||||
|
||||
if roster.contact_usable(contact):
|
||||
if entry is not None and entry.state == roster.ACTIVE:
|
||||
await msg.reply(messages.ALREADY_ACTIVE)
|
||||
return
|
||||
since = entry.since if entry else roster.utc_now()
|
||||
await roster.mark(ctx.api, contact, roster.ACTIVE, since)
|
||||
await msg.reply(messages.ADDED)
|
||||
# Every other route to active announces; without this the operator's
|
||||
# log misses arrivals that took the fast path.
|
||||
name = roster.contact_name(contact)
|
||||
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=name))
|
||||
return
|
||||
|
||||
if contact.get("contactGroupMemberId") is None:
|
||||
if roster.accept_started(contact):
|
||||
# Already accepted; the connection is still completing. Marked
|
||||
# here too: contact_ready promotes a pending mark and does
|
||||
# nothing without one, so ACCEPTING would promise a roster place
|
||||
# that never arrives.
|
||||
since = entry.since if entry else roster.utc_now()
|
||||
await roster.mark(ctx.api, contact, roster.PENDING, since)
|
||||
await msg.reply(messages.ACCEPTING)
|
||||
return
|
||||
|
||||
if roster.awaiting_accept(contact):
|
||||
# They connected to us from the group rather than accepting our
|
||||
# request. Accept it and mark them pending; contactConnected
|
||||
# then promotes them exactly as it would the other way round.
|
||||
await ctx.api.api_accept_member_contact(contact["contactId"])
|
||||
since = entry.since if entry else roster.utc_now()
|
||||
await roster.mark(ctx.api, contact, roster.PENDING, since)
|
||||
await msg.reply(messages.ACCEPTING)
|
||||
return
|
||||
|
||||
if roster.connecting(contact):
|
||||
# The core clears contactGroupMemberId when the peer accepts,
|
||||
# well before the connection reports ready, so this shape is
|
||||
# also a handshake in progress. Reporting it as gone would send
|
||||
# the member to CONNECTION_LOST's advice, and connecting
|
||||
# directly there tears down the connection that was completing.
|
||||
since = entry.since if entry else roster.utc_now()
|
||||
await roster.mark(ctx.api, contact, roster.PENDING, since)
|
||||
await msg.reply(messages.CONNECTING)
|
||||
return
|
||||
|
||||
# The core clears this once a member contact has connected, and
|
||||
# api_send_member_contact_invitation requires it, so the handshake
|
||||
# cannot be re-driven from this side. The mark is left alone: an
|
||||
# active one renders under "Not reachable", which is the truth.
|
||||
await msg.reply(messages.CONNECTION_LOST)
|
||||
return
|
||||
|
||||
# Reaching here means contactGroupMemberId is still set, which the core
|
||||
# clears on connect: the person never completed the handshake, so an
|
||||
# active mark is stale.
|
||||
if entry is None or entry.state != roster.PENDING:
|
||||
since = entry.since if entry else roster.utc_now()
|
||||
await roster.mark(ctx.api, contact, roster.PENDING, since)
|
||||
|
||||
if contact.get("contactGrpInvSent"):
|
||||
# The core rejects a second invitation; the person has simply not
|
||||
# accepted the first one yet.
|
||||
await msg.reply(messages.STILL_PENDING)
|
||||
return
|
||||
|
||||
# First send failed. api_create_member_contact would raise "member
|
||||
# contact already exists", so resend on the existing contact.
|
||||
try:
|
||||
await ctx.api.api_send_member_contact_invitation(
|
||||
contact["contactId"], messages.INVITATION_TEXT
|
||||
)
|
||||
except ChatError:
|
||||
log.warning("invitation resend to contact %s failed", contact["contactId"])
|
||||
await msg.reply(messages.INVITATION_FAILED)
|
||||
return
|
||||
await msg.reply(messages.INVITATION_SENT)
|
||||
return
|
||||
|
||||
contact = await ctx.api.api_create_member_contact(ctx.roster_group_id, member["groupMemberId"])
|
||||
await roster.mark(ctx.api, contact, roster.PENDING, roster.utc_now())
|
||||
new_contact_id = contact["contactId"]
|
||||
try:
|
||||
await ctx.api.api_send_member_contact_invitation(new_contact_id, messages.INVITATION_TEXT)
|
||||
except ChatError:
|
||||
log.warning("invitation to contact %s failed to send", new_contact_id)
|
||||
await msg.reply(messages.INVITATION_FAILED)
|
||||
return
|
||||
await msg.reply(messages.INVITATION_SENT)
|
||||
|
||||
|
||||
async def contact_ready(ctx: BotContext, contact_id: int) -> None:
|
||||
"""Promote a pending contact once its connection is usable.
|
||||
|
||||
Shared by contactConnected and contactSndReady. Re-reads the contact rather
|
||||
than trusting the event payload.
|
||||
"""
|
||||
try:
|
||||
contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id)
|
||||
if contact is None:
|
||||
return
|
||||
entry = roster.entry_of(contact)
|
||||
if entry is None or entry.state != roster.PENDING:
|
||||
return
|
||||
if not entry.reachable:
|
||||
# The event says the connection is up, but the record is what
|
||||
# `active()` will consult, so promote only on what it will see.
|
||||
return
|
||||
await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since)
|
||||
except ChatError:
|
||||
# Nobody is waiting on a reply here, so without this the failure is a
|
||||
# bare traceback from the library and the person is stranded pending.
|
||||
log.warning("could not promote contact %s", contact_id, exc_info=True)
|
||||
return
|
||||
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name))
|
||||
|
||||
|
||||
async def reconcile_roster(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None:
|
||||
"""Catch up on what happened while the bot was stopped.
|
||||
|
||||
Both events this compensates for are delivered once and never replayed: an
|
||||
acceptance (`contactConnected`) leaves someone stuck pending, and a removal
|
||||
from the roster group leaves someone on the roster who should not be.
|
||||
|
||||
`groups` is passed in by startup so the two passes share one listing, which
|
||||
is the largest thing startup reads and grows with every customer ever seen.
|
||||
"""
|
||||
try:
|
||||
present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id)
|
||||
contacts = await ctx.api.api_list_contacts(ctx.user_id)
|
||||
if groups is None:
|
||||
groups = await ctx.api.api_list_groups(ctx.user_id)
|
||||
except ChatError:
|
||||
# Startup must not fail because the catch-up pass could not run.
|
||||
log.warning("could not reconcile the roster on startup", exc_info=True)
|
||||
return
|
||||
|
||||
# Revocation deletes the bot's only durable state, so it runs only when the
|
||||
# roster group is unambiguous. An empty member list is deliberately NOT a
|
||||
# reason to skip: the last member leaving is when revoking matters most.
|
||||
marked = sum(1 for g in groups if setup.is_roster_group(g))
|
||||
revoke = marked == 1
|
||||
if not revoke:
|
||||
log.warning("%d groups carry the roster marker; skipping revocation", marked)
|
||||
|
||||
for contact in contacts:
|
||||
entry = roster.entry_of(contact)
|
||||
if entry is None:
|
||||
continue
|
||||
try:
|
||||
if revoke and entry.contact_id not in present:
|
||||
await roster.unmark(ctx.api, contact)
|
||||
log.info("removed %s from the roster: no longer in the roster group", entry.name)
|
||||
await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name))
|
||||
elif entry.state == roster.PENDING and entry.reachable:
|
||||
await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since)
|
||||
log.info("promoted %s on startup: their connection is ready", entry.name)
|
||||
await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name))
|
||||
except ChatError:
|
||||
# One bad contact must not abandon the rest of the pass.
|
||||
log.warning("could not reconcile contact %s", entry.contact_id, exc_info=True)
|
||||
|
||||
|
||||
def _member_name(member: T.GroupMember) -> str:
|
||||
return member.get("localDisplayName") or (member.get("memberProfile") or {}).get(
|
||||
"displayName", ""
|
||||
)
|
||||
|
||||
|
||||
async def member_gone(ctx: BotContext, group_id: int, member: T.GroupMember) -> None:
|
||||
"""Take someone off the roster when they leave or are removed from the group.
|
||||
|
||||
Membership of the roster group is the access-control boundary, so it has to
|
||||
be revocable: without this, someone removed from the group keeps being added
|
||||
to every business chat and cannot even run `/leave` to stop it.
|
||||
"""
|
||||
if group_id != ctx.roster_group_id:
|
||||
return
|
||||
contact_id = member.get("memberContactId")
|
||||
if contact_id is None:
|
||||
return
|
||||
try:
|
||||
contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id)
|
||||
if contact is None:
|
||||
return
|
||||
entry = roster.entry_of(contact)
|
||||
if entry is None:
|
||||
return
|
||||
await roster.unmark(ctx.api, contact)
|
||||
except ChatError:
|
||||
# The only failure in the bot that the roster group would not hear
|
||||
# about, and it is the one on the access-control path. Access is not at
|
||||
# risk — every add re-reads roster group membership — but the operator
|
||||
# is owed the mark still being there until the next start repairs it.
|
||||
log.warning("could not take contact %s off the roster", contact_id, exc_info=True)
|
||||
await ctx.post_to_roster(
|
||||
messages.REVOKE_FAILED.format(name=safe_name(_member_name(member)))
|
||||
)
|
||||
return
|
||||
log.info("removed %s from the roster: no longer in the roster group", entry.name)
|
||||
await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name))
|
||||
|
||||
|
||||
@_reply_on_error
|
||||
async def list_roster(ctx: BotContext, msg: Message) -> None:
|
||||
"""Reply with the roster, active and pending."""
|
||||
entries = await roster.load(ctx.api, ctx.user_id)
|
||||
await msg.reply(messages.render_roster(entries))
|
||||
|
||||
|
||||
@_reply_on_error
|
||||
async def leave(ctx: BotContext, msg: Message) -> None:
|
||||
"""Take the sender off the roster, keeping the direct contact."""
|
||||
member = group_sender(msg)
|
||||
if member is None:
|
||||
return
|
||||
contact = await _contact_of(ctx, member)
|
||||
if contact is None or roster.entry_of(contact) is None:
|
||||
await msg.reply(messages.NOT_ON_ROSTER)
|
||||
return
|
||||
await roster.unmark(ctx.api, contact)
|
||||
await msg.reply(messages.LEFT)
|
||||
|
||||
|
||||
@_reply_on_error
|
||||
async def help_cmd(ctx: BotContext, msg: Message) -> None:
|
||||
"""Reply with the help text."""
|
||||
await msg.reply(messages.HELP)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Optional HTTP endpoint reporting whether the core still answers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from simplex_chat import ChatError
|
||||
|
||||
from .config import ConfigError, Health
|
||||
from .context import BotContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PATH = "/health"
|
||||
|
||||
# The probe issues a real command, so it has to give up before the monitor does.
|
||||
PROBE_TIMEOUT = 5.0
|
||||
|
||||
# Larger than any request a monitor sends, and the cap on what is read.
|
||||
MAX_REQUEST_BYTES = 4096
|
||||
READ_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _head(status: str, length: int) -> bytes:
|
||||
return (
|
||||
f"HTTP/1.1 {status}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {length}\r\n"
|
||||
"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _response(status: str, payload: str) -> tuple[bytes, bytes]:
|
||||
"""(head, body). HEAD answers with the head alone, as HTTP requires."""
|
||||
body = f'{{"status":"{payload}"}}\n'.encode()
|
||||
return _head(status, len(body)), body
|
||||
|
||||
|
||||
OK = _response("200 OK", "ok")
|
||||
UNAVAILABLE = _response("503 Service Unavailable", "unavailable")
|
||||
NOT_FOUND = _response("404 Not Found", "not found")
|
||||
NOT_ALLOWED = _response("405 Method Not Allowed", "method not allowed")
|
||||
BAD_REQUEST = _response("400 Bad Request", "bad request")
|
||||
|
||||
|
||||
class Probe:
|
||||
"""One outstanding query at a time, however often the endpoint is polled.
|
||||
|
||||
`asyncio.wait_for` bounds the wait, not the work: the FFI call it abandons
|
||||
keeps a worker thread in the loop's default executor until the core answers.
|
||||
Starting a fresh one per poll would exhaust that executor — as few as six
|
||||
threads on a small container — and the receive loop reads events through the
|
||||
same executor, so a stalled core would take the bot's own traffic down with
|
||||
it. The task is therefore reused rather than replaced, and never cancelled.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: BotContext) -> None:
|
||||
self._ctx = ctx
|
||||
self._task: asyncio.Task[bool] | None = None
|
||||
|
||||
async def check(self) -> bool:
|
||||
"""Whether the core answered within PROBE_TIMEOUT."""
|
||||
task = self._task
|
||||
if task is None or task.done():
|
||||
task = asyncio.create_task(self._query())
|
||||
self._task = task
|
||||
done, _pending = await asyncio.wait({task}, timeout=PROBE_TIMEOUT)
|
||||
if not done:
|
||||
log.warning("health probe still waiting after %ss", PROBE_TIMEOUT)
|
||||
return False
|
||||
return task.result()
|
||||
|
||||
async def _query(self) -> bool:
|
||||
"""Query the roster group. Never raises, whatever the core does.
|
||||
|
||||
Reaching the process proves only that the event loop runs. This reads
|
||||
the database, so it also waits on the store lock every other operation
|
||||
takes — unlike `/u`, which the core answers from memory and which would
|
||||
report healthy while a transaction was wedged. It stays small: the
|
||||
roster group holds the people who answer, not customers.
|
||||
"""
|
||||
try:
|
||||
await self._ctx.api.api_list_members(self._ctx.roster_group_id)
|
||||
except ChatError:
|
||||
log.warning("health probe failed", exc_info=True)
|
||||
return False
|
||||
except Exception:
|
||||
# A malformed reply or a controller that is gone are exactly what
|
||||
# this endpoint exists to report, and both arrive as something other
|
||||
# than a chat error.
|
||||
log.warning("health probe could not reach the core", exc_info=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _handle(
|
||||
probe: Probe,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
try:
|
||||
try:
|
||||
line = await asyncio.wait_for(reader.readline(), READ_TIMEOUT)
|
||||
except ValueError:
|
||||
# Longer than MAX_REQUEST_BYTES: answered rather than dropped, so a
|
||||
# monitor sees a reason.
|
||||
_write(writer, BAD_REQUEST, body=True)
|
||||
await writer.drain()
|
||||
return
|
||||
|
||||
request = line.decode("latin-1").split()
|
||||
method = request[0] if request else ""
|
||||
if len(request) < 2 or request[1].split("?")[0] != PATH:
|
||||
_write(writer, NOT_FOUND, body=True)
|
||||
elif method not in ("GET", "HEAD"):
|
||||
_write(writer, NOT_ALLOWED, body=True)
|
||||
else:
|
||||
_write(writer, OK if await probe.check() else UNAVAILABLE, body=method == "GET")
|
||||
await writer.drain()
|
||||
except (TimeoutError, OSError):
|
||||
# A client that stopped sending, or went away mid-response.
|
||||
log.debug("health request dropped", exc_info=True)
|
||||
finally:
|
||||
writer.close()
|
||||
with contextlib.suppress(OSError):
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
def _write(writer: asyncio.StreamWriter, response: tuple[bytes, bytes], body: bool) -> None:
|
||||
head, payload = response
|
||||
writer.write(head + payload if body else head)
|
||||
|
||||
|
||||
async def serve(ctx: BotContext, config: Health) -> asyncio.Server | None:
|
||||
"""Start the endpoint, or None when the default port is already taken.
|
||||
|
||||
A port the config names has to work: monitoring that silently failed to
|
||||
listen reads as health. The default port is different — nothing about it was
|
||||
asked for, so an unrelated service on it must not keep the bot from running.
|
||||
"""
|
||||
probe = Probe(ctx)
|
||||
|
||||
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
await _handle(probe, reader, writer)
|
||||
|
||||
try:
|
||||
server = await asyncio.start_server(
|
||||
handle, config.host, config.port, limit=MAX_REQUEST_BYTES
|
||||
)
|
||||
except OSError as e:
|
||||
if config.configured:
|
||||
raise ConfigError(
|
||||
f"health endpoint cannot listen on {config.host}:{config.port}: {e}"
|
||||
) from e
|
||||
log.warning(
|
||||
"No health endpoint: the default %s:%s could not be bound (%s). Set "
|
||||
"health.host or health.port, or health.enabled = false.",
|
||||
config.host,
|
||||
config.port,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
log.info("Health endpoint: http://%s:%s%s", config.host, config.port, PATH)
|
||||
return server
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Every user-visible string, and roster rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .roster import ACTIVE, RosterEntry
|
||||
|
||||
ADDED = "You are on the roster. You will be added to new chats."
|
||||
ALREADY_ACTIVE = "You are already on the roster."
|
||||
INVITATION_SENT = "Contact request sent. Accept it to join the roster."
|
||||
STILL_PENDING = (
|
||||
"Contact request not accepted yet. If you declined it, leave this group, "
|
||||
"join it again with the link, then run /dm."
|
||||
)
|
||||
INVITATION_FAILED = "Contact request could not be sent. Run /dm again."
|
||||
ACCEPTING = "Accepting the connection you started. You will be on the roster shortly."
|
||||
CONNECTING = "The connection is still completing. You will be on the roster shortly."
|
||||
CONNECTION_LOST = (
|
||||
"The direct connection is gone, so I cannot add you to chats. Open my "
|
||||
"profile in this group, connect directly, then run /dm."
|
||||
)
|
||||
INVITATION_TEXT = "Accept this contact request to be added to incoming chats. Keep the contact."
|
||||
NOW_ACTIVE = "Now on the roster: {name}"
|
||||
REMOVED_FROM_GROUP = "Off the roster: {name} left the roster group."
|
||||
LEFT = "You are off the roster. Chats you have already joined are unchanged."
|
||||
NOT_ON_ROSTER = "You are not on the roster."
|
||||
ROSTER_EMPTY = "The roster is empty."
|
||||
|
||||
# Keeps /list under the core's per-message size limit.
|
||||
MAX_LISTED = 40
|
||||
|
||||
# Below the core's maxEncodedMsgLength (Protocol.hs).
|
||||
MAX_REPLY_BYTES = 12000
|
||||
TRUNCATED = "\n… truncated"
|
||||
EMPTY_ROSTER_LOG = "Connected: {customer} → nobody on the roster to add"
|
||||
NOBODY_NEW_LOG = "Connected: {customer} → everyone on the roster was already in the chat"
|
||||
COMMAND_FAILED = "The command failed. Try again."
|
||||
REVOKE_FAILED = "Could not take {name} off the roster — retrying on the next restart."
|
||||
BUSINESS_FAILED_LOG = "Connected: {customer} → could not set up the chat, nobody added"
|
||||
|
||||
HELP = (
|
||||
"I add roster members to chats started by anyone who connects to my address.\n\n"
|
||||
"/dm — join the roster. Without a direct contact I send a contact request; "
|
||||
"you join the roster once you accept it.\n"
|
||||
"/list — roster members, and contact requests not yet accepted.\n"
|
||||
"/leave — leave the roster. Chats you have already joined are unchanged."
|
||||
)
|
||||
|
||||
|
||||
def _since(label: str, since: str) -> str:
|
||||
"""` — since 2026-08-13`, or empty when the entry has no timestamp."""
|
||||
day = since[:10]
|
||||
return f" — {label} {day}" if day else ""
|
||||
|
||||
|
||||
def _section(title: str, label: str, entries: Sequence[RosterEntry]) -> list[str]:
|
||||
"""A `/list` section, capped so the whole reply stays sendable.
|
||||
|
||||
A long enough roster would push `/list` past the core's wire limit
|
||||
(maxEncodedMsgLength), so it is capped here and what is omitted is stated
|
||||
rather than silently dropped.
|
||||
"""
|
||||
lines = [f"{title} ({len(entries)}):"]
|
||||
lines += [f" • {e.name}{_since(label, e.since)}" for e in entries[:MAX_LISTED]]
|
||||
if len(entries) > MAX_LISTED:
|
||||
lines.append(f" … and {len(entries) - MAX_LISTED} more")
|
||||
return lines
|
||||
|
||||
|
||||
def render_roster(entries: Sequence[RosterEntry]) -> str:
|
||||
"""Format the roster for `/list`, with a section per state."""
|
||||
active = [e for e in entries if e.state == ACTIVE and e.reachable]
|
||||
unreachable = [e for e in entries if e.state == ACTIVE and not e.reachable]
|
||||
pending = [e for e in entries if e.state != ACTIVE]
|
||||
|
||||
lines: list[str] = []
|
||||
if active:
|
||||
lines += _section("On the roster", "since", active)
|
||||
else:
|
||||
lines.append(ROSTER_EMPTY)
|
||||
if unreachable:
|
||||
lines.append("")
|
||||
lines += _section("Not reachable, not being added", "since", unreachable)
|
||||
if pending:
|
||||
lines.append("")
|
||||
lines += _section("Contact request not accepted", "asked", pending)
|
||||
|
||||
return _bounded("\n".join(lines))
|
||||
|
||||
|
||||
def _bounded(out: str) -> str:
|
||||
"""Keep a message inside what the core will send."""
|
||||
encoded = out.encode()
|
||||
if len(encoded) > MAX_REPLY_BYTES:
|
||||
# A last resort: names are capped in characters, so a section of CJK
|
||||
# names can still overrun what the core will send. The suffix is inside
|
||||
# the budget, so the result never exceeds MAX_REPLY_BYTES.
|
||||
room = MAX_REPLY_BYTES - len(TRUNCATED.encode())
|
||||
return encoded[:room].decode(errors="ignore") + TRUNCATED
|
||||
return out
|
||||
|
||||
|
||||
def invite_log(customer: str, added: Sequence[str], failed: Sequence[str]) -> str:
|
||||
"""One line for the roster group recording who was pulled into a business chat."""
|
||||
line = (
|
||||
f"Connected: {customer} → added {', '.join(added)}"
|
||||
if added
|
||||
else f"Connected: {customer} → nobody added"
|
||||
)
|
||||
if failed:
|
||||
line += f" (failed: {', '.join(failed)})"
|
||||
return _bounded(line)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Roster membership, stored in contact custom data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from simplex_chat import ChatApi, util
|
||||
from simplex_chat.types import T
|
||||
|
||||
from .text import safe_name
|
||||
|
||||
NAMESPACE = "supportBotLight"
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
|
||||
RosterState = Literal["active", "pending"]
|
||||
|
||||
READY_STATUSES = frozenset({"ready", "sndReady"})
|
||||
|
||||
# Mirrors isInGroup in apps/simplex-support-bot/src/bot.ts.
|
||||
TERMINAL_STATUSES = frozenset({"rejected", "removed", "left", "deleted", "unknown"})
|
||||
|
||||
# The connection is gone; nothing about it is still in progress.
|
||||
DEAD_STATUSES = frozenset({"deleted", "failed"})
|
||||
|
||||
|
||||
def in_group(member: T.GroupMember) -> bool:
|
||||
return member["memberStatus"] not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
async def contact_ids_in_group(api: ChatApi, group_id: int) -> set[int]:
|
||||
"""Contact ids of everyone currently in a group.
|
||||
|
||||
api_list_members keeps rows for people who left or were removed, so the
|
||||
status filter is what makes this a membership test rather than a history of
|
||||
everyone who was ever in the group.
|
||||
"""
|
||||
members = await api.api_list_members(group_id)
|
||||
return {cid for m in members if (cid := m.get("memberContactId")) is not None and in_group(m)}
|
||||
|
||||
|
||||
def connecting(contact: T.Contact) -> bool:
|
||||
"""Whether a connection is still on its way up.
|
||||
|
||||
Between accepting and `ready` the core parks a member contact in `accepted`
|
||||
with `contactGroupMemberId` cleared, which is indistinguishable by shape
|
||||
from a connection the peer deleted. Only the status separates them.
|
||||
"""
|
||||
tag = util.conn_status(contact)
|
||||
return tag is not None and tag not in DEAD_STATUSES
|
||||
|
||||
|
||||
def awaiting_accept(contact: T.Contact) -> bool:
|
||||
"""Whether the peer opened a direct connection we have not accepted.
|
||||
|
||||
A member who taps "connect directly" on the bot's profile in the roster
|
||||
group produces this: a contact in `prepared` state with no
|
||||
`contactGroupMemberId`, otherwise indistinguishable from one the peer
|
||||
deleted.
|
||||
"""
|
||||
inv = contact.get("groupDirectInv")
|
||||
if inv is not None:
|
||||
# The record survives acceptance; only this flag moves, and the core
|
||||
# rejects a second accept with "connection already started".
|
||||
return not inv.get("groupDirectInvStartedConnection", False)
|
||||
return util.conn_status(contact) == "prepared"
|
||||
|
||||
|
||||
def accept_started(contact: T.Contact) -> bool:
|
||||
"""Whether we accepted and the connection is still completing.
|
||||
|
||||
UPSTREAM BUG: `groupDirectInv` outlives the connection it describes. Nothing
|
||||
clears the record when that connection dies, so the started flag alone
|
||||
reports progress on a contact the peer deleted long ago.
|
||||
|
||||
Workaround: the connection status decides, and the flag only distinguishes
|
||||
accepted from not yet accepted.
|
||||
"""
|
||||
inv = contact.get("groupDirectInv")
|
||||
if inv is None or not inv.get("groupDirectInvStartedConnection", False):
|
||||
return False
|
||||
return util.conn_status(contact) not in DEAD_STATUSES
|
||||
|
||||
|
||||
def contact_usable(contact: T.Contact) -> bool:
|
||||
"""Whether the bot can actually add this contact to a group.
|
||||
|
||||
`api_create_member_contact` sets the member's contact id before the person
|
||||
has accepted anything, so the contact merely existing proves nothing — only
|
||||
a connected connection does.
|
||||
"""
|
||||
return util.conn_status(contact) in READY_STATUSES
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RosterEntry:
|
||||
"""One person on the roster, as recorded in their contact's custom data."""
|
||||
|
||||
contact_id: int
|
||||
name: str
|
||||
state: RosterState
|
||||
since: str
|
||||
reachable: bool
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
"""Current UTC time as an ISO-8601 string, second precision."""
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def contact_name(contact: T.Contact) -> str:
|
||||
"""A contact's display name, sanitised for rendering."""
|
||||
return safe_name(
|
||||
contact.get("localDisplayName") or (contact.get("profile") or {}).get("displayName") or ""
|
||||
)
|
||||
|
||||
|
||||
def entry_of(contact: T.Contact) -> RosterEntry | None:
|
||||
"""The roster entry for a contact, or None if it carries no roster mark."""
|
||||
mark = (contact.get("customData") or {}).get(NAMESPACE)
|
||||
if not isinstance(mark, dict):
|
||||
return None
|
||||
state = mark.get("roster")
|
||||
if state != ACTIVE and state != PENDING:
|
||||
return None
|
||||
return RosterEntry(
|
||||
contact_id=contact["contactId"],
|
||||
name=contact_name(contact),
|
||||
state=state,
|
||||
since=str(mark.get("since", "")),
|
||||
reachable=contact_usable(contact),
|
||||
)
|
||||
|
||||
|
||||
async def mark(api: ChatApi, contact: T.Contact, state: RosterState, since: str) -> None:
|
||||
"""Write the roster mark, preserving any other keys in the blob."""
|
||||
await api.api_merge_contact_custom_data(contact, NAMESPACE, {"roster": state, "since": since})
|
||||
|
||||
|
||||
async def unmark(api: ChatApi, contact: T.Contact) -> None:
|
||||
"""Remove the roster mark, leaving any other keys and the contact intact."""
|
||||
await api.api_merge_contact_custom_data(contact, NAMESPACE, None)
|
||||
|
||||
|
||||
async def load(api: ChatApi, user_id: int) -> list[RosterEntry]:
|
||||
"""Every marked contact, sorted by display name."""
|
||||
contacts = await api.api_list_contacts(user_id)
|
||||
entries = [e for c in contacts if (e := entry_of(c)) is not None]
|
||||
return sorted(entries, key=lambda e: e.name.lower())
|
||||
|
||||
|
||||
async def active(api: ChatApi, user_id: int) -> list[RosterEntry]:
|
||||
"""Marked active and still reachable — the ones added to business chats.
|
||||
|
||||
A contact marked active can stop being usable later, for instance when the
|
||||
person deletes the bot. `api_add_member` always fails for such a contact, so
|
||||
it is excluded here rather than failing once per business chat forever.
|
||||
`/list` reports the same distinction under "Not reachable".
|
||||
"""
|
||||
return [e for e in await load(api, user_id) if e.state == ACTIVE and e.reachable]
|
||||
|
||||
|
||||
async def find_contact(api: ChatApi, user_id: int, contact_id: int) -> T.Contact | None:
|
||||
"""The contact with this id, or None if it no longer exists."""
|
||||
for c in await api.api_list_contacts(user_id):
|
||||
if c["contactId"] == contact_id:
|
||||
return c
|
||||
return None
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Find or create the roster group, and keep its command menu in sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from simplex_chat import ChatApi, ChatError
|
||||
from simplex_chat.types import T
|
||||
|
||||
from . import commands, roster
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GROUP_MARKER = "roster"
|
||||
JOIN_ROLE: T.GroupMemberRole = "member"
|
||||
|
||||
# api_update_group_profile broadcasts, and the core's view queue is bounded
|
||||
# (tbqSize in Mobile.hs) with a blocking write. Nothing drains that queue until
|
||||
# the bot serves, so after enough downtime this call cannot return. The bot must
|
||||
# start anyway: the write completes once the queue drains, and a stale menu is a
|
||||
# cosmetic problem next to a process that never gets there.
|
||||
PROFILE_PUSH_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def is_roster_group(group: T.GroupInfo) -> bool:
|
||||
"""Whether this is a roster group the bot is still in.
|
||||
|
||||
api_list_groups keeps groups the bot has left or been removed from. Without
|
||||
the membership test the marker on a dead group would be chosen on every
|
||||
start: no command would ever arrive, nothing could be posted, and the marker
|
||||
would keep a replacement from being created.
|
||||
"""
|
||||
mark = (group.get("customData") or {}).get(roster.NAMESPACE)
|
||||
if not isinstance(mark, dict) or mark.get("group") != GROUP_MARKER:
|
||||
return False
|
||||
return roster.in_group(group["membership"])
|
||||
|
||||
|
||||
def _preferences() -> T.GroupPreferences:
|
||||
return {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
}
|
||||
|
||||
|
||||
async def _get_or_create_group_link(api: ChatApi, group_id: int) -> str | None:
|
||||
"""The group's join link, creating one if it doesn't exist yet.
|
||||
|
||||
A link can be missing if the process died between marking the group and
|
||||
creating the link on a previous run — that must not leave the group
|
||||
permanently unjoinable.
|
||||
|
||||
`api_get_group_link_str` also fails for reasons other than "no link
|
||||
exists" — if that happens while a link is actually present, the fallback
|
||||
create hits the group's unique link index and raises too. A missing link
|
||||
must never block startup, so that failure is logged and swallowed rather
|
||||
than left to propagate out of `ensure_roster_group`.
|
||||
"""
|
||||
try:
|
||||
return await api.api_get_group_link_str(group_id)
|
||||
except ChatError:
|
||||
pass
|
||||
try:
|
||||
return await api.api_create_group_link(group_id, JOIN_ROLE)
|
||||
except ChatError:
|
||||
log.warning(
|
||||
"Could not get or create a join link for roster group %s", group_id, exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_roster_group(api: ChatApi, user_id: int, config: Config) -> int:
|
||||
"""Return the roster group id, creating the group on first run.
|
||||
|
||||
The group is identified by a marker in its custom data, not by name, so an
|
||||
operator renaming it in the client doesn't cause a second group to appear.
|
||||
"""
|
||||
marked = [g for g in await api.api_list_groups(user_id) if is_roster_group(g)]
|
||||
if len(marked) > 1:
|
||||
# Reachable when two instances share a database, or after a database is
|
||||
# restored. Members of the group not chosen here are talking to a bot
|
||||
# that ignores them, so say which one won.
|
||||
log.warning(
|
||||
"%d groups carry the roster marker (%s); using %s",
|
||||
len(marked),
|
||||
", ".join(str(g["groupId"]) for g in marked),
|
||||
marked[0]["groupId"],
|
||||
)
|
||||
if marked:
|
||||
group = marked[0]
|
||||
try:
|
||||
await _sync_preferences(api, group)
|
||||
except ChatError:
|
||||
# The menu is a convenience; the commands work when typed. The core
|
||||
# requires owner rights to update the profile, so an operator who
|
||||
# demotes the bot would otherwise brick every later start.
|
||||
log.warning("could not update the command menu", exc_info=True)
|
||||
group_id = group["groupId"]
|
||||
log.info("Roster group: %s:%s", group_id, group["localDisplayName"])
|
||||
else:
|
||||
profile: T.GroupProfile = {
|
||||
"displayName": config.group_name,
|
||||
"fullName": "",
|
||||
"groupPreferences": _preferences(),
|
||||
}
|
||||
group = await api.api_new_group(user_id, profile)
|
||||
group_id = group["groupId"]
|
||||
await api.api_set_group_custom_data(group_id, {roster.NAMESPACE: {"group": GROUP_MARKER}})
|
||||
log.info("Roster group created: %s", group_id)
|
||||
|
||||
link = await _get_or_create_group_link(api, group_id)
|
||||
if link is not None:
|
||||
log.info("Roster group link (share with the people who should answer):\n%s", link)
|
||||
return group_id
|
||||
|
||||
|
||||
async def _sync_preferences(api: ChatApi, group: T.GroupInfo) -> None:
|
||||
"""Restore the preferences the roster group needs, only when they differ.
|
||||
|
||||
Both matter: without `commands` there is no menu, and without
|
||||
`directMessages` the core refuses to create a member contact, so `/dm`
|
||||
fails with nothing to explain it. An owner can switch either off in a
|
||||
client, so neither can be assumed to survive from creation.
|
||||
|
||||
`api_update_group_profile` broadcasts to every member, so a no-op update is
|
||||
traffic for everyone in the group.
|
||||
"""
|
||||
profile = group.get("groupProfile") or {}
|
||||
prefs = profile.get("groupPreferences") or {}
|
||||
desired = _preferences()
|
||||
if all(prefs.get(key) == value for key, value in desired.items()):
|
||||
return
|
||||
updated: T.GroupProfile = {**profile, "groupPreferences": {**prefs, **desired}}
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
api.api_update_group_profile(group["groupId"], updated), PROFILE_PUSH_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"Roster group preferences are still being written after %ss; continuing",
|
||||
PROFILE_PUSH_TIMEOUT,
|
||||
)
|
||||
return
|
||||
log.info("Restored roster group preferences on %s", group["groupId"])
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Sanitising peer-controlled text before it is rendered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
# mkValidName in src/Simplex/Chat/Library/Commands.hs caps a locally entered
|
||||
# name at 50 characters. It is not applied to inbound profiles.
|
||||
MAX_NAME = 50
|
||||
|
||||
UNNAMED = "(unnamed)"
|
||||
|
||||
# Characters that render as nothing but are neither whitespace nor a control
|
||||
# category, so `str.split` and `str.isprintable` both let them through. A stock
|
||||
# client accepts them in a profile name, which makes "ㅤㅤAlice" a
|
||||
# working impersonation of "Alice".
|
||||
# Separators the bot's own messages use. A customer chooses their display name,
|
||||
# and the roster group is the operator's only record of who was added.
|
||||
SEPARATORS = frozenset("→")
|
||||
|
||||
INVISIBLE = frozenset(
|
||||
"ᅟᅠㅤᅠ" # Hangul fillers
|
||||
"⠀" # Braille pattern blank
|
||||
"឴឵" # Khmer inherent vowels
|
||||
"" # word joiner, zero-width no-break space
|
||||
)
|
||||
|
||||
|
||||
def safe_name(name: str) -> str:
|
||||
"""Collapse and truncate a display name for rendering.
|
||||
|
||||
The core does not sanitise inbound profiles: a peer's display name reaches
|
||||
us verbatim and may contain newlines or run to kilobytes. Rendered as-is it
|
||||
forges lines in the roster group and in the log, and can push a message past
|
||||
the size the core will send.
|
||||
"""
|
||||
# NFKC folds compatibility forms, so a name cannot hide behind an exotic
|
||||
# encoding of an ordinary character.
|
||||
collapsed = " ".join(unicodedata.normalize("NFKC", name).split())
|
||||
printable = "".join(
|
||||
c for c in collapsed if c.isprintable() and c not in INVISIBLE and c not in SEPARATORS
|
||||
)
|
||||
stripped = printable.strip()
|
||||
if not stripped:
|
||||
return UNNAMED
|
||||
if len(stripped) > MAX_NAME:
|
||||
return stripped[: MAX_NAME - 1] + "…"
|
||||
return stripped
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Fake ChatApi and wire-object factories. No libsimplex, no I/O."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from simplex_chat import Message, util
|
||||
from simplex_chat.core import ChatAPIError
|
||||
|
||||
USER_ID = 1
|
||||
ROSTER_GROUP_ID = 10
|
||||
|
||||
|
||||
class FakeChatApi:
|
||||
"""Records calls; returns canned wire dicts.
|
||||
|
||||
`fail_on` is a set of method names that raise `ChatAPIError` when called,
|
||||
used to drive the partial-failure paths.
|
||||
"""
|
||||
|
||||
def __init__(self, contacts: list[dict] | None = None, fail_on: set[str] | None = None):
|
||||
self.contacts = contacts or []
|
||||
self.groups: list[dict] = []
|
||||
self.members: dict[int, list[dict]] = {}
|
||||
self.fail_on = fail_on or set()
|
||||
self.custom_data: list[tuple[int, dict | None]] = []
|
||||
self.group_custom_data: list[tuple[int, dict | None]] = []
|
||||
self.replies: list[str] = []
|
||||
self.sent: list[tuple[Any, str]] = []
|
||||
self.added: list[tuple[int, int, str]] = []
|
||||
self.created_member_contacts: list[tuple[int, int]] = []
|
||||
self.invitations: list[tuple[int, str]] = []
|
||||
self.new_groups: list[dict] = []
|
||||
self.profile_updates: list[tuple[int, dict]] = []
|
||||
self.links: list[int] = []
|
||||
self.group_links: dict[int, str] = {}
|
||||
self._next_contact_id = 100
|
||||
self._next_item_id = 1000
|
||||
self._member_contacts_created: set[tuple[int, int]] = set()
|
||||
self.accepted_member_contacts: list[int] = []
|
||||
|
||||
def _check(self, name: str) -> None:
|
||||
if name in self.fail_on:
|
||||
raise ChatAPIError(f"fake failure in {name}", {"type": "chatCmdError"})
|
||||
|
||||
async def api_list_contacts(self, user_id: int) -> list[dict]:
|
||||
self._check("api_list_contacts")
|
||||
# Copies, like the core: a caller holding a contact does not see a later
|
||||
# write to it, so stale reads show up in tests instead of in production.
|
||||
return copy.deepcopy(self.contacts)
|
||||
|
||||
async def api_set_contact_custom_data(self, contact_id: int, custom_data=None) -> None:
|
||||
self._check("api_set_contact_custom_data")
|
||||
self.custom_data.append((contact_id, custom_data))
|
||||
for c in self.contacts:
|
||||
if c["contactId"] == contact_id:
|
||||
if custom_data is None:
|
||||
c.pop("customData", None)
|
||||
else:
|
||||
c["customData"] = custom_data
|
||||
|
||||
async def api_merge_contact_custom_data(self, contact: dict, key: str, value) -> None:
|
||||
# Mirrors ChatApi: the column is replaced wholesale, so a merge is a
|
||||
# read-modify-write through the same set command.
|
||||
await self.api_set_contact_custom_data(
|
||||
contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value)
|
||||
)
|
||||
|
||||
async def api_merge_group_custom_data(self, group: dict, key: str, value) -> None:
|
||||
await self.api_set_group_custom_data(
|
||||
group["groupId"], util.merged_custom_data(group.get("customData"), key, value)
|
||||
)
|
||||
|
||||
async def api_create_member_contact(self, group_id: int, group_member_id: int) -> dict:
|
||||
self._check("api_create_member_contact")
|
||||
key = (group_id, group_member_id)
|
||||
if key in self._member_contacts_created:
|
||||
raise ChatAPIError("member contact already exists", {"type": "chatCmdError"})
|
||||
self._member_contacts_created.add(key)
|
||||
self.created_member_contacts.append((group_id, group_member_id))
|
||||
contact = make_contact(self._next_contact_id, f"member{group_member_id}")
|
||||
self._next_contact_id += 1
|
||||
self.contacts.append(contact)
|
||||
return contact
|
||||
|
||||
async def api_send_member_contact_invitation(self, contact_id: int, message=None) -> dict:
|
||||
self._check("api_send_member_contact_invitation")
|
||||
for c in self.contacts:
|
||||
if c["contactId"] == contact_id and c.get("contactGrpInvSent"):
|
||||
raise ChatAPIError("x.grp.direct.inv already sent", {"type": "chatCmdError"})
|
||||
self.invitations.append((contact_id, message))
|
||||
for c in self.contacts:
|
||||
if c["contactId"] == contact_id:
|
||||
c["contactGrpInvSent"] = True
|
||||
return make_contact(contact_id, "invited", grp_inv_sent=True)
|
||||
|
||||
async def api_accept_member_contact(self, contact_id: int) -> dict:
|
||||
self._check("api_accept_member_contact")
|
||||
self.accepted_member_contacts.append(contact_id)
|
||||
for c in self.contacts:
|
||||
if c["contactId"] == contact_id:
|
||||
c.setdefault("groupDirectInv", {})["groupDirectInvStartedConnection"] = True
|
||||
return c
|
||||
return make_contact(contact_id, "accepted")
|
||||
|
||||
async def api_list_members(self, group_id: int) -> list[dict]:
|
||||
self._check("api_list_members")
|
||||
return list(self.members.get(group_id, []))
|
||||
|
||||
async def api_add_member(self, group_id: int, contact_id: int, member_role: str) -> dict:
|
||||
self._check("api_add_member")
|
||||
self.added.append((group_id, contact_id, member_role))
|
||||
# Distinct id spaces; keep them apart so a mix-up shows up.
|
||||
return make_member(group_member_id=contact_id + 1000, contact_id=contact_id)
|
||||
|
||||
async def api_send_text_message(self, chat, text: str, in_reply_to=None) -> list:
|
||||
self._check("api_send_text_message")
|
||||
self.sent.append((chat, text))
|
||||
return []
|
||||
|
||||
async def api_send_text_reply(self, chat_item, text: str) -> list:
|
||||
self._check("api_send_text_reply")
|
||||
self.replies.append(text)
|
||||
# Message.reply indexes items[0], so this cannot return [].
|
||||
self._next_item_id += 1
|
||||
sent_item = {
|
||||
"chatInfo": chat_item["chatInfo"],
|
||||
"chatItem": {
|
||||
"chatDir": {"type": "direct"},
|
||||
"meta": {"itemId": self._next_item_id},
|
||||
"content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": text}},
|
||||
},
|
||||
}
|
||||
return [sent_item]
|
||||
|
||||
async def api_list_groups(self, user_id: int, contact_id=None, search=None) -> list[dict]:
|
||||
self._check("api_list_groups")
|
||||
return list(self.groups)
|
||||
|
||||
async def api_new_group(self, user_id: int, group_profile: dict) -> dict:
|
||||
self._check("api_new_group")
|
||||
self.new_groups.append(group_profile)
|
||||
group = make_group(ROSTER_GROUP_ID, group_profile)
|
||||
self.groups.append(group)
|
||||
return group
|
||||
|
||||
async def api_set_group_custom_data(self, group_id: int, custom_data=None) -> None:
|
||||
self._check("api_set_group_custom_data")
|
||||
self.group_custom_data.append((group_id, custom_data))
|
||||
for g in self.groups:
|
||||
if g["groupId"] == group_id:
|
||||
g["customData"] = custom_data
|
||||
|
||||
async def api_update_group_profile(self, group_id: int, group_profile: dict) -> dict:
|
||||
self._check("api_update_group_profile")
|
||||
self.profile_updates.append((group_id, group_profile))
|
||||
return make_group(group_id, group_profile)
|
||||
|
||||
async def api_create_group_link(self, group_id: int, member_role: str) -> str:
|
||||
self._check("api_create_group_link")
|
||||
self.links.append(group_id)
|
||||
link = f"https://simplex.chat/contact#/?v=2&group={group_id}"
|
||||
self.group_links[group_id] = link
|
||||
return link
|
||||
|
||||
async def api_get_group_link_str(self, group_id: int) -> str:
|
||||
self._check("api_get_group_link_str")
|
||||
try:
|
||||
return self.group_links[group_id]
|
||||
except KeyError:
|
||||
raise ChatAPIError("no group link", {"type": "chatCmdError"}) from None
|
||||
|
||||
|
||||
def make_contact(
|
||||
contact_id: int,
|
||||
name: str,
|
||||
custom_data: dict | None = None,
|
||||
connected: bool = False,
|
||||
grp_inv_sent: bool = False,
|
||||
grp_member_id: int | None = -1,
|
||||
conn_status: str | None = None,
|
||||
) -> dict:
|
||||
contact: dict = {
|
||||
"contactId": contact_id,
|
||||
"localDisplayName": name,
|
||||
"profile": {"profileId": contact_id, "displayName": name, "fullName": ""},
|
||||
"contactGrpInvSent": grp_inv_sent,
|
||||
}
|
||||
if custom_data is not None:
|
||||
contact["customData"] = custom_data
|
||||
if conn_status is not None:
|
||||
contact["activeConn"] = {"connStatus": {"type": conn_status}}
|
||||
elif connected:
|
||||
contact["activeConn"] = {"connStatus": {"type": "ready"}}
|
||||
# The core sets contactGroupMemberId when a member contact is created and
|
||||
# clears it once that contact connects (resetMemberContactFields). -1 means
|
||||
# "use whichever of those matches `connected`".
|
||||
if grp_member_id == -1:
|
||||
grp_member_id = None if connected else contact_id
|
||||
if grp_member_id is not None:
|
||||
contact["contactGroupMemberId"] = grp_member_id
|
||||
return contact
|
||||
|
||||
|
||||
def make_member(
|
||||
group_member_id: int = 1,
|
||||
contact_id: int | None = None,
|
||||
name: str = "someone",
|
||||
status: str = "complete",
|
||||
) -> dict:
|
||||
member: dict = {
|
||||
"groupMemberId": group_member_id,
|
||||
"localDisplayName": name,
|
||||
"memberProfile": {"displayName": name, "fullName": ""},
|
||||
"memberStatus": status,
|
||||
}
|
||||
if contact_id is not None:
|
||||
member["memberContactId"] = contact_id
|
||||
return member
|
||||
|
||||
|
||||
def make_group(
|
||||
group_id: int,
|
||||
profile: dict,
|
||||
custom_data: dict | None = None,
|
||||
membership_status: str = "creator",
|
||||
) -> dict:
|
||||
# The core always sends membership; discovery reads it to skip groups the
|
||||
# bot has left.
|
||||
group: dict = {
|
||||
"groupId": group_id,
|
||||
"groupProfile": profile,
|
||||
"localDisplayName": "g",
|
||||
"membership": make_member(1, name="bot", status=membership_status),
|
||||
}
|
||||
if custom_data is not None:
|
||||
group["customData"] = custom_data
|
||||
return group
|
||||
|
||||
|
||||
def join_roster_group(api: FakeChatApi) -> None:
|
||||
"""Put every contact in the roster group.
|
||||
|
||||
Being on the roster means being in that group; the bot re-checks it before
|
||||
adding anyone to a customer's chat, so tests have to model it.
|
||||
"""
|
||||
api.members[ROSTER_GROUP_ID] = [
|
||||
make_member(1000 + c["contactId"], contact_id=c["contactId"], name=c["localDisplayName"])
|
||||
for c in api.contacts
|
||||
]
|
||||
|
||||
|
||||
def make_group_message(api: FakeChatApi, member: dict, text: str, group_id: int = ROSTER_GROUP_ID):
|
||||
"""A `Message` as delivered from a group, wired to the fake api."""
|
||||
chat_item = {
|
||||
"chatInfo": {"type": "group", "groupInfo": make_group(group_id, {"displayName": "r"})},
|
||||
"chatItem": {
|
||||
"chatDir": {"type": "groupRcv", "groupMember": member},
|
||||
"meta": {"itemId": 1},
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}},
|
||||
},
|
||||
}
|
||||
return Message(
|
||||
chat_item=chat_item,
|
||||
content={"type": "text", "text": text},
|
||||
client=SimpleNamespace(api=api),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api() -> FakeChatApi:
|
||||
return FakeChatApi()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Constants and boundaries pinned at their exact edge.
|
||||
|
||||
Each of these was a surviving mutant: the value could be moved by one, or a
|
||||
member of a set removed, with the whole suite still green.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from support_bot_light import commands, config, messages, roster, setup, text
|
||||
from support_bot_light.config import ConfigError, load_config
|
||||
from tests.conftest import make_contact, make_member
|
||||
from tests.test_config import VALID, write
|
||||
|
||||
|
||||
def entry(name: str, state: str = "active", reachable: bool = True) -> roster.RosterEntry:
|
||||
return roster.RosterEntry(
|
||||
contact_id=1, name=name, state=state, since="2026-08-13", reachable=reachable
|
||||
)
|
||||
|
||||
|
||||
def test_the_roster_group_link_hands_out_the_member_role():
|
||||
# An owner could remove the bot from its own roster group.
|
||||
assert setup.JOIN_ROLE == "member"
|
||||
|
||||
|
||||
def test_both_ready_statuses_make_a_contact_usable():
|
||||
# contactSndReady is a distinct event from contactConnected, and a member
|
||||
# promoted by one must not be treated as unreachable by the other.
|
||||
for status in ("ready", "sndReady"):
|
||||
assert roster.contact_usable(make_contact(1, "sh", conn_status=status)) is True
|
||||
assert roster.contact_usable(make_contact(1, "sh", conn_status="accepted")) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["deleted", "failed"])
|
||||
def test_a_dead_connection_is_not_accepted_or_connecting(status):
|
||||
contact = make_contact(1, "sh", conn_status=status)
|
||||
contact["groupDirectInv"] = {"groupDirectInvLink": "x", "groupDirectInvStartedConnection": True}
|
||||
assert roster.accept_started(contact) is False
|
||||
assert roster.connecting(contact) is False
|
||||
|
||||
|
||||
def test_a_member_of_the_roster_group_is_in_it_until_a_terminal_status():
|
||||
assert roster.in_group(make_member(1, status="pending_approval")) is True
|
||||
assert roster.in_group(make_member(1, status="invited")) is True
|
||||
assert roster.in_group(make_member(1, status="left")) is False
|
||||
|
||||
|
||||
def test_list_shows_forty_before_it_summarises():
|
||||
# 40 keeps the reply inside the core's wire limit with room for two more
|
||||
# sections; the literal is the point, so a change has to be deliberate.
|
||||
assert messages.MAX_LISTED == 40
|
||||
at_cap = messages.render_roster([entry(f"n{i}") for i in range(40)])
|
||||
assert at_cap.count("•") == 40
|
||||
assert "more" not in at_cap
|
||||
|
||||
over_cap = messages.render_roster([entry(f"n{i}") for i in range(41)])
|
||||
assert over_cap.count("•") == 40
|
||||
assert "… and 1 more" in over_cap
|
||||
|
||||
|
||||
def test_a_reply_at_the_byte_cap_is_not_truncated():
|
||||
room = messages.MAX_REPLY_BYTES - len("On the roster (1):\n • ") - len(" — since 2026-08-13")
|
||||
assert messages.render_roster([entry("a" * min(room, text.MAX_NAME))]).endswith("2026-08-13")
|
||||
|
||||
over = [entry("漢" * text.MAX_NAME) for _ in range(messages.MAX_LISTED)]
|
||||
over += [entry("漢" * text.MAX_NAME, state="pending") for _ in range(messages.MAX_LISTED)]
|
||||
rendered = messages.render_roster(over)
|
||||
assert len(rendered.encode()) <= messages.MAX_REPLY_BYTES
|
||||
assert rendered.endswith(messages.TRUNCATED)
|
||||
|
||||
|
||||
def test_a_name_of_fifty_is_kept_whole():
|
||||
# mkValidName caps a locally entered name at 50; inbound profiles are not
|
||||
# capped at all, which is why this exists.
|
||||
assert text.MAX_NAME == 50
|
||||
assert text.safe_name("a" * 50) == "a" * 50
|
||||
over = text.safe_name("a" * 51)
|
||||
assert len(over) == 50 and over.endswith("…")
|
||||
|
||||
|
||||
def test_a_welcome_of_twelve_thousand_bytes_is_accepted(tmp_path):
|
||||
assert config.MAX_WELCOME_BYTES == 12000
|
||||
at_cap = "w" * 12000
|
||||
text_at = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{at_cap}"')
|
||||
assert load_config(write(tmp_path, text_at)).welcome == at_cap
|
||||
|
||||
over = "w" * 12001
|
||||
text_over = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{over}"')
|
||||
with pytest.raises(ConfigError, match="too long"):
|
||||
load_config(write(tmp_path, text_over))
|
||||
|
||||
|
||||
def test_an_image_of_9357_bytes_is_accepted(tmp_path):
|
||||
# 9357 raw bytes is what a 12500-character data URI holds once base64 and
|
||||
# the "data:image/png;base64," prefix are added. The pre-read check must
|
||||
# admit everything the encoded cap can hold, and no more.
|
||||
assert config.MAX_IMAGE_BYTES == 9357
|
||||
at_cap = tmp_path / "a.png"
|
||||
at_cap.write_bytes(b"\x89PNG" + b"x" * (9357 - 4))
|
||||
conf = VALID.replace("[roster]", f'image = "{at_cap}"\n\n[roster]')
|
||||
assert load_config(write(tmp_path, conf)).image is not None
|
||||
|
||||
over = tmp_path / "b.png"
|
||||
over.write_bytes(b"\x89PNG" + b"x" * (9358 - 4))
|
||||
conf_over = VALID.replace("[roster]", f'image = "{over}"\n\n[roster]')
|
||||
with pytest.raises(ConfigError, match="too large"):
|
||||
load_config(write(tmp_path, conf_over))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [1, 65535])
|
||||
def test_the_port_range_ends_are_accepted(tmp_path, port):
|
||||
assert config.MAX_PORT == 65535
|
||||
conf = load_config(write(tmp_path, VALID + f"\n[health]\nport = {port}\n"))
|
||||
assert conf.health is not None and conf.health.port == port
|
||||
|
||||
|
||||
def test_the_command_menu_carries_every_command():
|
||||
wire = commands.to_wire(commands.COMMANDS)
|
||||
assert [c["keyword"] for c in wire] == [
|
||||
commands.DM,
|
||||
commands.LIST,
|
||||
commands.LEAVE,
|
||||
commands.HELP,
|
||||
]
|
||||
@@ -0,0 +1,534 @@
|
||||
import pytest
|
||||
from simplex_chat import ChatCommandError
|
||||
|
||||
from support_bot_light import business, messages
|
||||
from support_bot_light.config import Config
|
||||
from support_bot_light.context import BotContext
|
||||
from tests.conftest import (
|
||||
ROSTER_GROUP_ID,
|
||||
USER_ID,
|
||||
join_roster_group,
|
||||
make_contact,
|
||||
make_group,
|
||||
make_member,
|
||||
)
|
||||
|
||||
BUSINESS_GROUP_ID = 42
|
||||
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(api):
|
||||
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
||||
|
||||
|
||||
def event(name="Alex"):
|
||||
return {
|
||||
"type": "acceptingBusinessRequest",
|
||||
"groupInfo": make_group(BUSINESS_GROUP_ID, {"displayName": name, "fullName": ""}),
|
||||
}
|
||||
|
||||
|
||||
async def test_adds_active_roster_members(ctx, api):
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(
|
||||
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(3, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}),
|
||||
]
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [
|
||||
(BUSINESS_GROUP_ID, 2, "owner"),
|
||||
(BUSINESS_GROUP_ID, 1, "owner"),
|
||||
]
|
||||
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added Narasimha, sh")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["rejected", "removed", "left", "deleted", "unknown"])
|
||||
async def test_does_not_add_someone_who_has_left_the_roster_group(ctx, api, status):
|
||||
# The departure event and a queued business request arrive in whatever order
|
||||
# the core dispatches them, so an active mark is not authority on its own:
|
||||
# this is what stops a departed member reading a conversation started after
|
||||
# they went.
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(
|
||||
2, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
]
|
||||
join_roster_group(api)
|
||||
api.members[ROSTER_GROUP_ID][1]["memberStatus"] = status
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added sh")]
|
||||
|
||||
|
||||
async def test_reconcile_does_not_add_someone_who_has_left_the_roster_group(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
api.members[ROSTER_GROUP_ID][0]["memberStatus"] = "removed"
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
|
||||
|
||||
async def test_skips_members_already_in_the_group(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="invited")]
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == []
|
||||
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_does_not_skip_members_who_left(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
|
||||
|
||||
async def test_empty_roster_logs_and_adds_nobody(ctx, api):
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == []
|
||||
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_pending_only_roster_counts_as_empty(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(1, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}})
|
||||
)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == []
|
||||
assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_one_failure_does_not_block_the_rest(ctx, api):
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(
|
||||
2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
]
|
||||
calls: list[int] = []
|
||||
original = api.api_add_member
|
||||
|
||||
async def flaky(group_id, contact_id, member_role):
|
||||
calls.append(contact_id)
|
||||
if contact_id == 2:
|
||||
raise ChatCommandError("nope", {"type": "chatCmdError"})
|
||||
return await original(group_id, contact_id, member_role)
|
||||
|
||||
api.api_add_member = flaky
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert sorted(calls) == [1, 2] # both attempted
|
||||
assert api.sent[-1][1] == "Connected: Alex → added sh (failed: Narasimha)"
|
||||
|
||||
|
||||
async def test_roster_read_failure_logs_and_adds_nobody(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_list_contacts")
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == []
|
||||
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_member_list_failure_logs_and_adds_nobody(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_list_members")
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == []
|
||||
assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_uses_configured_member_role(api):
|
||||
ctx = BotContext(
|
||||
api=api,
|
||||
user_id=USER_ID,
|
||||
roster_group_id=ROSTER_GROUP_ID,
|
||||
config=Config("S", "./x", "hi", "R", "admin"),
|
||||
)
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "admin")]
|
||||
|
||||
|
||||
async def test_reconcile_repairs_a_chat_left_half_added_by_a_crash(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
assert api.sent[-1][1] == "Connected: Alex → added sh"
|
||||
|
||||
|
||||
async def test_reconcile_is_idempotent_when_everyone_is_present(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="complete")]
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
# The chat was left unmarked, so a crash took the roster group's record of
|
||||
# this customer with it; the repair puts it back even with nothing to add.
|
||||
assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex")
|
||||
|
||||
|
||||
async def test_reconcile_skips_non_business_groups(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": ROSTER_GROUP_ID,
|
||||
"groupProfile": {"displayName": "roster", "fullName": ""},
|
||||
"localDisplayName": "roster",
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
|
||||
|
||||
async def test_reconcile_skips_a_chat_the_bot_has_left(ctx, api):
|
||||
# The core keeps the group row after removal; adding into it would fail on
|
||||
# every start, and the customer is no longer the bot's to serve.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="removed"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
assert api.group_custom_data == []
|
||||
|
||||
|
||||
async def test_reconcile_failure_does_not_stop_startup(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_list_groups")
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx) # must not raise
|
||||
|
||||
|
||||
async def test_reconcile_skips_a_chat_whose_roster_pass_already_ran(ctx, api):
|
||||
# Someone who joins the roster later must not be back-filled into every
|
||||
# conversation the bot has ever handled.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "newbie", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
"customData": {"supportBotLight": {"rostered": True}},
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
assert api.sent == []
|
||||
|
||||
|
||||
async def test_reconcile_does_not_re_invite_someone_who_left_a_chat(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
"customData": {"supportBotLight": {"rostered": True}},
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")]
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
|
||||
|
||||
async def test_on_business_request_marks_the_chat_as_rostered(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.group_custom_data[-1] == (
|
||||
BUSINESS_GROUP_ID,
|
||||
{"supportBotLight": {"rostered": True}},
|
||||
)
|
||||
|
||||
|
||||
async def test_reconcile_marks_chats_even_with_an_empty_roster(ctx, api):
|
||||
# Otherwise the chat stays unmarked and a later restart back-fills whoever
|
||||
# joined the roster in the meantime.
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.group_custom_data[-1] == (
|
||||
BUSINESS_GROUP_ID,
|
||||
{"supportBotLight": {"rostered": True}},
|
||||
)
|
||||
assert api.added == []
|
||||
|
||||
|
||||
async def test_a_failed_mark_does_not_report_nobody_added(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_set_group_custom_data")
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
assert api.sent[-1][1] == "Connected: Alex → added sh"
|
||||
|
||||
|
||||
async def test_a_chat_where_every_add_failed_is_retried_next_start(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_add_member")
|
||||
join_roster_group(api)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.group_custom_data == [] # not marked, so repair will revisit it
|
||||
|
||||
|
||||
async def test_a_chat_left_unmarked_is_not_back_filled_with_a_later_roster(ctx, api):
|
||||
# The one bit that keeps a new roster member out of old conversations.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
api.fail_on.add("api_send_text_message")
|
||||
await business.on_business_request(ctx, event())
|
||||
api.fail_on.clear()
|
||||
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
"customData": api.group_custom_data[-1][1],
|
||||
}
|
||||
)
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
2, "newbie", {"supportBotLight": {"roster": "active", "since": "y"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
api.added.clear()
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == []
|
||||
|
||||
|
||||
async def test_a_chat_is_marked_even_when_its_line_never_went_out(ctx, api):
|
||||
# An unmarked chat is repaired by every later start with the roster of the
|
||||
# day, so withholding the marker to preserve a log line would hand this
|
||||
# customer's conversation to whoever joins the roster next.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
join_roster_group(api)
|
||||
api.fail_on.add("api_send_text_message")
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
assert api.group_custom_data[-1][1] == {"supportBotLight": {"rostered": True}}
|
||||
|
||||
|
||||
async def test_a_failed_mark_still_reports_the_repaired_chat(ctx, api):
|
||||
# The marker is re-derived on the next start; the report is not, because
|
||||
# the event that would have produced it was consumed before the crash.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
api.fail_on.add("api_set_group_custom_data")
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
assert api.sent[-1][1] == "Connected: Alex → added sh"
|
||||
|
||||
|
||||
async def test_an_unfinished_repair_is_retried_by_the_queued_event(ctx, api):
|
||||
# Nothing was added and the chat was left unmarked, so the event that the
|
||||
# startup pass raced is the only remaining chance to finish it.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
api.fail_on.add("api_add_member")
|
||||
await business.reconcile_chats(ctx)
|
||||
assert api.group_custom_data == [] # not marked: the repair failed
|
||||
|
||||
api.fail_on.clear()
|
||||
await business.on_business_request(ctx, event())
|
||||
assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")]
|
||||
|
||||
|
||||
async def test_repair_does_not_re_report_a_chat_to_the_event_handler(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.groups.append(
|
||||
{
|
||||
"groupId": BUSINESS_GROUP_ID,
|
||||
"groupProfile": {"displayName": "Alex", "fullName": ""},
|
||||
"localDisplayName": "Alex",
|
||||
"businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"},
|
||||
"membership": make_member(99, name="bot", status="complete"),
|
||||
}
|
||||
)
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
join_roster_group(api)
|
||||
await business.reconcile_chats(ctx)
|
||||
posts_after_repair = len(api.sent)
|
||||
await business.on_business_request(ctx, event())
|
||||
assert len(api.sent) == posts_after_repair # the queued event adds no line
|
||||
|
||||
# Only that one event is swallowed: the same customer coming back later
|
||||
# must be handled like anyone else.
|
||||
api.members[BUSINESS_GROUP_ID] = []
|
||||
await business.on_business_request(ctx, event())
|
||||
assert len(api.sent) == posts_after_repair + 1
|
||||
@@ -0,0 +1,32 @@
|
||||
from simplex_chat import BotCommand
|
||||
|
||||
from support_bot_light.commands import COMMANDS, to_wire
|
||||
|
||||
|
||||
def test_declares_four_commands():
|
||||
assert tuple(c.keyword for c in COMMANDS) == ("dm", "list", "leave", "help")
|
||||
|
||||
|
||||
def test_no_command_takes_params():
|
||||
# Zero-argument commands send on tap instead of pasting a placeholder.
|
||||
assert all(c.params is None for c in COMMANDS)
|
||||
|
||||
|
||||
def test_to_wire_omits_params_when_none():
|
||||
wire = to_wire([BotCommand(keyword="list", label="Who gets invited")])
|
||||
assert wire == [{"type": "command", "keyword": "list", "label": "Who gets invited"}]
|
||||
assert "params" not in wire[0]
|
||||
|
||||
|
||||
def test_to_wire_includes_params_when_set():
|
||||
wire = to_wire([BotCommand(keyword="x", label="X", params="<n>")])
|
||||
assert wire == [{"type": "command", "keyword": "x", "label": "X", "params": "<n>"}]
|
||||
|
||||
|
||||
def test_to_wire_distinguishes_none_from_empty_string():
|
||||
assert "params" not in to_wire([BotCommand("a", "A")])[0]
|
||||
assert to_wire([BotCommand("b", "B", params="")])[0]["params"] == ""
|
||||
|
||||
|
||||
def test_to_wire_preserves_declaration_order():
|
||||
assert [c["keyword"] for c in to_wire(COMMANDS)] == [c.keyword for c in COMMANDS]
|
||||
@@ -0,0 +1,286 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from support_bot_light.config import Config, ConfigError, Health, load_config
|
||||
|
||||
VALID = """
|
||||
[bot]
|
||||
display_name = "Support"
|
||||
db_prefix = "./support_bot_light"
|
||||
welcome = "Hi! Someone will join shortly."
|
||||
|
||||
[roster]
|
||||
group_name = "Invite roster"
|
||||
member_role = "admin"
|
||||
"""
|
||||
|
||||
# Minimal 1x1 PNG. The loader never parses it, only encodes the bytes.
|
||||
PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef"
|
||||
b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def write(tmp_path, text):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_loads_all_fields(tmp_path):
|
||||
cfg = load_config(write(tmp_path, VALID))
|
||||
assert cfg == Config(
|
||||
display_name="Support",
|
||||
db_prefix="./support_bot_light",
|
||||
welcome="Hi! Someone will join shortly.",
|
||||
group_name="Invite roster",
|
||||
member_role="admin",
|
||||
health=Health(host="127.0.0.1", port=8080),
|
||||
)
|
||||
|
||||
|
||||
def test_member_role_defaults_to_owner(tmp_path):
|
||||
text = VALID.replace('member_role = "admin"\n', "")
|
||||
assert load_config(write(tmp_path, text)).member_role == "owner"
|
||||
|
||||
|
||||
def test_rejects_unknown_member_role(tmp_path):
|
||||
text = VALID.replace('"admin"', '"chief"')
|
||||
with pytest.raises(ConfigError, match="member_role"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_missing_key(tmp_path):
|
||||
text = VALID.replace('welcome = "Hi! Someone will join shortly."\n', "")
|
||||
with pytest.raises(ConfigError, match="bot.welcome"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_missing_bot_section(tmp_path):
|
||||
with pytest.raises(ConfigError, match=r"missing \[bot\] section"):
|
||||
load_config(write(tmp_path, '[roster]\ngroup_name = "R"\n'))
|
||||
|
||||
|
||||
def test_rejects_missing_roster_section(tmp_path):
|
||||
with pytest.raises(ConfigError, match=r"missing \[roster\] section"):
|
||||
load_config(write(tmp_path, "[bot]\n"))
|
||||
|
||||
|
||||
def test_rejects_empty_string(tmp_path):
|
||||
text = VALID.replace('"Invite roster"', '" "')
|
||||
with pytest.raises(ConfigError, match="roster.group_name"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_missing_file(tmp_path):
|
||||
with pytest.raises(ConfigError, match="not found"):
|
||||
load_config(tmp_path / "nope.toml")
|
||||
|
||||
|
||||
def test_invalid_toml(tmp_path):
|
||||
with pytest.raises(ConfigError, match="invalid TOML"):
|
||||
load_config(write(tmp_path, "[bot"))
|
||||
|
||||
|
||||
def test_image_defaults_to_none(tmp_path):
|
||||
assert load_config(write(tmp_path, VALID)).image is None
|
||||
|
||||
|
||||
def test_png_image_encodes_with_prefix_and_roundtrips(tmp_path):
|
||||
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
image = load_config(write(tmp_path, text)).image
|
||||
assert image is not None
|
||||
prefix = "data:image/png;base64,"
|
||||
assert image.startswith(prefix)
|
||||
assert base64.b64decode(image[len(prefix) :]) == PNG_BYTES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ext", ["jpg", "jpeg"])
|
||||
def test_jpg_and_jpeg_extensions_encode_as_jpg(tmp_path, ext):
|
||||
(tmp_path / f"avatar.{ext}").write_bytes(b"not really a jpeg, just bytes")
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
f'db_prefix = "./support_bot_light"\nimage = "avatar.{ext}"',
|
||||
)
|
||||
image = load_config(write(tmp_path, text)).image
|
||||
assert image is not None
|
||||
assert image.startswith("data:image/jpg;base64,")
|
||||
|
||||
|
||||
def test_uppercase_extension_accepted(tmp_path):
|
||||
(tmp_path / "avatar.PNG").write_bytes(PNG_BYTES)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.PNG"',
|
||||
)
|
||||
image = load_config(write(tmp_path, text)).image
|
||||
assert image is not None
|
||||
assert image.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_rejects_unsupported_extension(tmp_path):
|
||||
(tmp_path / "avatar.gif").write_bytes(b"gif bytes")
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.gif"',
|
||||
)
|
||||
with pytest.raises(ConfigError, match=r"\.gif"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_missing_image_file(tmp_path):
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "missing.png"',
|
||||
)
|
||||
resolved = tmp_path / "missing.png"
|
||||
with pytest.raises(ConfigError, match=r"not found.*missing\.png|missing\.png.*not found"):
|
||||
load_config(write(tmp_path, text))
|
||||
assert not resolved.exists()
|
||||
|
||||
|
||||
def test_rejects_oversized_image(tmp_path):
|
||||
# 12500 caps the whole data URI, prefix included.
|
||||
(tmp_path / "avatar.png").write_bytes(b"\x00" * 20000)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="12500"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_empty_image_string(tmp_path):
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"', 'db_prefix = "./support_bot_light"\nimage = " "'
|
||||
)
|
||||
with pytest.raises(ConfigError, match="bot.image"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_relative_image_path_resolves_against_config_dir(tmp_path, monkeypatch):
|
||||
other_dir = tmp_path / "elsewhere"
|
||||
other_dir.mkdir()
|
||||
monkeypatch.chdir(other_dir)
|
||||
|
||||
(tmp_path / "avatar.png").write_bytes(PNG_BYTES)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
image = load_config(write(tmp_path, text)).image
|
||||
assert image is not None
|
||||
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
|
||||
|
||||
|
||||
def test_absolute_image_path_works(tmp_path):
|
||||
image_path = tmp_path / "avatar.png"
|
||||
image_path.write_bytes(PNG_BYTES)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
f'db_prefix = "./support_bot_light"\nimage = "{image_path}"',
|
||||
)
|
||||
image = load_config(write(tmp_path, text)).image
|
||||
assert image is not None
|
||||
assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES
|
||||
|
||||
|
||||
def test_rejects_empty_image_file(tmp_path):
|
||||
(tmp_path / "avatar.png").write_bytes(b"")
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="empty"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_a_non_regular_image_file(tmp_path):
|
||||
import os
|
||||
|
||||
os.mkfifo(tmp_path / "avatar.png")
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="not a regular file"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_an_oversized_image_before_reading_it(tmp_path):
|
||||
(tmp_path / "avatar.png").write_bytes(b"A" * 20000)
|
||||
text = VALID.replace(
|
||||
'db_prefix = "./support_bot_light"',
|
||||
'db_prefix = "./support_bot_light"\nimage = "avatar.png"',
|
||||
)
|
||||
with pytest.raises(ConfigError, match="bytes exceeds"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_rejects_an_over_long_welcome(tmp_path):
|
||||
text = VALID.replace(
|
||||
'welcome = "Hi! Someone will join shortly."', 'welcome = "' + "x" * 20000 + '"'
|
||||
)
|
||||
with pytest.raises(ConfigError, match="too long"):
|
||||
load_config(write(tmp_path, text))
|
||||
|
||||
|
||||
def test_health_is_on_without_configuration(tmp_path):
|
||||
assert load_config(write(tmp_path, VALID)).health == Health(host="127.0.0.1", port=8080)
|
||||
|
||||
|
||||
def test_health_can_be_switched_off(tmp_path):
|
||||
assert load_config(write(tmp_path, VALID + "\n[health]\nenabled = false\n")).health is None
|
||||
|
||||
|
||||
def test_health_port_can_be_set(tmp_path):
|
||||
config = load_config(write(tmp_path, VALID + "\n[health]\nport = 9999\n"))
|
||||
assert config.health == Health(host="127.0.0.1", port=9999, configured=True)
|
||||
|
||||
|
||||
def test_the_default_port_is_not_treated_as_chosen(tmp_path):
|
||||
# A port nobody asked for must not be able to stop the bot from starting.
|
||||
assert load_config(write(tmp_path, VALID)).health == Health("127.0.0.1", 8080)
|
||||
assert load_config(write(tmp_path, VALID)).health.configured is False
|
||||
|
||||
|
||||
def test_health_host_can_be_set(tmp_path):
|
||||
config = load_config(write(tmp_path, VALID + '\n[health]\nhost = "0.0.0.0"\nport = 9000\n'))
|
||||
assert config.health == Health(host="0.0.0.0", port=9000, configured=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"section",
|
||||
[
|
||||
'[health]\nenabled = "yes"\n',
|
||||
"[health]\nport = 0\n",
|
||||
"[health]\nport = 65536\n",
|
||||
"[health]\nport = true\n", # TOML booleans are ints in Python
|
||||
'[health]\nport = "8080"\n',
|
||||
'[health]\nport = 8080\nhost = " "\n',
|
||||
],
|
||||
)
|
||||
def test_invalid_health_settings_are_rejected(tmp_path, section):
|
||||
with pytest.raises(ConfigError):
|
||||
load_config(write(tmp_path, VALID + "\n" + section))
|
||||
|
||||
|
||||
def test_a_missing_config_points_at_the_template(tmp_path):
|
||||
# Under Docker this is a restart loop until the operator acts, so the error
|
||||
# has to say what the action is.
|
||||
(tmp_path / "config.toml.example").write_text(VALID, encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="copy config.toml.example to config.toml"):
|
||||
load_config(tmp_path / "config.toml")
|
||||
|
||||
|
||||
def test_a_missing_config_without_a_template_says_only_that(tmp_path):
|
||||
with pytest.raises(ConfigError, match="not found") as raised:
|
||||
load_config(tmp_path / "config.toml")
|
||||
assert "copy" not in str(raised.value)
|
||||
@@ -0,0 +1,720 @@
|
||||
import pytest
|
||||
from simplex_chat import ChatCommandError
|
||||
|
||||
from support_bot_light import handlers, messages, roster
|
||||
from support_bot_light.config import Config
|
||||
from support_bot_light.context import BotContext
|
||||
from tests.conftest import (
|
||||
ROSTER_GROUP_ID,
|
||||
USER_ID,
|
||||
make_contact,
|
||||
make_group,
|
||||
make_group_message,
|
||||
make_member,
|
||||
)
|
||||
|
||||
CONFIG = Config(
|
||||
display_name="Support",
|
||||
db_prefix="./x",
|
||||
welcome="hi",
|
||||
group_name="Invite roster",
|
||||
member_role="owner",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(api):
|
||||
# The bot always has its own marked roster group; reconcile checks for it.
|
||||
api.groups.append(
|
||||
make_group(
|
||||
ROSTER_GROUP_ID,
|
||||
{"displayName": "Invite roster", "fullName": ""},
|
||||
custom_data={"supportBotLight": {"group": "roster"}},
|
||||
)
|
||||
)
|
||||
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
||||
|
||||
|
||||
async def test_dm_with_existing_contact_marks_active(ctx, api):
|
||||
api.contacts.append(make_contact(7, "sh", connected=True))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active"
|
||||
assert api.replies == [messages.ADDED]
|
||||
assert api.created_member_contacts == []
|
||||
|
||||
|
||||
async def test_dm_promotes_pending_contact_keeps_original_since(ctx, api):
|
||||
# Self-heal after a missed contactConnected; the ask date must survive.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"Alex",
|
||||
{"supportBotLight": {"roster": "pending", "since": "2026-08-01T00:00:00+00:00"}},
|
||||
connected=True,
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"] == {
|
||||
"roster": "active",
|
||||
"since": "2026-08-01T00:00:00+00:00",
|
||||
}
|
||||
assert api.replies == [messages.ADDED]
|
||||
|
||||
|
||||
async def test_dm_promotes_usable_contact_even_if_invitation_was_sent(ctx, api):
|
||||
# The invitation is what made the contact usable, so both flags are set.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"Alex",
|
||||
{"supportBotLight": {"roster": "pending", "since": "x"}},
|
||||
connected=True,
|
||||
grp_inv_sent=True,
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active"
|
||||
assert api.replies == [messages.ADDED]
|
||||
|
||||
|
||||
async def test_dm_without_contact_creates_and_invites(ctx, api):
|
||||
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
|
||||
assert api.invitations == [(100, messages.INVITATION_TEXT)]
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
assert api.replies == [messages.INVITATION_SENT]
|
||||
|
||||
|
||||
async def test_dm_replies_invitation_failed_when_send_fails(ctx, api):
|
||||
api.fail_on.add("api_send_member_contact_invitation")
|
||||
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
assert api.replies == [messages.INVITATION_FAILED]
|
||||
|
||||
|
||||
async def test_dm_while_pending_and_invitation_sent_is_a_noop(ctx, api):
|
||||
# The core rejects a second invitation.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.invitations == []
|
||||
assert api.custom_data == [] # already pending, mark untouched
|
||||
assert api.replies == [messages.STILL_PENDING]
|
||||
|
||||
|
||||
async def test_dm_while_pending_and_invitation_never_sent_resends_it(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}})
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.invitations == [(7, messages.INVITATION_TEXT)]
|
||||
assert api.custom_data == [] # already pending, mark untouched
|
||||
assert api.replies == [messages.INVITATION_SENT]
|
||||
|
||||
|
||||
async def test_dm_when_already_active_is_a_noop(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data == []
|
||||
assert api.replies == [messages.ALREADY_ACTIVE]
|
||||
|
||||
|
||||
async def test_dm_ignores_non_group_message(ctx, api):
|
||||
msg = make_group_message(api, make_member(1), "/dm")
|
||||
msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"}
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.replies == [] and api.custom_data == []
|
||||
|
||||
|
||||
async def test_dm_recovers_when_contact_vanished(ctx, api):
|
||||
# memberContactId points at a contact that no longer exists.
|
||||
msg = make_group_message(api, make_member(1, contact_id=404, name="ghost"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
|
||||
|
||||
|
||||
async def test_dm_replies_command_failed_when_api_fails(ctx, api):
|
||||
# api_create_member_contact has no try/except of its own.
|
||||
api.fail_on.add("api_create_member_contact")
|
||||
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.replies == [messages.COMMAND_FAILED]
|
||||
|
||||
|
||||
async def test_dm_lets_unexpected_errors_propagate(ctx, api):
|
||||
async def boom(contact_id, message=None):
|
||||
raise RuntimeError("network on fire")
|
||||
|
||||
api.api_send_member_contact_invitation = boom
|
||||
msg = make_group_message(api, make_member(1, name="Alex"), "/dm")
|
||||
with pytest.raises(RuntimeError):
|
||||
await handlers.dm(ctx, msg)
|
||||
|
||||
|
||||
async def test_dm_after_leave_does_not_promote_unconnected_contact(ctx, api):
|
||||
member = make_member(1, name="Alex")
|
||||
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
|
||||
assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)]
|
||||
created_contact_id = api.contacts[-1]["contactId"]
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
|
||||
# The core sets memberContactId as soon as the contact exists.
|
||||
member["memberContactId"] = created_contact_id
|
||||
await handlers.leave(ctx, make_group_message(api, member, "/leave"))
|
||||
api.custom_data.clear()
|
||||
|
||||
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
|
||||
written = api.custom_data[-1][1]["supportBotLight"]["roster"] if api.custom_data else None
|
||||
assert written != "active", "unconnected contact must never be marked active"
|
||||
|
||||
|
||||
async def test_dm_after_leave_still_promotes_on_accept(ctx, api):
|
||||
"""/dm -> /leave -> /dm -> accept must end up active.
|
||||
|
||||
/leave clears our roster mark, but the core's contactGrpInvSent survives and
|
||||
cannot be unset, so the second /dm must re-establish the pending mark
|
||||
or the eventual acceptance has nothing to promote.
|
||||
"""
|
||||
member = make_member(1, name="Alex")
|
||||
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
|
||||
contact_id = api.contacts[-1]["contactId"]
|
||||
# The fake names the contact after the group member id, not the member.
|
||||
contact_name = api.contacts[-1]["profile"]["displayName"]
|
||||
member["memberContactId"] = contact_id
|
||||
|
||||
await handlers.leave(ctx, make_group_message(api, member, "/leave"))
|
||||
await handlers.dm(ctx, make_group_message(api, member, "/dm"))
|
||||
assert api.replies[-1] == messages.STILL_PENDING
|
||||
|
||||
for c in api.contacts:
|
||||
if c["contactId"] == contact_id:
|
||||
c["activeConn"] = {"connStatus": {"type": "ready"}}
|
||||
await handlers.contact_ready(ctx, contact_id)
|
||||
assert [e.name for e in await roster.active(api, USER_ID)] == [contact_name]
|
||||
|
||||
|
||||
async def test_contact_connected_promotes_pending(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"Alex",
|
||||
{"supportBotLight": {"roster": "pending", "since": "2026-08-13"}},
|
||||
connected=True,
|
||||
)
|
||||
)
|
||||
await handlers.contact_ready(ctx, 7)
|
||||
assert api.custom_data[-1][1]["supportBotLight"] == {
|
||||
"roster": "active",
|
||||
"since": "2026-08-13", # original ask time preserved
|
||||
}
|
||||
assert api.sent == [(["group", ROSTER_GROUP_ID], "Now on the roster: Alex")]
|
||||
|
||||
|
||||
async def test_contact_connected_ignores_unmarked_contact(ctx, api):
|
||||
api.contacts.append(make_contact(7, "stranger"))
|
||||
await handlers.contact_ready(ctx, 7)
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_contact_connected_ignores_already_active(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
|
||||
)
|
||||
await handlers.contact_ready(ctx, 7)
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_contact_connected_does_not_promote_an_unusable_connection(ctx, api):
|
||||
# The event says the connection is up; the contact record says otherwise.
|
||||
# active() consults the record, so promoting here would list somebody the
|
||||
# bot cannot reach.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"Alex",
|
||||
{"supportBotLight": {"roster": "pending", "since": "x"}},
|
||||
conn_status="deleted",
|
||||
)
|
||||
)
|
||||
await handlers.contact_ready(ctx, 7)
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_contact_connected_for_unknown_contact_is_a_noop(ctx, api):
|
||||
await handlers.contact_ready(ctx, 999)
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_a_failed_revocation_is_reported_to_the_roster_group(ctx, api):
|
||||
# Revocation is the access-control path; a silent failure would leave the
|
||||
# operator reading /list as the truth.
|
||||
api.contacts.append(
|
||||
make_contact(7, "Alex", {"supportBotLight": {"roster": "active", "since": "x"}})
|
||||
)
|
||||
api.fail_on.add("api_set_contact_custom_data")
|
||||
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="Alex"))
|
||||
assert api.sent[-1][1] == messages.REVOKE_FAILED.format(name="Alex")
|
||||
|
||||
|
||||
async def test_list_renders_both_states(ctx, api):
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1,
|
||||
"sh",
|
||||
{"supportBotLight": {"roster": "active", "since": "2026-08-13"}},
|
||||
connected=True,
|
||||
),
|
||||
make_contact(2, "Alex", {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}),
|
||||
]
|
||||
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
|
||||
assert "On the roster (1):" in api.replies[0]
|
||||
assert "Contact request not accepted (1):" in api.replies[0]
|
||||
|
||||
|
||||
async def test_list_when_empty(ctx, api):
|
||||
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
|
||||
assert api.replies == [messages.ROSTER_EMPTY]
|
||||
|
||||
|
||||
async def test_list_replies_command_failed_when_api_fails(ctx, api):
|
||||
api.fail_on.add("api_list_contacts")
|
||||
await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list"))
|
||||
assert api.replies == [messages.COMMAND_FAILED]
|
||||
|
||||
|
||||
async def test_leave_clears_the_mark(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
|
||||
await handlers.leave(ctx, msg)
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
assert api.replies == [messages.LEFT]
|
||||
|
||||
|
||||
async def test_leave_when_not_on_roster(ctx, api):
|
||||
api.contacts.append(make_contact(7, "sh"))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
|
||||
await handlers.leave(ctx, msg)
|
||||
assert api.custom_data == []
|
||||
assert api.replies == [messages.NOT_ON_ROSTER]
|
||||
|
||||
|
||||
async def test_leave_without_any_contact(ctx, api):
|
||||
msg = make_group_message(api, make_member(1, name="stranger"), "/leave")
|
||||
await handlers.leave(ctx, msg)
|
||||
assert api.replies == [messages.NOT_ON_ROSTER]
|
||||
assert api.created_member_contacts == [] # /leave never creates a contact
|
||||
|
||||
|
||||
async def test_leave_ignores_non_group_message(ctx, api):
|
||||
msg = make_group_message(api, make_member(1, contact_id=7), "/leave")
|
||||
msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"}
|
||||
await handlers.leave(ctx, msg)
|
||||
assert api.replies == [] and api.custom_data == []
|
||||
|
||||
|
||||
async def test_leave_replies_command_failed_when_api_fails(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}})
|
||||
)
|
||||
api.fail_on.add("api_list_contacts")
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave")
|
||||
await handlers.leave(ctx, msg)
|
||||
assert api.replies == [messages.COMMAND_FAILED]
|
||||
|
||||
|
||||
async def test_help_replies_with_help_text(ctx, api):
|
||||
await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help"))
|
||||
assert api.replies == [messages.HELP]
|
||||
|
||||
|
||||
async def test_help_replies_command_failed_when_send_fails(ctx, api):
|
||||
# help_cmd's only action is the reply, so the first send must fail alone.
|
||||
calls = 0
|
||||
original = api.api_send_text_reply
|
||||
|
||||
async def flaky_once(chat_item, text):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise ChatCommandError("boom", {"type": "chatCmdError"})
|
||||
return await original(chat_item, text)
|
||||
|
||||
api.api_send_text_reply = flaky_once
|
||||
await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help"))
|
||||
assert api.replies == [messages.COMMAND_FAILED]
|
||||
|
||||
|
||||
async def test_dm_redrives_a_contact_that_never_connected(ctx, api):
|
||||
# Marked active but never usable: the member contact still exists, so the
|
||||
# invitation can be re-sent.
|
||||
api.contacts.append(
|
||||
make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-01-01"}})
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
assert api.invitations == [(7, messages.INVITATION_TEXT)]
|
||||
assert api.replies == [messages.INVITATION_SENT]
|
||||
|
||||
|
||||
async def test_dm_reports_a_connection_that_is_gone_for_good(ctx, api):
|
||||
# The person deleted the bot after connecting. The core cleared
|
||||
# contactGroupMemberId, so no invitation can be sent and telling them to
|
||||
# retry would be false.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"sh",
|
||||
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
|
||||
grp_member_id=None,
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.invitations == []
|
||||
assert api.replies == [messages.CONNECTION_LOST]
|
||||
|
||||
|
||||
async def test_dm_on_a_dead_contact_with_an_invitation_outstanding_waits(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"sh",
|
||||
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
|
||||
grp_inv_sent=True,
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
assert api.invitations == []
|
||||
assert api.replies == [messages.STILL_PENDING]
|
||||
|
||||
|
||||
async def test_dm_finds_a_contact_created_since_the_message_was_built(ctx, api):
|
||||
# Two commands sent in quick succession both carry the pre-/dm snapshot, in
|
||||
# which memberContactId is still None.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True
|
||||
)
|
||||
)
|
||||
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")]
|
||||
stale = make_member(1, name="sh") # no memberContactId
|
||||
await handlers.dm(ctx, make_group_message(api, stale, "/dm"))
|
||||
assert api.created_member_contacts == []
|
||||
assert api.replies == [messages.STILL_PENDING]
|
||||
|
||||
|
||||
async def test_leave_finds_a_contact_created_since_the_message_was_built(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}})
|
||||
)
|
||||
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")]
|
||||
stale = make_member(1, name="sh")
|
||||
await handlers.leave(ctx, make_group_message(api, stale, "/leave"))
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
assert api.replies == [messages.LEFT]
|
||||
|
||||
|
||||
async def test_member_gone_takes_them_off_the_roster(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh"))
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="sh")
|
||||
|
||||
|
||||
async def test_member_gone_ignores_other_groups(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
await handlers.member_gone(ctx, 999, make_member(1, contact_id=7, name="sh"))
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_member_gone_ignores_someone_not_on_the_roster(ctx, api):
|
||||
api.contacts.append(make_contact(7, "sh", connected=True))
|
||||
await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh"))
|
||||
assert api.custom_data == [] and api.sent == []
|
||||
|
||||
|
||||
async def test_reconcile_promotes_an_acceptance_missed_while_stopped(ctx, api):
|
||||
api.members[ROSTER_GROUP_ID] = [
|
||||
make_member(1, contact_id=1),
|
||||
make_member(2, contact_id=2),
|
||||
make_member(3, contact_id=3),
|
||||
]
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1,
|
||||
"accepted",
|
||||
{"supportBotLight": {"roster": "pending", "since": "2026-01-01"}},
|
||||
connected=True,
|
||||
),
|
||||
make_contact(
|
||||
2, "waiting", {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}}
|
||||
),
|
||||
make_contact(
|
||||
3,
|
||||
"already",
|
||||
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
|
||||
connected=True,
|
||||
),
|
||||
]
|
||||
await handlers.reconcile_roster(ctx)
|
||||
assert api.custom_data == [
|
||||
(1, {"supportBotLight": {"roster": "active", "since": "2026-01-01"}})
|
||||
]
|
||||
assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="accepted")
|
||||
|
||||
|
||||
async def test_contact_ready_failure_does_not_escape(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
api.fail_on.add("api_set_contact_custom_data")
|
||||
await handlers.contact_ready(ctx, 7) # must not raise
|
||||
assert api.sent == []
|
||||
|
||||
|
||||
async def test_reconcile_removes_someone_who_left_while_stopped(ctx, api):
|
||||
# api_list_members keeps the row and only changes its status.
|
||||
api.members[ROSTER_GROUP_ID] = [
|
||||
make_member(1, contact_id=5, name="stays"),
|
||||
make_member(2, contact_id=7, name="gone", status="left"),
|
||||
]
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
5, "stays", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(
|
||||
7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
]
|
||||
await handlers.reconcile_roster(ctx)
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="gone")
|
||||
|
||||
|
||||
async def test_reconcile_failure_does_not_stop_startup(ctx, api):
|
||||
api.fail_on.add("api_list_members")
|
||||
await handlers.reconcile_roster(ctx) # must not raise
|
||||
|
||||
|
||||
async def test_reconcile_continues_past_a_failing_contact(ctx, api):
|
||||
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=5, name="stays")]
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
7, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(
|
||||
8, "b", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
]
|
||||
attempts: list[int] = []
|
||||
|
||||
async def flaky(contact_id, custom_data=None):
|
||||
attempts.append(contact_id)
|
||||
raise ChatCommandError("nope", {"type": "chatCmdError"})
|
||||
|
||||
api.api_set_contact_custom_data = flaky
|
||||
await handlers.reconcile_roster(ctx)
|
||||
assert attempts == [7, 8], "a failure on one contact must not abandon the rest"
|
||||
|
||||
|
||||
async def test_dm_on_a_dead_contact_leaves_the_mark_alone(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"sh",
|
||||
{"supportBotLight": {"roster": "active", "since": "2026-01-01"}},
|
||||
grp_member_id=None,
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.custom_data == []
|
||||
assert api.replies == [messages.CONNECTION_LOST]
|
||||
|
||||
|
||||
async def test_reconcile_skips_revocation_when_the_marker_is_ambiguous(ctx, api):
|
||||
# A second marked group means ensure_roster_group may have picked the wrong
|
||||
# one; deleting every mark on that basis is not recoverable.
|
||||
api.groups.append(
|
||||
make_group(
|
||||
99,
|
||||
{"displayName": "Invite roster", "fullName": ""},
|
||||
custom_data={"supportBotLight": {"group": "roster"}},
|
||||
)
|
||||
)
|
||||
api.members[ROSTER_GROUP_ID] = []
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
await handlers.reconcile_roster(ctx)
|
||||
assert api.custom_data == []
|
||||
|
||||
|
||||
async def test_reconcile_revokes_even_when_the_last_member_leaves(ctx, api):
|
||||
api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="gone", status="left")]
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
await handlers.reconcile_roster(ctx)
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
|
||||
|
||||
async def test_dm_accepts_a_connection_the_member_started(ctx, api):
|
||||
# Tapping "connect directly" on the bot's profile leaves a prepared contact
|
||||
# with no contactGroupMemberId, which looks identical to a dead one.
|
||||
api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="prepared"))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.accepted_member_contacts == [7]
|
||||
assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending"
|
||||
assert api.replies == [messages.ACCEPTING]
|
||||
|
||||
|
||||
async def test_dm_still_reports_a_genuinely_dead_contact(ctx, api):
|
||||
# No prepared connection and no groupDirectInv: nothing to accept.
|
||||
api.contacts.append(make_contact(7, "sh", grp_member_id=None, conn_status="deleted"))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.accepted_member_contacts == []
|
||||
assert api.replies == [messages.CONNECTION_LOST]
|
||||
|
||||
|
||||
async def test_dm_does_not_re_accept_a_connection_already_started(ctx, api):
|
||||
# The core keeps groupDirectInv after acceptance and rejects a second
|
||||
# accept with "connection already started".
|
||||
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")
|
||||
contact["groupDirectInv"] = {
|
||||
"groupDirectInvLink": "x",
|
||||
"groupDirectInvStartedConnection": True,
|
||||
}
|
||||
api.contacts.append(contact)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.accepted_member_contacts == []
|
||||
assert api.replies == [messages.ACCEPTING]
|
||||
|
||||
|
||||
async def test_dm_accepts_an_invitation_not_yet_started(ctx, api):
|
||||
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")
|
||||
contact["groupDirectInv"] = {
|
||||
"groupDirectInvLink": "x",
|
||||
"groupDirectInvStartedConnection": False,
|
||||
}
|
||||
api.contacts.append(contact)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.accepted_member_contacts == [7]
|
||||
|
||||
|
||||
async def test_dm_reports_a_handshake_in_progress_as_connecting(ctx, api):
|
||||
# The core clears contactGroupMemberId when the peer accepts and only later
|
||||
# reports ready. Calling that gone would send the member to advice that
|
||||
# tears the completing connection down.
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7,
|
||||
"Kit",
|
||||
{"supportBotLight": {"roster": "pending", "since": "x"}},
|
||||
grp_member_id=None,
|
||||
conn_status="accepted",
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.replies == [messages.CONNECTING]
|
||||
assert api.accepted_member_contacts == []
|
||||
|
||||
|
||||
async def test_dm_marks_an_unmarked_member_whose_connection_is_completing(ctx, api):
|
||||
# ACCEPTING and CONNECTING both promise a roster place, and contact_ready
|
||||
# delivers it only for a pending mark.
|
||||
api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="accepted"))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert roster.entry_of(api.contacts[0]) is not None
|
||||
|
||||
for c in api.contacts:
|
||||
c["activeConn"] = {"connStatus": {"type": "ready"}}
|
||||
await handlers.contact_ready(ctx, 7)
|
||||
assert [e.name for e in await roster.active(api, USER_ID)] == ["Kit"]
|
||||
|
||||
|
||||
async def test_dm_marks_an_unmarked_member_whose_accept_already_started(ctx, api):
|
||||
contact = make_contact(7, "Kit", grp_member_id=None, conn_status="joined")
|
||||
contact["groupDirectInv"] = {
|
||||
"groupDirectInvLink": "x",
|
||||
"groupDirectInvStartedConnection": True,
|
||||
}
|
||||
api.contacts.append(contact)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.replies == [messages.ACCEPTING]
|
||||
assert roster.entry_of(api.contacts[0]) is not None
|
||||
|
||||
|
||||
async def test_dm_reports_a_dead_connection_even_after_we_accepted(ctx, api):
|
||||
# The core never clears groupDirectInv, so the started flag alone would
|
||||
# promise progress on a connection the peer has since deleted.
|
||||
contact = make_contact(7, "Alice", grp_member_id=None, conn_status="deleted")
|
||||
contact["groupDirectInv"] = {
|
||||
"groupDirectInvLink": "x",
|
||||
"groupDirectInvStartedConnection": True,
|
||||
}
|
||||
api.contacts.append(contact)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="Alice"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.accepted_member_contacts == []
|
||||
assert api.replies == [messages.CONNECTION_LOST]
|
||||
|
||||
|
||||
async def test_dm_fast_path_announces_the_arrival(ctx, api):
|
||||
api.contacts.append(make_contact(7, "sh", connected=True))
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.replies == [messages.ADDED]
|
||||
assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="sh")
|
||||
|
||||
|
||||
async def test_dm_on_an_already_active_member_announces_nothing(ctx, api):
|
||||
api.contacts.append(
|
||||
make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
)
|
||||
)
|
||||
msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm")
|
||||
await handlers.dm(ctx, msg)
|
||||
assert api.sent == []
|
||||
@@ -0,0 +1,236 @@
|
||||
"""The monitoring endpoint: real sockets, no libsimplex."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from simplex_chat.core import ChatAPIError
|
||||
|
||||
from support_bot_light import health
|
||||
from support_bot_light.config import Config, ConfigError, Health
|
||||
from support_bot_light.context import BotContext
|
||||
from tests.conftest import ROSTER_GROUP_ID, USER_ID
|
||||
|
||||
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
||||
|
||||
|
||||
class ProbeApi:
|
||||
"""The one call the probe makes, with the outcomes it has to distinguish."""
|
||||
|
||||
def __init__(self, error: bool = False, delay: float = 0.0):
|
||||
self.error = error
|
||||
self.delay = delay
|
||||
self.calls = 0
|
||||
|
||||
async def api_list_members(self, group_id: int) -> list[dict]:
|
||||
self.calls += 1
|
||||
assert group_id == ROSTER_GROUP_ID
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if self.error:
|
||||
raise ChatAPIError("core is unhappy", {"type": "chatCmdError"})
|
||||
return []
|
||||
|
||||
|
||||
def context(api) -> BotContext:
|
||||
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
||||
|
||||
|
||||
async def request(server: asyncio.Server, line: str) -> str:
|
||||
"""Send one request line to a running endpoint and read the whole reply."""
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||
try:
|
||||
writer.write(f"{line}\r\nHost: localhost\r\n\r\n".encode())
|
||||
await writer.drain()
|
||||
return (await reader.read()).decode("latin-1")
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
async def endpoint(api) -> asyncio.Server:
|
||||
# Port 0: the OS picks a free one, so tests never collide.
|
||||
return await health.serve(context(api), Health(host="127.0.0.1", port=0))
|
||||
|
||||
|
||||
async def test_reports_ok_while_the_core_answers():
|
||||
api = ProbeApi()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, "GET /health HTTP/1.1")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 200 OK")
|
||||
assert reply.endswith('{"status":"ok"}\n')
|
||||
assert api.calls == 1
|
||||
|
||||
|
||||
async def test_reports_unavailable_when_the_core_errors():
|
||||
server = await endpoint(ProbeApi(error=True))
|
||||
try:
|
||||
reply = await request(server, "GET /health HTTP/1.1")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 503 Service Unavailable")
|
||||
|
||||
|
||||
async def test_a_slow_core_times_out_rather_than_hanging(monkeypatch):
|
||||
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
||||
api = ProbeApi(delay=5)
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await asyncio.wait_for(request(server, "GET /health HTTP/1.1"), 2)
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 503")
|
||||
|
||||
|
||||
async def test_a_concurrent_request_does_not_start_a_second_probe(monkeypatch):
|
||||
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.5)
|
||||
api = ProbeApi(delay=0.3)
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
first = asyncio.create_task(request(server, "GET /health HTTP/1.1"))
|
||||
await asyncio.sleep(0.05)
|
||||
second = await request(server, "GET /health HTTP/1.1")
|
||||
assert (await first).startswith("HTTP/1.1 200 OK")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert second.startswith("HTTP/1.1 200 OK") # it waits on the same probe
|
||||
assert api.calls == 1
|
||||
|
||||
|
||||
async def test_polling_a_stalled_core_never_starts_a_second_query(monkeypatch):
|
||||
# Each abandoned query keeps a worker in the loop's default executor, which
|
||||
# the receive loop also uses: a query per poll would take the bot's own
|
||||
# traffic down with the core.
|
||||
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
||||
api = ProbeApi(delay=3)
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
for _ in range(5):
|
||||
reply = await request(server, "GET /health HTTP/1.1")
|
||||
assert reply.startswith("HTTP/1.1 503")
|
||||
assert api.calls == 1 # one query outstanding, not five
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
async def test_the_next_poll_after_recovery_starts_a_fresh_query(monkeypatch):
|
||||
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
||||
api = ProbeApi(delay=0.2)
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 503")
|
||||
await asyncio.sleep(0.3) # the abandoned query completes
|
||||
api.delay = 0 # the core recovers
|
||||
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 200")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert api.calls == 2
|
||||
|
||||
|
||||
async def test_a_core_that_raises_anything_reports_unavailable():
|
||||
# A malformed reply or a missing controller is what this exists to report,
|
||||
# and neither arrives as a chat error.
|
||||
class Broken(ProbeApi):
|
||||
async def api_list_members(self, group_id: int) -> list[dict]:
|
||||
self.calls += 1
|
||||
raise RuntimeError("controller not initialized")
|
||||
|
||||
api = Broken()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, "GET /health HTTP/1.1")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 503")
|
||||
|
||||
|
||||
async def test_an_oversized_request_line_is_answered():
|
||||
api = ProbeApi()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, "GET /" + "x" * (health.MAX_REQUEST_BYTES + 10))
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 400")
|
||||
assert api.calls == 0
|
||||
|
||||
|
||||
async def test_head_is_answered_without_a_body():
|
||||
api = ProbeApi()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, "HEAD /health HTTP/1.1")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 200 OK")
|
||||
assert "{" not in reply
|
||||
assert api.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "status"),
|
||||
[
|
||||
("GET / HTTP/1.1", "404"),
|
||||
("GET /healthz HTTP/1.1", "404"),
|
||||
("POST /health HTTP/1.1", "405"),
|
||||
("nonsense", "404"),
|
||||
],
|
||||
)
|
||||
async def test_only_get_on_the_health_path_is_answered(line, status):
|
||||
api = ProbeApi()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, line)
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith(f"HTTP/1.1 {status}")
|
||||
assert api.calls == 0
|
||||
|
||||
|
||||
async def test_a_query_string_still_matches_the_path():
|
||||
api = ProbeApi()
|
||||
server = await endpoint(api)
|
||||
try:
|
||||
reply = await request(server, "GET /health?from=monitor HTTP/1.1")
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
assert reply.startswith("HTTP/1.1 200 OK")
|
||||
|
||||
|
||||
async def test_a_configured_port_already_in_use_stops_the_bot():
|
||||
api = ProbeApi()
|
||||
taken = await endpoint(api)
|
||||
port = taken.sockets[0].getsockname()[1]
|
||||
try:
|
||||
with pytest.raises(ConfigError, match="cannot listen"):
|
||||
await health.serve(context(api), Health("127.0.0.1", port, configured=True))
|
||||
finally:
|
||||
taken.close()
|
||||
await taken.wait_closed()
|
||||
|
||||
|
||||
async def test_the_default_port_being_in_use_does_not_stop_the_bot():
|
||||
# Nothing asked for port 8080; an unrelated service on it is not a reason to
|
||||
# refuse to answer chats.
|
||||
api = ProbeApi()
|
||||
taken = await endpoint(api)
|
||||
port = taken.sockets[0].getsockname()[1]
|
||||
try:
|
||||
assert await health.serve(context(api), Health("127.0.0.1", port)) is None
|
||||
finally:
|
||||
taken.close()
|
||||
await taken.wait_closed()
|
||||
@@ -0,0 +1,312 @@
|
||||
import pytest
|
||||
from simplex_chat import Bot, BotProfile, SqliteDb
|
||||
from simplex_chat.core import ChatAPIError
|
||||
|
||||
from support_bot_light import handlers, health
|
||||
from support_bot_light.__main__ import (
|
||||
_register,
|
||||
_run,
|
||||
_serve,
|
||||
bot_profile,
|
||||
build_bot,
|
||||
startup_error,
|
||||
)
|
||||
from support_bot_light.config import Config, Health
|
||||
from support_bot_light.context import BotContext
|
||||
from tests.conftest import (
|
||||
ROSTER_GROUP_ID,
|
||||
USER_ID,
|
||||
make_group,
|
||||
make_group_message,
|
||||
make_member,
|
||||
)
|
||||
|
||||
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
||||
OTHER_GROUP_ID = 99
|
||||
|
||||
|
||||
def plain_bot() -> Bot:
|
||||
return Bot(
|
||||
profile=BotProfile(display_name="Support"),
|
||||
db=SqliteDb(file_prefix="./unused"),
|
||||
welcome="hi",
|
||||
)
|
||||
|
||||
|
||||
def registered(api) -> Bot:
|
||||
bot = plain_bot()
|
||||
ctx = BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
||||
_register(bot, ctx)
|
||||
return bot
|
||||
|
||||
|
||||
def test_registers_all_four_commands(api):
|
||||
bot = registered(api)
|
||||
keywords = [names for names, _predicate, _handler in bot._command_handlers]
|
||||
assert keywords == [("dm",), ("list",), ("leave",), ("help",)]
|
||||
|
||||
|
||||
def test_registers_connection_and_business_events(api):
|
||||
bot = registered(api)
|
||||
assert set(bot._event_handlers) == {
|
||||
"acceptingBusinessRequest",
|
||||
"contactConnected",
|
||||
"contactSndReady",
|
||||
"deletedMember",
|
||||
"leftMember",
|
||||
}
|
||||
|
||||
|
||||
def test_commands_match_in_the_roster_group(api):
|
||||
bot = registered(api)
|
||||
msg = make_group_message(api, make_member(1), "/dm", group_id=ROSTER_GROUP_ID)
|
||||
_names, predicate, _handler = bot._command_handlers[0]
|
||||
assert predicate(msg) is True
|
||||
|
||||
|
||||
def test_commands_do_not_match_in_other_groups(api):
|
||||
# A /dm typed inside a business chat must not be acted on.
|
||||
bot = registered(api)
|
||||
msg = make_group_message(api, make_member(1), "/dm", group_id=OTHER_GROUP_ID)
|
||||
_names, predicate, _handler = bot._command_handlers[0]
|
||||
assert predicate(msg) is False
|
||||
|
||||
|
||||
def test_bot_profile_carries_display_name_and_image():
|
||||
profile = bot_profile(
|
||||
Config("Support", "./x", "hi", "R", "owner", image="data:image/png;base64,AAA")
|
||||
)
|
||||
assert profile.display_name == "Support"
|
||||
assert profile.image == "data:image/png;base64,AAA"
|
||||
|
||||
|
||||
def test_bot_profile_without_image():
|
||||
assert bot_profile(CONFIG).image is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index,keyword", [(0, "dm"), (1, "list"), (2, "leave"), (3, "help")])
|
||||
def test_every_command_is_scoped_to_the_roster_group(api, index, keyword):
|
||||
# A /list answered in a business chat would show the roster to a customer.
|
||||
bot = registered(api)
|
||||
names, predicate, _handler = bot._command_handlers[index]
|
||||
assert names == (keyword,)
|
||||
inside = make_group_message(api, make_member(1), f"/{keyword}", group_id=ROSTER_GROUP_ID)
|
||||
outside = make_group_message(api, make_member(1), f"/{keyword}", group_id=OTHER_GROUP_ID)
|
||||
assert predicate(inside) is True
|
||||
assert predicate(outside) is False
|
||||
|
||||
|
||||
async def test_registered_handlers_call_the_matching_handler(api, monkeypatch):
|
||||
# Registration bookkeeping alone would not catch /dm being wired to leave().
|
||||
bot = registered(api)
|
||||
called: list[str] = []
|
||||
|
||||
def spy(name):
|
||||
async def handler(_ctx, _msg):
|
||||
called.append(name)
|
||||
|
||||
return handler
|
||||
|
||||
for name in ("dm", "list_roster", "leave", "help_cmd"):
|
||||
monkeypatch.setattr(handlers, name, spy(name))
|
||||
for keywords, _predicate, handler in bot._command_handlers:
|
||||
await handler(make_group_message(api, make_member(1), f"/{keywords[0]}"), None)
|
||||
assert called == ["dm", "list_roster", "leave", "help_cmd"]
|
||||
|
||||
|
||||
def test_a_taken_display_name_is_explained():
|
||||
# The core reports it as a bare errorStore; the cause is in the store error.
|
||||
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "duplicateName"}})
|
||||
assert "bot.display_name" in startup_error(e)
|
||||
|
||||
|
||||
def test_any_other_chat_error_keeps_its_detail():
|
||||
e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "userNotFound"}})
|
||||
assert "userNotFound" in startup_error(e)
|
||||
|
||||
|
||||
def test_a_rejected_command_is_quoted_as_the_core_wrote_it():
|
||||
# The core puts what the caller did wrong in the message, and the tag says
|
||||
# nothing; printing the raw dict instead would bury it.
|
||||
e = ChatAPIError(
|
||||
"chat command error: error",
|
||||
{"type": "error", "errorType": {"type": "commandError", "message": "Profile image"}},
|
||||
)
|
||||
assert startup_error(e) == "Profile image"
|
||||
|
||||
|
||||
def test_an_error_without_detail_is_rendered_plainly():
|
||||
assert startup_error(ValueError("no active user after start")) == "no active user after start"
|
||||
|
||||
|
||||
def test_the_bot_opens_a_business_address():
|
||||
# Without these two the address yields direct chats that nothing handles:
|
||||
# acceptingBusinessRequest never fires and no roster is ever added.
|
||||
bot = build_bot(CONFIG)
|
||||
assert bot._business_address is True
|
||||
assert bot._auto_accept is True
|
||||
assert bot._welcome == "hi"
|
||||
|
||||
|
||||
def test_the_bot_does_not_apply_its_profile_while_starting():
|
||||
# The name the core will accept is only knowable from the database, which
|
||||
# nothing can read until the client has started. _apply_profile does it.
|
||||
assert build_bot(CONFIG)._update_profile is False
|
||||
|
||||
|
||||
class FakeBot:
|
||||
"""A Bot stand-in for _serve: an async context manager with an api."""
|
||||
|
||||
def __init__(self, api, sync_error: Exception | None = None):
|
||||
self.api = api
|
||||
self.profile = BotProfile(display_name="Support")
|
||||
self.served = 0
|
||||
self.syncs = 0
|
||||
self.sync_error = sync_error
|
||||
self.signal_handlers = 0
|
||||
self._command_handlers = []
|
||||
self._event_handlers = {}
|
||||
self.stop_requested = False
|
||||
self.stopped = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc):
|
||||
return False
|
||||
|
||||
def install_signal_handlers(self):
|
||||
self.signal_handlers += 1
|
||||
|
||||
async def sync_profile(self) -> bool:
|
||||
self.syncs += 1
|
||||
if self.sync_error is not None:
|
||||
raise self.sync_error
|
||||
return True
|
||||
|
||||
def on_command(self, *_names, **_kw):
|
||||
def register(handler):
|
||||
self._command_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
return register
|
||||
|
||||
def on_event(self, tag):
|
||||
def register(handler):
|
||||
self._event_handlers.setdefault(tag, []).append(handler)
|
||||
return handler
|
||||
|
||||
return register
|
||||
|
||||
async def serve_forever(self):
|
||||
self.served += 1
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def serve_api(api):
|
||||
"""The fake api with the calls _serve makes before serving."""
|
||||
|
||||
async def api_get_active_user():
|
||||
return {"userId": USER_ID, "localDisplayName": "Support"}
|
||||
|
||||
api.api_get_active_user = api_get_active_user
|
||||
api.group_links[ROSTER_GROUP_ID] = "https://example.invalid/g#x"
|
||||
api.groups.append(
|
||||
make_group(
|
||||
ROSTER_GROUP_ID,
|
||||
{"displayName": "Invite roster", "fullName": ""},
|
||||
{"supportBotLight": {"group": "roster"}},
|
||||
)
|
||||
)
|
||||
return api
|
||||
|
||||
|
||||
async def test_serve_wires_the_handlers_and_serves(api):
|
||||
bot = FakeBot(serve_api(api))
|
||||
await _serve(CONFIG, bot)
|
||||
assert bot.served == 1
|
||||
assert len(bot._command_handlers) == 4 # nothing is delivered without these
|
||||
assert set(bot._event_handlers) == {
|
||||
"acceptingBusinessRequest",
|
||||
"contactConnected",
|
||||
"contactSndReady",
|
||||
"deletedMember",
|
||||
"leftMember",
|
||||
}
|
||||
|
||||
|
||||
async def test_serve_reads_the_group_listing_once(api):
|
||||
# It is the largest thing startup marshals and grows with every customer.
|
||||
bot = FakeBot(serve_api(api))
|
||||
calls = {"n": 0}
|
||||
original = api.api_list_groups
|
||||
|
||||
async def counted(user_id, **kw):
|
||||
calls["n"] += 1
|
||||
return await original(user_id, **kw)
|
||||
|
||||
api.api_list_groups = counted
|
||||
await _serve(CONFIG, bot)
|
||||
assert calls["n"] == 2 # one for discovery, one shared by both passes
|
||||
|
||||
|
||||
async def test_serve_does_not_begin_serving_after_a_signal(api):
|
||||
bot = FakeBot(serve_api(api))
|
||||
bot.stop_requested = True
|
||||
await _serve(CONFIG, bot)
|
||||
assert bot.served == 0
|
||||
|
||||
|
||||
async def test_serve_closes_the_health_endpoint_afterwards(api):
|
||||
config = Config("Support", "./x", "hi", "Invite roster", "owner", health=Health("127.0.0.1", 0))
|
||||
bot = FakeBot(serve_api(api))
|
||||
servers: list = []
|
||||
original = health.serve
|
||||
|
||||
async def spy(ctx, cfg):
|
||||
server = await original(ctx, cfg)
|
||||
servers.append(server)
|
||||
return server
|
||||
|
||||
health.serve = spy
|
||||
try:
|
||||
await _serve(config, bot)
|
||||
finally:
|
||||
health.serve = original
|
||||
assert servers and not servers[0].is_serving()
|
||||
|
||||
|
||||
async def test_the_bot_serves_after_a_refused_rename(api, caplog):
|
||||
# The core keeps display names unique; a refused one is not a reason to
|
||||
# leave customers unanswered.
|
||||
refused = ChatAPIError("x", {"storeError": {"type": "duplicateName"}})
|
||||
bot = FakeBot(serve_api(api), sync_error=refused)
|
||||
await _serve(CONFIG, bot)
|
||||
assert bot.served == 1
|
||||
assert "bot.display_name" in caplog.text
|
||||
|
||||
|
||||
async def test_the_profile_is_applied_after_start(api, monkeypatch):
|
||||
bot = FakeBot(serve_api(api))
|
||||
await _serve(CONFIG, bot)
|
||||
assert bot.syncs == 1
|
||||
|
||||
|
||||
async def test_run_installs_signal_handlers_before_starting(monkeypatch):
|
||||
# Startup runs migrations and address creation; a signal there would
|
||||
# otherwise kill the process mid-write.
|
||||
order: list[str] = []
|
||||
bot = FakeBot(None)
|
||||
|
||||
def build(_config):
|
||||
return bot
|
||||
|
||||
async def serve(_config, b):
|
||||
order.append(f"serve:{b.signal_handlers}")
|
||||
|
||||
monkeypatch.setattr("support_bot_light.__main__.build_bot", build)
|
||||
monkeypatch.setattr("support_bot_light.__main__._serve", serve)
|
||||
await _run(CONFIG)
|
||||
assert order == ["serve:1"]
|
||||
@@ -0,0 +1,85 @@
|
||||
from support_bot_light import messages
|
||||
from support_bot_light.roster import RosterEntry
|
||||
|
||||
|
||||
def entry(name, state, since="2026-08-13T09:00:00+00:00", reachable=True):
|
||||
return RosterEntry(contact_id=1, name=name, state=state, since=since, reachable=reachable)
|
||||
|
||||
|
||||
def test_render_roster_lists_active_and_pending():
|
||||
out = messages.render_roster([entry("sh", "active"), entry("Alex", "pending")])
|
||||
assert "On the roster (1):" in out
|
||||
assert "• sh — since 2026-08-13" in out
|
||||
assert "Contact request not accepted (1):" in out
|
||||
assert "• Alex — asked 2026-08-13" in out
|
||||
|
||||
|
||||
def test_render_roster_empty():
|
||||
assert messages.render_roster([]) == messages.ROSTER_EMPTY
|
||||
|
||||
|
||||
def test_render_roster_omits_pending_section_when_none():
|
||||
out = messages.render_roster([entry("sh", "active")])
|
||||
assert "Waiting" not in out
|
||||
|
||||
|
||||
def test_render_roster_omits_date_suffix_when_since_is_empty():
|
||||
out = messages.render_roster([entry("sh", "active", since="")])
|
||||
assert out == "On the roster (1):\n • sh"
|
||||
assert "since" not in out
|
||||
|
||||
|
||||
def test_render_roster_formats_date_suffix():
|
||||
out = messages.render_roster([entry("sh", "active")])
|
||||
assert out == "On the roster (1):\n • sh — since 2026-08-13"
|
||||
|
||||
|
||||
def test_invite_log_lists_added_names():
|
||||
assert messages.invite_log("Alex", ["sh", "Narasimha"], []) == (
|
||||
"Connected: Alex → added sh, Narasimha"
|
||||
)
|
||||
|
||||
|
||||
def test_invite_log_reports_failures():
|
||||
line = messages.invite_log("Alex", ["sh"], ["Narasimha"])
|
||||
assert line == "Connected: Alex → added sh (failed: Narasimha)"
|
||||
|
||||
|
||||
def test_help_mentions_every_command():
|
||||
for keyword in ("dm", "list", "leave"):
|
||||
assert f"/{keyword}" in messages.HELP
|
||||
|
||||
|
||||
def test_render_roster_separates_unreachable_members():
|
||||
out = messages.render_roster(
|
||||
[entry("live", "active"), entry("dead", "active", reachable=False)]
|
||||
)
|
||||
assert "On the roster (1):" in out
|
||||
assert "Not reachable, not being added (1):" in out
|
||||
assert "• dead" in out
|
||||
|
||||
|
||||
def test_render_roster_caps_long_sections():
|
||||
entries = [entry(f"n{i}", "active") for i in range(messages.MAX_LISTED + 12)]
|
||||
out = messages.render_roster(entries)
|
||||
assert f"On the roster ({messages.MAX_LISTED + 12}):" in out
|
||||
assert "… and 12 more" in out
|
||||
assert out.count("•") == messages.MAX_LISTED
|
||||
assert len(out.encode()) < 15000
|
||||
|
||||
|
||||
def test_render_roster_bounds_the_whole_reply_in_bytes():
|
||||
# Names are capped in characters, so CJK can overrun a byte limit even with
|
||||
# every section capped.
|
||||
entries = [entry("漢" * 50, "active") for _ in range(messages.MAX_LISTED)]
|
||||
entries += [entry("漢" * 50, "pending") for _ in range(messages.MAX_LISTED)]
|
||||
out = messages.render_roster(entries)
|
||||
assert len(out.encode()) <= messages.MAX_REPLY_BYTES
|
||||
assert out.endswith(messages.TRUNCATED)
|
||||
|
||||
|
||||
def test_invite_log_is_bounded_in_bytes():
|
||||
names = ["漢" * 50 for _ in range(100)]
|
||||
out = messages.invite_log("Alex", names, [])
|
||||
assert len(out.encode()) <= messages.MAX_REPLY_BYTES
|
||||
assert out.endswith(messages.TRUNCATED)
|
||||
@@ -0,0 +1,154 @@
|
||||
from support_bot_light import roster
|
||||
from tests.conftest import USER_ID, make_contact
|
||||
|
||||
|
||||
def test_entry_of_reads_active_mark():
|
||||
contact = make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-08-13"}}
|
||||
)
|
||||
entry = roster.entry_of(contact)
|
||||
assert entry is not None
|
||||
assert (entry.contact_id, entry.name, entry.state, entry.since) == (
|
||||
7,
|
||||
"sh",
|
||||
"active",
|
||||
"2026-08-13",
|
||||
)
|
||||
|
||||
|
||||
def test_entry_of_returns_none_without_custom_data():
|
||||
assert roster.entry_of(make_contact(7, "sh")) is None
|
||||
|
||||
|
||||
def test_entry_of_ignores_other_namespaces():
|
||||
assert roster.entry_of(make_contact(7, "sh", {"otherBot": {"roster": "active"}})) is None
|
||||
|
||||
|
||||
def test_entry_of_ignores_unknown_state():
|
||||
contact = make_contact(7, "sh", {"supportBotLight": {"roster": "banned"}})
|
||||
assert roster.entry_of(contact) is None
|
||||
|
||||
|
||||
def test_entry_of_ignores_non_dict_mark():
|
||||
assert roster.entry_of(make_contact(7, "sh", {"supportBotLight": "oops"})) is None
|
||||
|
||||
|
||||
async def test_mark_preserves_other_keys(api):
|
||||
contact = make_contact(7, "sh", {"otherBot": {"keep": 1}})
|
||||
api.contacts.append(contact)
|
||||
await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00")
|
||||
contact_id, data = api.custom_data[-1]
|
||||
assert contact_id == 7
|
||||
assert data["otherBot"] == {"keep": 1}
|
||||
assert data["supportBotLight"] == {"roster": "active", "since": "2026-08-13T09:00:00+00:00"}
|
||||
|
||||
|
||||
async def test_mark_does_not_mutate_the_callers_contact(api):
|
||||
original = {"otherBot": {"keep": 1}}
|
||||
contact = make_contact(7, "sh", original)
|
||||
api.contacts.append(contact)
|
||||
await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00")
|
||||
# mark() builds a new blob; the caller's dict must be untouched.
|
||||
assert original == {"otherBot": {"keep": 1}}
|
||||
assert "supportBotLight" not in original
|
||||
|
||||
|
||||
async def test_unmark_removes_only_our_key(api):
|
||||
contact = make_contact(
|
||||
7, "sh", {"supportBotLight": {"roster": "active"}, "otherBot": {"keep": 1}}
|
||||
)
|
||||
api.contacts.append(contact)
|
||||
await roster.unmark(api, contact)
|
||||
assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}})
|
||||
|
||||
|
||||
async def test_unmark_clears_blob_when_nothing_left(api):
|
||||
contact = make_contact(7, "sh", {"supportBotLight": {"roster": "active"}})
|
||||
api.contacts.append(contact)
|
||||
await roster.unmark(api, contact)
|
||||
# None clears the column rather than writing an empty object.
|
||||
assert api.custom_data[-1] == (7, None)
|
||||
|
||||
|
||||
async def test_unmark_of_an_unmarked_contact_takes_nothing_away(api):
|
||||
contact = make_contact(7, "sh", {"otherBot": {"keep": 1}})
|
||||
api.contacts.append(contact)
|
||||
await roster.unmark(api, contact)
|
||||
assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}})
|
||||
|
||||
|
||||
async def test_load_returns_marked_contacts_sorted_case_insensitively(api):
|
||||
api.contacts += [
|
||||
make_contact(1, "Zoe", {"supportBotLight": {"roster": "active", "since": "x"}}),
|
||||
make_contact(2, "bob", {"supportBotLight": {"roster": "pending", "since": "x"}}),
|
||||
make_contact(3, "unmarked"),
|
||||
]
|
||||
entries = await roster.load(api, USER_ID)
|
||||
assert [e.name for e in entries] == ["bob", "Zoe"]
|
||||
|
||||
|
||||
async def test_active_filters_pending(api):
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(2, "b", {"supportBotLight": {"roster": "pending", "since": "x"}}),
|
||||
]
|
||||
assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1]
|
||||
|
||||
|
||||
async def test_find_contact_returns_none_when_absent(api):
|
||||
assert await roster.find_contact(api, USER_ID, 99) is None
|
||||
|
||||
|
||||
async def test_find_contact_returns_match(api):
|
||||
api.contacts.append(make_contact(7, "sh"))
|
||||
found = await roster.find_contact(api, USER_ID, 7)
|
||||
assert found is not None and found["contactId"] == 7
|
||||
|
||||
|
||||
def test_utc_now_is_iso_with_offset():
|
||||
now = roster.utc_now()
|
||||
assert now.endswith("+00:00") and "T" in now
|
||||
|
||||
|
||||
async def test_active_excludes_a_marked_contact_that_is_no_longer_usable(api):
|
||||
# The person deleted the bot: the mark survives but api_add_member would
|
||||
# fail for them on every business chat.
|
||||
api.contacts += [
|
||||
make_contact(
|
||||
1, "live", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True
|
||||
),
|
||||
make_contact(2, "dead", {"supportBotLight": {"roster": "active", "since": "x"}}),
|
||||
]
|
||||
assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1]
|
||||
|
||||
|
||||
def test_entry_name_is_sanitised():
|
||||
contact = make_contact(7, "a\nb", {"supportBotLight": {"roster": "active", "since": "x"}})
|
||||
entry = roster.entry_of(contact)
|
||||
assert entry is not None
|
||||
assert entry.name == "a b"
|
||||
|
||||
|
||||
def test_entry_of_survives_a_contact_with_no_profile():
|
||||
entry = roster.entry_of(
|
||||
{"contactId": 7, "customData": {"supportBotLight": {"roster": "active", "since": "x"}}}
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry.name == "(unnamed)"
|
||||
|
||||
|
||||
def test_contact_name_prefers_the_local_display_name():
|
||||
# The core makes localDisplayName unique per user; two peers calling
|
||||
# themselves "sh" render as "sh" and "sh_1", which is what the roster and
|
||||
# the log must show.
|
||||
contact = make_contact(1, "sh_1")
|
||||
contact["profile"]["displayName"] = "sh"
|
||||
assert roster.contact_name(contact) == "sh_1"
|
||||
|
||||
|
||||
def test_contact_name_falls_back_to_the_profile():
|
||||
contact = make_contact(1, "sh")
|
||||
del contact["localDisplayName"]
|
||||
assert roster.contact_name(contact) == "sh"
|
||||
@@ -0,0 +1,224 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from support_bot_light import commands, setup
|
||||
from support_bot_light.config import Config
|
||||
from tests.conftest import ROSTER_GROUP_ID, USER_ID, make_group
|
||||
|
||||
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
||||
MARKER = {"supportBotLight": {"group": "roster"}}
|
||||
|
||||
|
||||
async def test_creates_group_when_none_marked(api, caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG)
|
||||
assert group_id == ROSTER_GROUP_ID
|
||||
profile = api.new_groups[0]
|
||||
assert profile["displayName"] == "Invite roster"
|
||||
assert profile["groupPreferences"]["directMessages"] == {"enable": "on"}
|
||||
assert profile["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS)
|
||||
assert api.group_custom_data == [(ROSTER_GROUP_ID, MARKER)]
|
||||
assert api.links == [ROSTER_GROUP_ID] # created exactly once
|
||||
assert api.group_links[ROSTER_GROUP_ID] in caplog.text
|
||||
|
||||
|
||||
async def test_finds_existing_group_by_marker(api):
|
||||
profile = {
|
||||
"displayName": "renamed by a human",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
|
||||
assert api.new_groups == [] # not recreated
|
||||
assert api.profile_updates == [] # commands already match — no broadcast
|
||||
|
||||
|
||||
async def test_found_group_with_link_logs_it_without_recreating(api, caplog):
|
||||
caplog.set_level(logging.INFO)
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
|
||||
assert api.links == [] # fetched, not recreated
|
||||
assert "https://simplex.chat/contact#/?v=2&group=77" in caplog.text
|
||||
|
||||
|
||||
async def test_found_group_without_link_recreates_and_logs_it(api, caplog):
|
||||
# Crash window between marking the group and creating its link.
|
||||
caplog.set_level(logging.INFO)
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
|
||||
assert api.links == [77] # recovered by creating a new link
|
||||
assert api.group_links[77] in caplog.text
|
||||
|
||||
|
||||
async def test_group_link_get_failure_with_link_present_does_not_block_startup(api, caplog):
|
||||
# The create fallback hits the unique link index. A missing link must not
|
||||
# stop the bot starting.
|
||||
caplog.set_level(logging.WARNING)
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77"
|
||||
api.fail_on.add("api_get_group_link_str")
|
||||
api.fail_on.add("api_create_group_link")
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77
|
||||
|
||||
|
||||
async def test_pushes_commands_when_they_differ(api):
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {"directMessages": {"enable": "on"}, "commands": []},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
await setup.ensure_roster_group(api, USER_ID, CONFIG)
|
||||
assert len(api.profile_updates) == 1
|
||||
group_id, sent = api.profile_updates[0]
|
||||
assert group_id == 77
|
||||
assert sent["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS)
|
||||
|
||||
|
||||
async def test_pushed_profile_keeps_existing_display_name(api):
|
||||
profile = {
|
||||
"displayName": "renamed by a human",
|
||||
"fullName": "",
|
||||
"groupPreferences": {"directMessages": {"enable": "on"}, "commands": []},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, custom_data=MARKER))
|
||||
await setup.ensure_roster_group(api, USER_ID, CONFIG)
|
||||
_, sent = api.profile_updates[0]
|
||||
# Syncing commands must not silently rename a group the operator renamed.
|
||||
assert sent["displayName"] == "renamed by a human"
|
||||
|
||||
|
||||
async def test_ignores_groups_without_the_marker(api):
|
||||
api.groups.append(make_group(88, {"displayName": "Invite roster", "fullName": ""}))
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
|
||||
assert api.new_groups != [] # name match alone must not be trusted
|
||||
|
||||
|
||||
async def test_ignores_groups_with_a_foreign_marker(api):
|
||||
api.groups.append(
|
||||
make_group(
|
||||
88, {"displayName": "x", "fullName": ""}, custom_data={"otherBot": {"group": "roster"}}
|
||||
)
|
||||
)
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
|
||||
assert api.new_groups != []
|
||||
|
||||
|
||||
async def test_ignores_groups_with_the_wrong_marker_value(api):
|
||||
# Right namespace, wrong marker.
|
||||
api.groups.append(
|
||||
make_group(
|
||||
88,
|
||||
{"displayName": "x", "fullName": ""},
|
||||
custom_data={"supportBotLight": {"group": "archive"}},
|
||||
)
|
||||
)
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
|
||||
assert api.new_groups != []
|
||||
|
||||
|
||||
async def test_ignores_groups_with_a_non_dict_marker(api):
|
||||
api.groups.append(
|
||||
make_group(
|
||||
88, {"displayName": "x", "fullName": ""}, custom_data={"supportBotLight": "roster"}
|
||||
)
|
||||
)
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID
|
||||
|
||||
|
||||
async def test_warns_and_picks_one_when_two_groups_are_marked(api, caplog):
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "on"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups += [
|
||||
make_group(20, profile, custom_data=MARKER),
|
||||
make_group(21, profile, custom_data=MARKER),
|
||||
]
|
||||
api.group_links[20] = "https://simplex.chat/#g20"
|
||||
with caplog.at_level("WARNING"):
|
||||
assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 20
|
||||
assert "2 groups carry the roster marker" in caplog.text
|
||||
assert api.new_groups == []
|
||||
|
||||
|
||||
async def test_a_group_the_bot_has_left_is_not_reused(api):
|
||||
# Nothing would ever be delivered there, and the marker would keep a
|
||||
# replacement from being created.
|
||||
api.groups.append(
|
||||
make_group(
|
||||
77,
|
||||
{"displayName": "old roster", "fullName": ""},
|
||||
{"supportBotLight": {"group": "roster"}},
|
||||
membership_status="removed",
|
||||
)
|
||||
)
|
||||
group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG)
|
||||
assert group_id != 77
|
||||
assert api.new_groups # a live roster group was created instead
|
||||
|
||||
|
||||
async def test_direct_messages_is_restored_when_an_owner_switches_it_off(api):
|
||||
# api_create_member_contact fails without it, so /dm would fail forever with
|
||||
# nothing to explain it.
|
||||
profile = {
|
||||
"displayName": "Invite roster",
|
||||
"fullName": "",
|
||||
"groupPreferences": {
|
||||
"directMessages": {"enable": "off"},
|
||||
"commands": commands.to_wire(commands.COMMANDS),
|
||||
},
|
||||
}
|
||||
api.groups.append(make_group(77, profile, {"supportBotLight": {"group": "roster"}}))
|
||||
await setup.ensure_roster_group(api, USER_ID, CONFIG)
|
||||
pushed = api.profile_updates[-1][1]["groupPreferences"]
|
||||
assert pushed["directMessages"] == {"enable": "on"}
|
||||
assert pushed["commands"] == commands.to_wire(commands.COMMANDS)
|
||||
|
||||
|
||||
async def test_a_profile_push_that_cannot_return_does_not_hang_startup(api, monkeypatch):
|
||||
# The core's view queue is bounded and nothing drains it until the bot
|
||||
# serves, so this write can block until it does.
|
||||
monkeypatch.setattr(setup, "PROFILE_PUSH_TIMEOUT", 0.05)
|
||||
|
||||
async def never_returns(group_id, profile):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
monkeypatch.setattr(api, "api_update_group_profile", never_returns)
|
||||
api.groups.append(make_group(77, {"displayName": "r", "fullName": ""}, MARKER))
|
||||
group_id = await asyncio.wait_for(setup.ensure_roster_group(api, USER_ID, CONFIG), 2)
|
||||
assert group_id == 77
|
||||
@@ -0,0 +1,50 @@
|
||||
from support_bot_light.text import MAX_NAME, UNNAMED, safe_name
|
||||
|
||||
|
||||
def test_collapses_newlines_so_a_name_cannot_forge_a_line():
|
||||
assert safe_name("AAA\n • ceo@example.com — since 2020-01-01") == (
|
||||
"AAA • ceo@example.com — since 2020-01-01"
|
||||
)
|
||||
assert "\n" not in safe_name("a\r\nb\tc")
|
||||
|
||||
|
||||
def test_truncates_a_long_name():
|
||||
out = safe_name("X" * 14000)
|
||||
assert len(out) == MAX_NAME
|
||||
assert out.endswith("…")
|
||||
|
||||
|
||||
def test_strips_non_printable_characters():
|
||||
assert safe_name("bob\x00\x07") == "bob"
|
||||
|
||||
|
||||
def test_blank_and_whitespace_only_names():
|
||||
assert safe_name("") == UNNAMED
|
||||
assert safe_name(" \n ") == UNNAMED
|
||||
|
||||
|
||||
def test_leaves_an_ordinary_name_alone():
|
||||
assert safe_name("Narasimha") == "Narasimha"
|
||||
|
||||
|
||||
def test_strips_invisible_but_printable_characters():
|
||||
# Hangul fillers and Braille blanks are Lo/So, so isprintable() lets them
|
||||
# through while they render as nothing.
|
||||
assert safe_name("\u3164\u3164Alice") == "Alice"
|
||||
assert safe_name("\u115f\u1160Alice") == "Alice"
|
||||
assert safe_name("\u2800Alice") == "Alice"
|
||||
assert safe_name("\u3164" * 10) == UNNAMED
|
||||
|
||||
|
||||
def test_normalises_compatibility_forms():
|
||||
assert safe_name("\uff21lice") == "Alice"
|
||||
|
||||
|
||||
def test_leaves_names_in_other_scripts_alone():
|
||||
for name in (
|
||||
"\uae40\ucca0\uc218",
|
||||
"Nguy\u1ec5n",
|
||||
"\u0645\u062d\u0645\u062f",
|
||||
"Jos\u00e9 M\u00fcller",
|
||||
):
|
||||
assert safe_name(name) == name
|
||||
Reference in New Issue
Block a user