mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 00:21:52 +00:00
223 lines
7.7 KiB
Python
223 lines
7.7 KiB
Python
"""`Bot` — Client extended with server-side features (address, auto-accept, commands)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from . import util
|
|
from .api import Db
|
|
from .client import (
|
|
BotProfile,
|
|
ChatMessage,
|
|
Client,
|
|
CommandHandler,
|
|
EventHandler,
|
|
FileMessage,
|
|
ImageMessage,
|
|
LinkMessage,
|
|
Message,
|
|
MessageHandler,
|
|
Middleware,
|
|
ParsedCommand,
|
|
Profile,
|
|
ReportMessage,
|
|
TextMessage,
|
|
UnknownMessage,
|
|
VideoMessage,
|
|
VoiceMessage,
|
|
log,
|
|
)
|
|
from .core import MigrationConfirmation
|
|
from .types import T
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class BotCommand:
|
|
"""One entry in the bot's advertised slash-command list (wire-side
|
|
`groupPreferences.commands` or profile `preferences.commands`).
|
|
|
|
`keyword` and `label` are required: `keyword` is what the user
|
|
types after `/`; `label` is the human-readable description shown
|
|
next to the keyword in the SimpleX client's commands menu.
|
|
|
|
`params` is an optional placeholder string that controls how the
|
|
client behaves when the user taps the command in the menu:
|
|
|
|
* `params=None` (default) — the client SENDS `/<keyword>`
|
|
immediately on tap; no input-box detour. Use this for
|
|
zero-argument commands (`/help`, `/ping`) where the action is
|
|
unambiguous.
|
|
|
|
* `params="<value>"` — the client PASTES `/<keyword> <params>`
|
|
into the input box and positions the cursor at the end. The
|
|
user edits the placeholder and sends. Use this for commands
|
|
that take a required argument (`/review <pr-url>`,
|
|
`/order <number>`) so the user sees the expected shape
|
|
without having to remember it.
|
|
|
|
Mirrors `CBCCommand` in the Haskell core
|
|
(`Simplex.Chat.Types.Preferences`) and the wire TypedDict
|
|
`ChatBotCommand_command`. Both SimpleX clients (Android/Kotlin
|
|
and iOS/Swift) implement the paste-vs-send branch on the
|
|
`params` field; see `CommandsMenuView.{kt,swift}` for the
|
|
reference UI behaviour.
|
|
"""
|
|
keyword: str
|
|
label: str
|
|
params: str | None = None
|
|
|
|
|
|
class Bot(Client):
|
|
"""SimpleX bot — Client extended with server-side features.
|
|
|
|
On top of `Client` (identity + messaging + connect_to/send_and_wait/events),
|
|
a Bot:
|
|
- creates and announces its own contact address
|
|
- auto-accepts incoming contact requests (configurable)
|
|
- advertises a list of slash-commands in its profile preferences
|
|
- sets `peerType=bot` and disables calls/voice in profile prefs
|
|
- sends a `welcome` message to new contacts via the auto-reply address setting
|
|
|
|
If you want just identity + messaging without any of that, use `Client`
|
|
directly.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
profile: Profile,
|
|
db: Db,
|
|
welcome: str | T.MsgContent | None = None,
|
|
commands: list[BotCommand] | None = None,
|
|
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
|
|
create_address: bool = True,
|
|
update_address: bool = True,
|
|
update_profile: bool = True,
|
|
auto_accept: bool = True,
|
|
business_address: bool = False,
|
|
allow_files: bool = False,
|
|
log_contacts: bool = True,
|
|
log_network: bool = False,
|
|
) -> None:
|
|
super().__init__(
|
|
profile=profile,
|
|
db=db,
|
|
confirm_migrations=confirm_migrations,
|
|
update_profile=update_profile,
|
|
log_contacts=log_contacts,
|
|
log_network=log_network,
|
|
)
|
|
self._welcome = welcome
|
|
self._commands = commands or []
|
|
self._create_address = create_address
|
|
self._update_address = update_address
|
|
self._auto_accept = auto_accept
|
|
self._business_address = business_address
|
|
self._allow_files = allow_files
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Profile + address sync (overrides hooks in Client)
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _post_start(self, user: T.User) -> None:
|
|
"""Bots sync address first, then embed the link in the profile."""
|
|
link = await self._sync_address(user)
|
|
await self._maybe_sync_profile(user, contact_link=link)
|
|
|
|
async def _sync_address(self, user: T.User) -> str | None:
|
|
"""Address sync. Returns the public link if any, for embedding in the profile."""
|
|
api = self.api
|
|
user_id = user["userId"]
|
|
|
|
address = await api.api_get_user_address(user_id)
|
|
if address is None:
|
|
if self._create_address:
|
|
log.info("Bot has no address, creating...")
|
|
await api.api_create_user_address(user_id)
|
|
address = await api.api_get_user_address(user_id)
|
|
if address is None:
|
|
raise RuntimeError("Failed reading newly created user address")
|
|
else:
|
|
log.warning("Bot has no address")
|
|
|
|
link: str | None = None
|
|
if address is not None:
|
|
link = util.contact_address_str(address["connLinkContact"])
|
|
log.info("Bot address: %s", link)
|
|
|
|
# Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194.
|
|
# autoAccept present → accept; absent → no auto-accept (mirrors Node bot.ts).
|
|
if address is not None and self._update_address:
|
|
desired: T.AddressSettings = {"businessAddress": self._business_address}
|
|
if self._auto_accept:
|
|
desired["autoAccept"] = {"acceptIncognito": False}
|
|
if self._welcome is not None:
|
|
desired["autoReply"] = (
|
|
{"type": "text", "text": self._welcome}
|
|
if isinstance(self._welcome, str)
|
|
else self._welcome
|
|
)
|
|
if address["addressSettings"] != desired:
|
|
log.info("Bot address settings changed, updating...")
|
|
await api.api_set_address_settings(user_id, desired)
|
|
|
|
return link
|
|
|
|
def _profile_to_wire(self) -> T.Profile:
|
|
"""Bot profile: base profile + peerType=bot, command list, calls/voice prefs disabled.
|
|
|
|
Mirrors Node `mkBotProfile` (bot.ts:88-102).
|
|
"""
|
|
p = super()._profile_to_wire()
|
|
prefs: T.Preferences = {
|
|
"calls": {"allow": "no"},
|
|
"voice": {"allow": "no"},
|
|
"files": {"allow": "yes" if self._allow_files else "no"},
|
|
}
|
|
if self._commands:
|
|
cmds: list[T.ChatBotCommand] = []
|
|
for c in self._commands:
|
|
entry: T.ChatBotCommand_command = {
|
|
"type": "command",
|
|
"keyword": c.keyword,
|
|
"label": c.label,
|
|
}
|
|
# `params` is `NotRequired[str]` on the wire; omit the
|
|
# key entirely when None so the Haskell parser sees
|
|
# `Nothing` rather than `Just ""`. The two have
|
|
# different client semantics: `Nothing` (`params=None`)
|
|
# triggers an immediate send on tap; `Just ""` would
|
|
# paste `/<keyword> ` (with a trailing space) into the
|
|
# input box, which is rarely what the operator wants.
|
|
if c.params is not None:
|
|
entry["params"] = c.params
|
|
cmds.append(entry)
|
|
prefs["commands"] = cmds
|
|
p["preferences"] = prefs
|
|
p["peerType"] = "bot"
|
|
return p
|
|
|
|
|
|
__all__ = [
|
|
"Bot",
|
|
"BotCommand",
|
|
"BotProfile",
|
|
"ChatMessage",
|
|
"Client",
|
|
"CommandHandler",
|
|
"EventHandler",
|
|
"FileMessage",
|
|
"ImageMessage",
|
|
"LinkMessage",
|
|
"Message",
|
|
"MessageHandler",
|
|
"Middleware",
|
|
"ParsedCommand",
|
|
"Profile",
|
|
"ReportMessage",
|
|
"TextMessage",
|
|
"UnknownMessage",
|
|
"VideoMessage",
|
|
"VoiceMessage",
|
|
]
|