Files
simplex-chat/packages/simplex-chat-python/src/simplex_chat/util.py
T
sh e63c403623 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.
2026-05-12 12:32:01 +01:00

129 lines
4.9 KiB
Python

"""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]