mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-28 07:49:50 +00:00
simplex-chat-python: add python library (#6954)
* docs: simplex-chat-python design and implementation plan * bots: Python wire types codegen * simplex-chat-python: package scaffold * simplex-chat-python: native libsimplex loader * simplex-chat-python: async FFI wrappers * simplex-chat-python: ChatApi with 49 api methods * simplex-chat-python: Bot class with decorators and dispatch * simplex-chat-python: install CLI, example bot, README * simplex-chat-python: audit fixes * bots: regenerate API docs and types Catches up the markdown, TypeScript and Python codegen outputs with two upstream schema changes: - APIConnectPlan.connectionLink became optional (from sh/python-lib audit fixes); cmdString and EBNF syntax now reflect optional parameter. - APIAddGroupRelays command and CRGroupRelaysAdded/CRGroupRelaysAddFailed responses added in #6917 (relay management). The TS and markdown outputs were regenerated when #6917 landed but the Python types module only got the new entries with this regeneration. * core: refresh SQLite query plans after relay_inactive_at migration The M20260507_relay_inactive_at migration (#6917 / #6952) shifted the query plans that 'Save query plans' verifies. Regenerated via the test that owns those snapshots; no behavioral change. * bots: keep APIConnectPlan connectionLink as required parameter The prior audit-fixes commit changed the syntax expression to `Optional ...` because the Haskell field is `connectionLink :: Maybe AConnectionLink`. That misrepresents the API contract: the `Maybe` is purely an internal signal for link-parsing failure (the handler returns `CEInvalidConnReq` on `Nothing`), not API-level optionality. Callers MUST always pass a connection link. Revert the syntax expression to `Param "connectionLink"` and add a comment so the intent is preserved next time someone audits. Regenerates COMMANDS.md, commands.ts and _commands.py to match.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Reusable helpers for working with chat events, types, and message content.
|
||||
|
||||
Mirrors the Node `util.ts` exports — provides the same primitives bot
|
||||
authors typically reach for: command parsing, sender display strings,
|
||||
message-content extraction, profile field cleanup, and ChatRef extraction
|
||||
from a ChatInfo (handy when echoing into a different chat).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .types import T
|
||||
|
||||
|
||||
def chat_info_ref(c_info: T.ChatInfo) -> T.ChatRef | None:
|
||||
"""Extract a wire-format `ChatRef` from a `ChatInfo`.
|
||||
|
||||
Returns `None` for non-chat infos (contactRequest, contactConnection)
|
||||
that can't be the target of `api_send_messages`. For groups, the
|
||||
`memberSupport` scope is forwarded so messages land in the right
|
||||
thread; other scopes are dropped (matches Node `util.chatInfoRef`).
|
||||
"""
|
||||
t = c_info["type"]
|
||||
if t == "direct":
|
||||
return {"chatType": "direct", "chatId": c_info["contact"]["contactId"]} # type: ignore[index]
|
||||
if t == "group":
|
||||
ref: T.ChatRef = {"chatType": "group", "chatId": c_info["groupInfo"]["groupId"]} # type: ignore[index]
|
||||
scope = c_info.get("groupChatScope") # type: ignore[union-attr]
|
||||
if scope and scope.get("type") == "memberSupport":
|
||||
member = scope.get("groupMember_")
|
||||
ms_scope: T.GroupChatScope_memberSupport = {"type": "memberSupport"}
|
||||
if member is not None:
|
||||
ms_scope["groupMemberId_"] = member["groupMemberId"]
|
||||
ref["chatScope"] = ms_scope
|
||||
return ref
|
||||
return None
|
||||
|
||||
|
||||
def chat_info_name(c_info: T.ChatInfo) -> str:
|
||||
"""Display string for a chat: `@Alice`, `#GroupName`, `private notes`, etc."""
|
||||
t = c_info["type"]
|
||||
if t == "direct":
|
||||
return f"@{c_info['contact']['profile']['displayName']}" # type: ignore[index]
|
||||
if t == "group":
|
||||
scope = c_info.get("groupChatScope") # type: ignore[union-attr]
|
||||
if scope and scope.get("type") == "memberSupport":
|
||||
member = scope.get("groupMember_")
|
||||
scope_name = f" {member['memberProfile']['displayName']}" if member else ""
|
||||
return f"#{c_info['groupInfo']['groupProfile']['displayName']}(support{scope_name})" # type: ignore[index]
|
||||
return f"#{c_info['groupInfo']['groupProfile']['displayName']}" # type: ignore[index]
|
||||
if t == "local":
|
||||
return "private notes"
|
||||
if t == "contactRequest":
|
||||
return f"request from @{c_info['contactRequest']['profile']['displayName']}" # type: ignore[index]
|
||||
if t == "contactConnection":
|
||||
alias = c_info["contactConnection"].get("localAlias") # type: ignore[index]
|
||||
return f"pending connection ({alias})" if alias else "pending connection"
|
||||
return f"<{t}>"
|
||||
|
||||
|
||||
def sender_name(c_info: T.ChatInfo, chat_dir: T.CIDirection) -> str:
|
||||
"""Sender display: chat name plus group sender suffix when applicable."""
|
||||
base = chat_info_name(c_info)
|
||||
if chat_dir["type"] == "groupRcv":
|
||||
sender = chat_dir["groupMember"]["memberProfile"]["displayName"] # type: ignore[index]
|
||||
return f"{base} @{sender}"
|
||||
return base
|
||||
|
||||
|
||||
def contact_address_str(link: T.CreatedConnLink) -> str:
|
||||
"""Prefer the short link, fall back to the full link."""
|
||||
return link.get("connShortLink") or link["connFullLink"]
|
||||
|
||||
|
||||
def from_local_profile(local: T.LocalProfile) -> T.Profile:
|
||||
"""Strip extra LocalProfile fields (profileId, localAlias) and undefined values."""
|
||||
p: dict[str, Any] = {}
|
||||
for key in (
|
||||
"displayName",
|
||||
"fullName",
|
||||
"shortDescr",
|
||||
"image",
|
||||
"contactLink",
|
||||
"preferences",
|
||||
"peerType",
|
||||
):
|
||||
v = local.get(key) # type: ignore[misc]
|
||||
if v is not None:
|
||||
p[key] = v
|
||||
return p # type: ignore[return-value]
|
||||
|
||||
|
||||
def ci_content_text(chat_item: T.ChatItem) -> str | None:
|
||||
"""Extract the message text from a sent or received message item, if any."""
|
||||
content = chat_item["content"]
|
||||
if content["type"] in ("sndMsgContent", "rcvMsgContent"):
|
||||
msg = content.get("msgContent", {}) # type: ignore[union-attr]
|
||||
return msg.get("text")
|
||||
return None
|
||||
|
||||
|
||||
_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$")
|
||||
|
||||
|
||||
def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None:
|
||||
"""Parse a `/keyword args...` slash-command from a chat item.
|
||||
|
||||
Returns `(keyword, trimmed_params)` or `None` if the message isn't a
|
||||
slash command. Mirrors Node `util.ciBotCommand` semantics.
|
||||
"""
|
||||
text = ci_content_text(chat_item)
|
||||
if not text:
|
||||
return None
|
||||
text = text.strip()
|
||||
m = _BOT_COMMAND_RE.match(text)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1), m.group(2).strip()
|
||||
|
||||
|
||||
def reaction_text(reaction: T.ACIReaction) -> str:
|
||||
"""Format an `ACIReaction` as the emoji character or tag string."""
|
||||
r = reaction["chatReaction"]["reaction"] # type: ignore[index]
|
||||
if r["type"] == "emoji":
|
||||
return r["emoji"] # type: ignore[index]
|
||||
return r.get("tag", "") # type: ignore[union-attr]
|
||||
Reference in New Issue
Block a user