Files
simplex-chat/apps/simplex-support-bot-light/src/support_bot_light/messages.py
T
sh 68916aa90a 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
2026-08-17 09:12:47 +01:00

114 lines
4.6 KiB
Python

"""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)