mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-05 08:02:08 +00:00
simplex-chat-python: add BotCommand.params field (#7034)
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user