mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 02:31:50 +00:00
Merge branch 'master' into f/public-groups
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
|
||||
|
||||
@@ -1683,6 +1683,10 @@ class Format_simplexLink(TypedDict):
|
||||
simplexUri: str
|
||||
smpHosts: list[str] # non-empty
|
||||
|
||||
class Format_simplexName(TypedDict):
|
||||
type: Literal["simplexName"]
|
||||
nameInfo: "SimplexNameInfo"
|
||||
|
||||
class Format_command(TypedDict):
|
||||
type: Literal["command"]
|
||||
commandStr: str
|
||||
@@ -1708,13 +1712,14 @@ Format = (
|
||||
| Format_uri
|
||||
| Format_hyperLink
|
||||
| Format_simplexLink
|
||||
| Format_simplexName
|
||||
| Format_command
|
||||
| Format_mention
|
||||
| Format_email
|
||||
| Format_phone
|
||||
)
|
||||
|
||||
Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "command", "mention", "email", "phone"]
|
||||
Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"]
|
||||
|
||||
class FormattedText(TypedDict):
|
||||
format: NotRequired["Format"]
|
||||
@@ -1851,6 +1856,10 @@ class GroupLinkPlan_noRelays(TypedDict):
|
||||
type: Literal["noRelays"]
|
||||
groupSLinkData_: NotRequired["GroupShortLinkData"]
|
||||
|
||||
class GroupLinkPlan_updateRequired(TypedDict):
|
||||
type: Literal["updateRequired"]
|
||||
groupSLinkData_: NotRequired["GroupShortLinkData"]
|
||||
|
||||
GroupLinkPlan = (
|
||||
GroupLinkPlan_ok
|
||||
| GroupLinkPlan_ownLink
|
||||
@@ -1858,9 +1867,10 @@ GroupLinkPlan = (
|
||||
| GroupLinkPlan_connectingProhibit
|
||||
| GroupLinkPlan_known
|
||||
| GroupLinkPlan_noRelays
|
||||
| GroupLinkPlan_updateRequired
|
||||
)
|
||||
|
||||
GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays"]
|
||||
GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired"]
|
||||
|
||||
class GroupMember(TypedDict):
|
||||
groupMemberId: int # int64
|
||||
@@ -1936,6 +1946,7 @@ class GroupRelay(TypedDict):
|
||||
userChatRelay: "UserChatRelay"
|
||||
relayStatus: "RelayStatus"
|
||||
relayLink: NotRequired[str]
|
||||
relayCap: "RelayCapabilities"
|
||||
|
||||
class GroupRootKey_private(TypedDict):
|
||||
type: Literal["private"]
|
||||
@@ -2344,6 +2355,12 @@ ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | P
|
||||
|
||||
ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"]
|
||||
|
||||
class PublicGroupAccess(TypedDict):
|
||||
groupWebPage: NotRequired[str]
|
||||
groupDomain: NotRequired[str]
|
||||
domainWebPage: bool
|
||||
allowEmbedding: bool
|
||||
|
||||
class PublicGroupData(TypedDict):
|
||||
publicMemberCount: int # int64
|
||||
|
||||
@@ -2351,6 +2368,7 @@ class PublicGroupProfile(TypedDict):
|
||||
groupType: "GroupType"
|
||||
groupLink: str
|
||||
publicGroupId: str
|
||||
publicGroupAccess: NotRequired["PublicGroupAccess"]
|
||||
|
||||
class RCErrorType_internal(TypedDict):
|
||||
type: Literal["internal"]
|
||||
@@ -2619,6 +2637,9 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError
|
||||
|
||||
RcvMsgError_Tag = Literal["dropped", "parseError"]
|
||||
|
||||
class RelayCapabilities(TypedDict):
|
||||
webDomain: NotRequired[str]
|
||||
|
||||
class RelayProfile(TypedDict):
|
||||
displayName: str
|
||||
fullName: str
|
||||
@@ -2680,6 +2701,19 @@ class SimplePreference(TypedDict):
|
||||
|
||||
SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"]
|
||||
|
||||
class SimplexNameDomain(TypedDict):
|
||||
nameTLD: "SimplexTLD"
|
||||
domain: str
|
||||
subDomain: list[str]
|
||||
|
||||
class SimplexNameInfo(TypedDict):
|
||||
nameType: "SimplexNameType"
|
||||
nameDomain: "SimplexNameDomain"
|
||||
|
||||
SimplexNameType = Literal["publicGroup", "contact"]
|
||||
|
||||
SimplexTLD = Literal["simplex", "testing", "web"]
|
||||
|
||||
SndCIStatusProgress = Literal["partial", "complete"]
|
||||
|
||||
class SndConnEvent_switchQueue(TypedDict):
|
||||
|
||||
@@ -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