mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 06:59:42 +00:00
* 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
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
"""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
|