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