diff --git a/packages/simplex-chat-python/examples/squaring_bot.py b/packages/simplex-chat-python/examples/squaring_bot.py index 296b51347e..4d062ad718 100644 --- a/packages/simplex-chat-python/examples/squaring_bot.py +++ b/packages/simplex-chat-python/examples/squaring_bot.py @@ -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=""`: the client PASTES `/square ` + # into the input box and positions the cursor at the end. The + # user replaces `` with the actual number and sends. + BotCommand(keyword="square", label="Square a number", params=""), + ], ) 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 ` and the user replaced + `` with the actual value before sending.""" + try: + n = float(cmd.args) + except ValueError: + await msg.reply(f"Usage: /square (got {cmd.args!r})") + return + await msg.reply(f"{n} * {n} = {n * n}") + + if __name__ == "__main__": bot.run() diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index fb511e2818..4e385493b2 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -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 `/` + immediately on tap; no input-box detour. Use this for + zero-argument commands (`/help`, `/ping`) where the action is + unambiguous. + + * `params=""` — the client PASTES `/ ` + 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 `, + `/order `) 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 `/ ` (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 diff --git a/packages/simplex-chat-python/tests/test_bot_registration.py b/packages/simplex-chat-python/tests/test_bot_registration.py index f6f245c344..06837bdc07 100644 --- a/packages/simplex-chat-python/tests/test_bot_registration.py +++ b/packages/simplex-chat-python/tests/test_bot_registration.py @@ -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: `. The SimpleX client (verified against + `CommandsMenuView.kt:153-161` and `CommandsMenuView.swift:117-128` + in simplex-chat 6.5) then pastes `/ ` 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 `).""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + commands=[ + BotCommand(keyword="review", label="Review PR", params=""), + BotCommand(keyword="order", label="Place order", params=""), + ], + ) + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] + assert cmds[0] == { + "type": "command", + "keyword": "review", + "label": "Review PR", + "params": "", + } + assert cmds[1] == { + "type": "command", + "keyword": "order", + "label": "Place order", + "params": "", + } + + +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():