simplex-chat-python: add BotCommand.params field (#7034)

This commit is contained in:
sh
2026-06-01 12:20:29 +00:00
committed by GitHub
parent 60e75aa398
commit e4ef8ef101
3 changed files with 126 additions and 5 deletions
@@ -26,7 +26,15 @@ bot = Bot(
profile=BotProfile(display_name="Squaring bot"),
db=SqliteDb(file_prefix="./squaring_bot"),
welcome="Send me a number, I'll square it.",
commands=[BotCommand(keyword="help", label="Show help")],
commands=[
# `params=None` (default): the client SENDS `/help` immediately
# when the user taps it in the commands menu.
BotCommand(keyword="help", label="Show help"),
# `params="<number>"`: the client PASTES `/square <number>`
# into the input box and positions the cursor at the end. The
# user replaces `<number>` with the actual number and sends.
BotCommand(keyword="square", label="Square a number", params="<number>"),
],
)
NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$")
@@ -48,5 +56,19 @@ async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None:
await msg.reply("Send a number, I'll square it.")
@bot.on_command("square")
async def square_cmd(msg: Message, cmd: ParsedCommand) -> None:
"""Demonstrates the `params` flow: `cmd.args` is the trimmed text
AFTER `/square`. When the user tapped the menu entry above, the
client pasted `/square <number>` and the user replaced
`<number>` with the actual value before sending."""
try:
n = float(cmd.args)
except ValueError:
await msg.reply(f"Usage: /square <number> (got {cmd.args!r})")
return
await msg.reply(f"{n} * {n} = {n * n}")
if __name__ == "__main__":
bot.run()
@@ -33,8 +33,38 @@ 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):
@@ -145,10 +175,24 @@ class Bot(Client):
"files": {"allow": "yes" if self._allow_files else "no"},
}
if self._commands:
prefs["commands"] = [
{"type": "command", "keyword": c.keyword, "label": c.label}
for c in 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
@@ -86,8 +86,63 @@ def test_bot_profile_to_wire_with_commands():
)
cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or []
assert len(cmds) == 2
# `params` defaults to None and must be ABSENT from the wire dict
# (not present as `null`/`""`) so the Haskell parser sees
# `Nothing` and the SimpleX client sends the bare `/keyword` on
# tap rather than pasting a trailing-space placeholder.
assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"}
assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"}
assert "params" not in cmds[0]
assert "params" not in cmds[1]
def test_bot_command_params_emits_on_wire():
"""When `BotCommand.params` is set, the wire dict carries it as
`params: <str>`. The SimpleX client (verified against
`CommandsMenuView.kt:153-161` and `CommandsMenuView.swift:117-128`
in simplex-chat 6.5) then pastes `/<keyword> <params>` into the
input box on tap, positions the cursor at the end, and lets the
user edit before sending. Use this for commands that take a
required argument (`/review <pr-url>`)."""
bot = Bot(
profile=BotProfile(display_name="x"),
db=SqliteDb(file_prefix="/tmp/test"),
commands=[
BotCommand(keyword="review", label="Review PR", params="<pr-url>"),
BotCommand(keyword="order", label="Place order", params="<order number>"),
],
)
cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or []
assert cmds[0] == {
"type": "command",
"keyword": "review",
"label": "Review PR",
"params": "<pr-url>",
}
assert cmds[1] == {
"type": "command",
"keyword": "order",
"label": "Place order",
"params": "<order number>",
}
def test_bot_command_distinguishes_none_from_empty_params():
"""`params=None` (immediate send) and `params=""` (paste with
trailing space) are semantically different on the client side.
Verify the wire form preserves the distinction: None → key
absent; empty string → key present with empty value."""
bot = Bot(
profile=BotProfile(display_name="x"),
db=SqliteDb(file_prefix="/tmp/test"),
commands=[
BotCommand(keyword="send", label="Send", params=None),
BotCommand(keyword="paste", label="Paste", params=""),
],
)
cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or []
assert "params" not in cmds[0]
assert cmds[1].get("params") == ""
def test_client_profile_to_wire_has_no_bot_extras():