From 82e9e2ad5972675ea9618cb56893b39b837f0a65 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Sat, 29 Aug 2026 13:17:37 +0300 Subject: [PATCH 01/13] community: Set channel category & position --- docs/community.md | 59 +++++++++++++++++- pyproject.toml | 2 +- status_sdk/__init__.py | 2 +- status_sdk/community/base.py | 7 ++- status_sdk/community/channel.py | 104 ++++++++++++++++++++++++++++++-- status_sdk/exceptions.py | 3 + 6 files changed, 167 insertions(+), 10 deletions(-) diff --git a/docs/community.md b/docs/community.md index 68b30de..bcaf5ee 100644 --- a/docs/community.md +++ b/docs/community.md @@ -946,7 +946,8 @@ Returns `list[dict]`, one entry per channel. |----|----|-------------| | `id` | `str` | The channel id (within the community). | | `name` | `str` | The channel name. Use this with [subscript access](./community.md#fetching-a-channel) and [`delete_channel`](./community.md#delete_channelchannel_name). | -| `category` | `str`
`None` | The id of the category the channel belongs to, or `None` if uncategorised. | +| `category_id` | `str`
`None` | The id of the category the channel belongs to, or `None` if uncategorised. | +| `category_name` | `str`
`None` | The name of the category the channel belongs to, or `None` if uncategorised. | ```python from status_sdk import Account, Community @@ -1498,6 +1499,62 @@ print(channel.emoji) ![Community Edit Emoji](./images/community/edit-channel-emoji.png) +### `category` + +Get the channel's category, or move the channel to another one. It can also be set by a **category name**, as described in [`categories`](./community.md#categories), and the channel is placed at the bottom of that category. Unknown category names are ignored and the channel is moved out of its current category, next to the other uncategorised channels. + +Returns `str` when reading, or `None` if the channel is not in a category. + +```python +from status_sdk import Account, Community + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +# Read +print(channel.category) + +# Update +channel.category = "Development" +``` + +### `position` + +Get or update the channel's position inside its own [`category`](./community.md#category). Positions start at `0` at the top of the category, so assigning `0` moves the channel to the very top. Out of range values are clamped - anything below `0` becomes `0`, and anything past the last channel moves it to the bottom of the category. + +Returns `int` when reading. + +```python +from status_sdk import Account, Community + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +# Read +print(channel.position) + +# Update - move the channel to the top of its category +channel.position = 0 +``` + ### Permissions Four properties describe what the logged-in account may do in the channel - [`can_post`](./community.md#can_post), [`can_view`](./community.md#can_view), [`can_react`](./community.md#can_react) and [`is_token_gated`](./community.md#is_token_gated). They are the per-channel counterpart of the `permissions` keys exposed by [`communities`](./account.md#channels) on `Account`, and every one of them returns a `bool`. diff --git a/pyproject.toml b/pyproject.toml index 4228477..881b261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "status-sdk" -version = "1.1.3" +version = "1.1.4" description = "Private chat. Communities. Multi-chain wallet. Browser. dApps all in one app, powered by SNT." readme = "README.md" requires-python = ">=3.11" diff --git a/status_sdk/__init__.py b/status_sdk/__init__.py index f3c258f..fecd7b3 100644 --- a/status_sdk/__init__.py +++ b/status_sdk/__init__.py @@ -2,7 +2,7 @@ from .account import Account from .group_chat import GroupChat -from .community.base import Community +from .community.base import Community, Channel from .utils import launch_docker_container from . import exceptions, models diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py index 7547fd0..c45d232 100644 --- a/status_sdk/community/base.py +++ b/status_sdk/community/base.py @@ -455,11 +455,16 @@ def channels(self) -> list[dict]: High level information for all community channels """ result = self.__get_community_info() + category_mapping = { + category_id: info["name"] + for category_id, info in result.get("categories", {}).items() + } available_chats = [ { "id": current["id"], "name": current["name"], - "category": current["categoryID"] if len(current["categoryID"]) > 0 else None + "category_id": current["categoryID"] if len(current["categoryID"]) > 0 else None, + "category_name": category_mapping.get(current["categoryID"]) } for current in result["chats"].values() ] diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py index b46ed6b..2912fbf 100644 --- a/status_sdk/community/channel.py +++ b/status_sdk/community/channel.py @@ -135,6 +135,65 @@ def can_post(self) -> bool: """ return self.__get_channel_info()["canPost"] + @property + def category(self) -> Optional[str]: + """ + The category's name + """ + return self.__get_channel_info()["categoryName"] + + @category.setter + def category(self, value: str): + community_info = self.__get_community_info() + category_mapping = { + info["name"]: category_id + for category_id, info in community_info.get("categories", {}).items() + } + + new_category = category_mapping.get(value) + + position_mapping = self.__get_category_positions() + params = [{ + "communityId": self.__community_id, + "categoryId": '' if not new_category else new_category, + "chatId": self.id, + "position": position_mapping.get(new_category, 0) + 1 + }] + self.__account._call_rpc("messaging", "reorderCommunityChat", params) + + @property + def position(self) -> Optional[str]: + """ + The position of the chat in the current `category` + """ + return self.__get_channel_info()["position"] + + @position.setter + def position(self, value: int): + if not isinstance(value, int): + raise exceptions.InvalidCommunityChannelPositionError("Community channel position must be an integer.") + + category_id = self.__get_channel_info()["categoryID"] + if len(category_id) == 0: + category_id = None + + position_mapping = self.__get_category_positions() + largest_position = position_mapping[category_id] + new_largest_position = largest_position + 1 + if value < 0: + value = 0 + + if value > new_largest_position: + value = new_largest_position + + params = [{ + "communityId": self.__community_id, + "categoryId": '' if not category_id else category_id, + "chatId": self.id, + "position": value + }] + self.__account._call_rpc("messaging", "reorderCommunityChat", params) + @property def description(self) -> str: """ @@ -261,6 +320,7 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None left out of `chat_setup` is cleared. That is why every current value is filled in first, and only the provided arguments override it. """ + info = self.__get_channel_info() channel_setup = { "identity": { "display_name": self.name, @@ -268,8 +328,8 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None "color": self.colour, "description": self.description }, - "category_id": self.__get_channel_info()["categoryID"], - "position": self.__get_channel_info()["position"] + "category_id": info["categoryID"], + "position": info["position"] } if name: @@ -287,9 +347,31 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None params = [self.__community_id, self.id, channel_setup] self.__account._call_rpc("messaging", "editCommunityChat", params) - def __get_channel_info(self) -> dict: + def __get_category_positions(self) -> dict: """ - Get information for the current channel. + Get the largest position for each category + """ + largest = {} + for chat in (self.__get_community_info().get("chats") or {}).values(): + category_id: str = chat["categoryID"] + current_position = chat["position"] + if len(category_id) == 0: + category_id = None + + if category_id not in largest: + largest[category_id] = current_position + + if current_position > largest[category_id]: + largest[category_id] = current_position + + return largest + + def __get_community_info(self) -> dict: + """ + Get up to date information for the community + + Output: + - up to date community data """ params = { "communityKey": self.__community_id, @@ -301,14 +383,24 @@ def __get_channel_info(self) -> dict: if error: raise exceptions.InvalidCommunityKeyError(error["message"]) - chats: dict[str, dict] = response["result"]["chats"] + if not response["result"]: + raise exceptions.CommunityNotFoundError(f"Community '{self.id}' was not found...") + + return response["result"] + + def __get_channel_info(self) -> dict: + """ + Get information for the current channel. + """ + response = self.__get_community_info() + chats: dict[str, dict] = response["chats"] selected_chat: dict = chats.get(self.id.replace(self.__community_id, ""), {}) if not selected_chat: raise exceptions.CommunityChannelNotFoundError() category_mapping = { category_id: info["name"] - for category_id, info in response["result"].get("categories", {}).items() + for category_id, info in response.get("categories", {}).items() } selected_chat["categoryName"] = category_mapping.get(selected_chat["categoryID"]) return selected_chat diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py index 7a6f550..f9251ca 100644 --- a/status_sdk/exceptions.py +++ b/status_sdk/exceptions.py @@ -74,6 +74,9 @@ class InvalidCommunityChannelColourError(ValueError): class InvalidCommunityChannelEmojiError(ValueError): pass +class InvalidCommunityChannelPositionError(ValueError): + pass + class ChatNotFoundError(Exception): pass From d90ca7886974daa95b485fed3e4fba6f66825717 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Sat, 29 Aug 2026 16:38:13 +0300 Subject: [PATCH 02/13] account: Message length property --- docs/account.md | 27 +++++++++++++++++++++++++++ status_sdk/account.py | 7 +++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/account.md b/docs/account.md index f529188..62a7fff 100644 --- a/docs/account.md +++ b/docs/account.md @@ -1908,6 +1908,33 @@ for chat in account.chats: print(f"{chat['type']}\t{chat['name']}\t{chat['id']}") ``` +#### `message_length` + +The maximum number of characters a single message may contain, as enforced by Status App. + +Returns `int`. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +print(account.message_length) + +# This is under the assumption you already have a contact / joined a community +chat = account.chats[0] +report = "A very long report..." + +# Split a long text into messages the app accepts +for index in range(0, len(report), account.message_length): + account.send_message(chat["id"], report[index:index + account.message_length]) +``` + ### Wallet #### `chains` diff --git a/status_sdk/account.py b/status_sdk/account.py index 604518a..fb47808 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -13,7 +13,6 @@ from .logger import Logger class Account: - # Enum mappings from original wakuext.py __mappings = { "contact_request": { @@ -594,6 +593,10 @@ def balance(self) -> pd.DataFrame: balance.insert(0, "timestamp", datetime.datetime.now()) return balance.copy() + @property + def message_length(self) -> int: + return 2_000 + @property def status(self) -> str: """ @@ -738,7 +741,7 @@ def validate_path(path: str): if not message: message = "" - if len(message) > 2_000: + if len(message) > self.message_length: raise exceptions.MessageTooLongError(f"Message cannot be longer than 2000 characters (got {len(message)})...") msg_params = { From aa216d3ff196196bf43e1e3db15d66c82183230d Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Sat, 29 Aug 2026 21:53:16 +0300 Subject: [PATCH 03/13] bug: Duplicate message ID - If a message is replied to, the replied ID is returned instead of the message ID of the sent message --- status_sdk/account.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/status_sdk/account.py b/status_sdk/account.py index fb47808..746f7a3 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -796,7 +796,11 @@ def validate_path(path: str): if error: raise exceptions.SendContentError(error.get("message")) - return response["result"]["messages"][0]["id"] + messages = response["result"]["messages"] + if reply_to_message_id and len(messages) > 1: + messages = [m for m in messages if m["id"] != reply_to_message_id] or messages + + return max(messages, key=lambda m: m.get("clock", 0))["id"] def delete_message(self, id: str) -> bool: """ From d6d023233d4328c50dc00836fa43d5b62a584202 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Tue, 1 Sep 2026 10:26:05 +0300 Subject: [PATCH 04/13] bot: Listen for bridged messages --- status_sdk/models.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/status_sdk/models.py b/status_sdk/models.py index 61cefb2..9b5b411 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -41,7 +41,9 @@ class Message: from_public_key: str timestamp: datetime.datetime chat_type: str + source: str reply_id: Optional[str] = None + bridge_id: Optional[str] = None payment_requests: list[PaymentRequest] = field(default_factory=list) @classmethod @@ -52,7 +54,8 @@ def from_raw(cls, raw: dict) -> Self: "id": raw["id"], "chat_id": raw["chatId"], "from_public_key": raw["from"], - "timestamp": datetime.datetime.fromtimestamp(raw["whisperTimestamp"] / 1_000) + "timestamp": datetime.datetime.fromtimestamp(raw["whisperTimestamp"] / 1_000), + "source": raw.get("bridgeMessage", {}).get("bridgeName", "status") } if len(raw["responseTo"]) > 0: @@ -82,6 +85,11 @@ def from_raw(cls, raw: dict) -> Self: caption = f"{text}\n\n" if len(text) > 0 else "" params["content"] = f"{caption}{img_path}" params["content_type"] = "image" + # Bridged Message + elif content_type == 18: + params["content"] = raw["bridgeMessage"]["bridgeName"] + params["content_type"] = "text" + params["bridge_id"] = raw["bridgeMessage"]["messageID"] payments = raw.get("paymentRequests", []) if payments: From a8df9df25f546f98449dd8938781be8d9f1cb220 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Fri, 4 Sep 2026 15:16:42 +0300 Subject: [PATCH 05/13] bug: Listen Messages monitors specific content types only --- status_sdk/account.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/status_sdk/account.py b/status_sdk/account.py index 746f7a3..2eec68d 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -877,10 +877,20 @@ def listen_messages(self) -> Generator[models.Message, None, None]: """ Listen for new **RAW** messages continuously. Can be used for real time processing. """ + ALLOWED_CONTENT_TYPES = [ + 1, # Text + 2, # Sticker + 4, # Emojis + 7, # Image + 18, # Bridged Message + ] + for message in self.signal.listen("messages.new"): event: dict = message.get("event", {}) if "chats" in event or "messages" in event: for raw in event["messages"]: + if raw["contentType"] not in ALLOWED_CONTENT_TYPES: + continue yield models.Message.from_raw(raw) def get_messages(self, chat_id: str, start_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None, end_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None) -> list[dict]: From 8cd465baa2194afa8e259d246c891dd1934805a9 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Wed, 2 Sep 2026 19:50:17 +0300 Subject: [PATCH 06/13] bot: Add & Delete community permissions --- docs/community.md | 139 ++++++++++++++++++++- pyproject.toml | 2 +- status_sdk/community/base.py | 57 +++------ status_sdk/community/channel.py | 126 +++++++++++++------ status_sdk/exceptions.py | 3 + status_sdk/models.py | 7 ++ status_sdk/utils/__init__.py | 1 + status_sdk/utils/community.py | 97 ++++++++++++++ status_sdk/{utils.py => utils/external.py} | 9 +- 9 files changed, 361 insertions(+), 80 deletions(-) create mode 100644 status_sdk/utils/__init__.py create mode 100644 status_sdk/utils/community.py rename status_sdk/{utils.py => utils/external.py} (95%) diff --git a/docs/community.md b/docs/community.md index bcaf5ee..54a636f 100644 --- a/docs/community.md +++ b/docs/community.md @@ -1179,6 +1179,42 @@ A valid channel emoji must satisfy all of the following: | `🇬🇧` | Flag (multi-codepoint) | | `👨‍👩‍👧` | ZWJ sequence | +## Channel permissions + +A **permission** is a rule that decides who may do something in the community. Every permission has a **scope** - what it grants - and optional **token criteria** - what a member must hold to be granted it. A permission with no token criteria applies to everyone; a permission with token criteria is what makes a channel [token gated](./community.md#is_token_gated). + +Permissions are [added](./community.md#add_permissionpermission-tokensnone), [listed](./community.md#permissions-1) and [deleted](./community.md#delete_permissionid) through the channel. + +### Permission scopes + +The `permission` argument is a **case-insensitive** string. Any other value raises a custom exception listing the valid ones. + +| Scope | Description | +|-----|-----|-------------| +| `view` | Members who meet the criteria can **read** the channel. | +| `view_post` | Members who meet the criteria can **read and post** in the channel. | +| `admin` | Members who meet the criteria become **administrators**. | +| `member` | Members who meet the criteria can **join** the community. | +| `token_master` | Members who meet the criteria become **token masters**. | +| `token_owner` | Members who meet the criteria become **token owners**. | + +`view` and `view_post` are the channel-level scopes and are the ones you normally want here. The remaining four are **community-wide** roles - they are accepted by the SDK, but the backend decides whether it will attach them to a single channel, and a rejection is raised as a custom exception. + +### Token criteria + +Token criteria are described with the `models.TokenPermission` **dataclass**: + +| Attribute | Type | Required | Description | +|----|----|----|-------------| +| `symbol` | `str` | Yes | The token symbol, as it appears in [`get_tokens`](./account.md#get_tokens) on `Account`. | +| `amount` | `float` | Yes | The minimum amount a member must hold. Converted to wei using the token's own `decimals`, so it is written in human units - `10` means 10 tokens, not 10 wei. | +| `chain_id` | `int` | No | The chain the token lives on. Defaults to `1` (Ethereum mainnet). | +| `address` | `str` | No | The token's contract address. Needed only when the same `symbol` exists more than once on `chain_id`. | + +Passing several `TokenPermission` objects requires a member to satisfy **all** of them. + +**Note**: Currently only **ERC-20** tokens are supported. Collectibles (ERC-721) and ENS criteria are configured in Status App. + ## Methods ### `send_message(message, reply_to_message_id=None)` @@ -1340,6 +1376,66 @@ deleted = channel.delete_message(messages[0]["id"]) print(f"Deleted: {deleted}") ``` +### `add_permission(permission, tokens=None)` + +Add a permission to the channel. See [Channel permissions](./community.md#channel-permissions) for what the scopes mean and how token criteria are described. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `permission` | `str` | Yes | The [scope](./community.md#permission-scopes) to grant - `view`, `view_post`, `admin`, `member`, `token_master` or `token_owner`. Case-insensitive. | +| `tokens` | `models.TokenPermission`
`list[models.TokenPermission]` | No | The [token criteria](./community.md#token-criteria) a member must meet. A single object or a list of them. When omitted, the permission applies to **every** member. | + +```python +from status_sdk import Account, Community, models + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +# Open to every member - anyone can read and post +channel.add_permission("view") + +# Token gated - only members holding at least 10 SNT on mainnet can read +channel.add_permission("view", models.TokenPermission(symbol="SNT", amount=10)) +``` + +### `delete_permission(id)` + +Delete a permission from the channel by its id. Current permissions and their ids come from the [`permissions` property](./community.md#permissions-1). + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `id` | `str` | Yes | The permission's `id`, from the [`permissions` property](./community.md#permissions-1). | + + +```python +from status_sdk import Account, Community + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +# Remove every token gate on the channel, leaving it open to all members +for permission_id in channel.permissions["id"].unique(): + channel.delete_permission(permission_id) +``` + ## Properties ### `id` @@ -1566,6 +1662,8 @@ Four properties describe what the logged-in account may do in the channel - [`ca | [`can_react`](./community.md#can_react) | `reactions` | The account can post emoji reactions. | | [`is_token_gated`](./community.md#is_token_gated) | `token_gated` | Access to the channel is gated behind a token. | +A fifth property, [`permissions`](./community.md#permissions-1), works the other way round - instead of what **this account** may do, it lists the [permission rules](./community.md#channel-permissions) configured on the channel. + #### `can_post` Whether the logged-in account is allowed to post in the channel. @@ -1667,4 +1765,43 @@ if channel.is_token_gated and not channel.can_post: print("The account does not hold the token this channel requires") ``` -**Note**: token gating is configured in Status App. The SDK reports it, and always [creates channels](./community.md#create_channelname-description-emojinone-colournone-category_namenone) that are open to every member. +Channels are always [created](./community.md#create_channelname-description-emojinone-colournone-category_namenone) open to every member. A channel becomes token gated once a permission carrying [token criteria](./community.md#token-criteria) is [added](./community.md#add_permissionpermission-tokensnone) to it, and stops being gated when that permission is [deleted](./community.md#delete_permissionid). + +#### `permissions` + +The permissions currently configured **on this channel**. Unlike the four properties above, this does not describe what the logged-in account may do - it lists the rules themselves, and is where [`delete_permission`](./community.md#delete_permissionid) gets its ids. + +Returns a `pd.DataFrame` with one row per **chat id per token criterion**, so a permission covering several channels or requiring several tokens spans several rows. An **empty** `DataFrame` is returned when the community has no permissions at all. + +| Column | Type | Description | +|-------|------|-------------| +| `id` | `str` | The permission's id. Pass this to [`delete_permission`](./community.md#delete_permissionid). | +| `type` | `str` | The [scope](./community.md#permission-scopes), mapped back to its name - `view`, `view_post`, `admin`, `member`, `token_master` or `token_owner`. | +| `chat_id` | `str` | The channel the permission applies to. | +| `is_private` | `bool` | Whether the permission is hidden from members who do not meet it. | +| `symbol` | `str` | Symbol of the required token. | +| `amount_in_wei` | `str` | The required amount, in **wei**. Divide by `10 ** decimals` for human units. | +| `decimals` | `int` | The token's decimals. | +| `chain_id` | `int` | The chain the required token lives on. | +| `contract_address` | `str` | The required token's contract address. | +| `token_type` | `int` | The criterion type - `1` for ERC-20. | + +```python +from status_sdk import Account, Community + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +permissions = channel.permissions +if len(permissions) > 0: + print(permissions[["id", "type", "symbol", "amount_in_wei", "chain_id"]]) +``` diff --git a/pyproject.toml b/pyproject.toml index 881b261..4f530d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ Issues = "https://github.com/status-im/status-python-sdk/issues" "Status Backend" = "https://github.com/status-im/status-go" [tool.setuptools] -packages = ["status_sdk", "status_sdk.community"] +packages = ["status_sdk", "status_sdk.community", "status_sdk.utils"] [tool.setuptools.package-data] status_sdk = ["docker-compose.yaml"] diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py index c45d232..34a9004 100644 --- a/status_sdk/community/base.py +++ b/status_sdk/community/base.py @@ -2,6 +2,7 @@ from .. import exceptions, models from .channel import Channel from typing import Union, Optional, Generator +from ..utils import community as utils import pandas as pd import datetime, copy, os, shutil @@ -48,7 +49,7 @@ def __init__(self, account: Account, community_id: Optional[str] = None, url: Op raise exceptions.CommunityNotFoundError(error["message"]) self.__id = response["result"]["community"]["communityId"] - result: dict = self.__get_community_info() + result: dict = utils.get_community_info(self.__account, self.id) # Account is a member -> actions can be used if result["joined"]: return @@ -170,7 +171,7 @@ def get_collectibles(self) -> pd.DataFrame: Output: - DataFrame - row per `owner` per contract. """ - result: dict = self.__get_community_info() + result: dict = utils.get_community_info(self.__account, self.id) info = [ { "symbol": nft_info["symbol"], @@ -313,7 +314,7 @@ def categories(self) -> dict[str, str]: "id": community_id, "position": info["position"] } - for community_id, info in self.__get_community_info().get("categories", {}).items() + for community_id, info in utils.get_community_info(self.__account, self.id).get("categories", {}).items() } return mapping @@ -322,7 +323,7 @@ def role(self) -> str: """ The account's community role """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return self.__role_mapping[result["memberRole"]] @property @@ -330,7 +331,7 @@ def name(self) -> str: """ The community's name """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["name"] @property @@ -338,7 +339,7 @@ def description(self) -> str: """ The community's description """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["description"] @property @@ -346,7 +347,7 @@ def is_member(self) -> str: """ If the account is a member of the community """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["isMember"] @property @@ -354,7 +355,7 @@ def is_encrypted(self) -> str: """ If the account is a member of the community """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["encrypted"] @property @@ -362,7 +363,7 @@ def tags(self) -> str: """ The community's tags """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["tags"] @property @@ -370,7 +371,7 @@ def has_joined(self) -> str: """ The community's tags """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["joined"] @property @@ -392,7 +393,7 @@ def introduction(self) -> str: """ The community's introduction message when new users join """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["introMessage"] @property @@ -400,7 +401,7 @@ def leave_message(self) -> str: """ The community's leave message when a member leaves. """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["outroMessage"] def get_members(self, dataframe: bool = False) -> Union[dict[str, dict], pd.DataFrame]: @@ -414,7 +415,7 @@ def get_members(self, dataframe: bool = False) -> Union[dict[str, dict], pd.Data Output: - `dict` or `DataFrame` of the current community members """ - raw_data: dict[str, dict] = self.__get_community_info().get("members", {}) + raw_data: dict[str, dict] = utils.get_community_info(self.__account, self.id).get("members", {}) if not dataframe: return raw_data @@ -454,7 +455,7 @@ def channels(self) -> list[dict]: """ High level information for all community channels """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) category_mapping = { category_id: info["name"] for category_id, info in result.get("categories", {}).items() @@ -475,7 +476,7 @@ def banned_members(self) -> list[str]: """ Currently banned public keys """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) banned_states = [0, 4] # Banned, BanWithAllmessagesDeleted public_keys = [ public_key @@ -545,7 +546,7 @@ def __getitem__(self, channel_name: str) -> Channel: Fetch a community chat by its name using subscript access, e.g. `community[channel_name]`. Available chat names can be found in the `chats` property. """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) category_mapping = { category_id: info["name"] for category_id, info in result.get("categories", {}).items() @@ -572,28 +573,6 @@ def __len__(self) -> int: """ return len(self.get_members()) - def __get_community_info(self) -> dict: - """ - Get up to date information for the community - - Output: - - up to date community data - """ - params = { - "communityKey": self.id, - "waitForResponse": True, - "tryDatabase": True - } - response = self.__account._call_rpc("messaging", "fetchCommunity", [params]) - error: dict = response.get("error", {}) - if error: - raise exceptions.InvalidCommunityKeyError(error["message"]) - - if not response["result"]: - raise exceptions.CommunityNotFoundError(f"Community '{self.id}' was not found...") - - return response["result"] - def __normalise_public_keys(self, public_keys: Union[str, list[str]]) -> list[str]: """ Verify if the given public keys exist in the community @@ -633,7 +612,7 @@ def __to_datetime(self, key: str) -> Optional[datetime.datetime]: Output: - the `datetime.datetime` of the `key` """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return datetime.datetime.fromtimestamp(result[key]) if result[key] != 0 else None def __verify_admin(self): diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py index 2912fbf..a969afb 100644 --- a/status_sdk/community/channel.py +++ b/status_sdk/community/channel.py @@ -1,17 +1,20 @@ from ..account import Account -from .. import exceptions +from .. import exceptions, models +from ..utils import community as utils from typing import Union, Optional import pandas as pd -import re, datetime, random, unicodedata +import re, datetime, random class Channel: - __perimission_mapping = { - 0: "unknown", - 1: "auto_accept", - 2: "manual_accept" + __permission_mapping = { + 1: "admin", + 2: "member", + 3: "view", + 4: "view_post", + 5: "token_master", + 6: "token_owner" } - __STATUS_COLOURS = [ "#FF7D46", # Orange "#F6B03C", # Yellow @@ -99,6 +102,13 @@ def id(self) -> str: raise exceptions.CommunityChannelNotFoundError() return self.__id + @property + def permissions(self) -> pd.DataFrame: + data = utils.get_channel_permissions(self.__account, self.__community_id, self.id) + if len(data) > 0: + data["type"] = data["type"].map(self.__permission_mapping) + return data + @property def url(self) -> str: """ @@ -144,7 +154,7 @@ def category(self) -> Optional[str]: @category.setter def category(self, value: str): - community_info = self.__get_community_info() + community_info = utils.get_community_info(self.__account, self.__community_id) category_mapping = { info["name"]: category_id for category_id, info in community_info.get("categories", {}).items() @@ -246,7 +256,7 @@ def emoji(self, value: str): self.__validate_emoji(value) self.__edit_channel(emoji=value) - def send_message(self, message: str, reply_to_message_id: Optional[str] = None): + def send_message(self, message: str, reply_to_message_id: Optional[str] = None) -> Optional[str]: """ Send a message to the Community chat. @@ -257,7 +267,7 @@ def send_message(self, message: str, reply_to_message_id: Optional[str] = None): Output: - The message ID """ - return self.__account.send_message(self.id, message, reply_to_message_id) + return self.__account.send_message(self.id, message, reply_to_message_id) if self.can_post else None def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: """ @@ -271,7 +281,7 @@ def send_image(self, file_path: str, message: Optional[str] = None, reply_to_mes Output: - The message ID """ - return self.__account.send_image(self.id, file_path, message, reply_to_message_id) + return self.__account.send_image(self.id, file_path, message, reply_to_message_id) if self.can_post else None def send_emoji_reaction(self, message_id: str, emoji_shortname: str): """ @@ -281,6 +291,8 @@ def send_emoji_reaction(self, message_id: str, emoji_shortname: str): - `message_id` - the `id` of the message, as it appears in `self.get_messages()` - `emoji_shortname` - the emoji shortname as in Status App, with or without the surrounding colons """ + if not self.can_react: + return self.__account.send_emoji_reaction(message_id, emoji_shortname, self.id) def get_messages(self, start_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None, end_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None) -> list[dict]: @@ -296,7 +308,7 @@ def get_messages(self, start_timestamp: Optional[Union[str, datetime.datetime, d Output: - All messages within the given range """ - return self.__account.get_messages(self.id, start_timestamp, end_timestamp) + return self.__account.get_messages(self.id, start_timestamp, end_timestamp) if self.can_view else [] def delete_message(self, id: str) -> bool: """ @@ -312,6 +324,70 @@ def delete_message(self, id: str) -> bool: self.name return self.__account.delete_message(id) + def add_permission(self, permission: str, tokens: Optional[Union[list[models.TokenPermission], models.TokenPermission]] = None): + """ + Add a new permission for the channel + + Parameters: + - `permission` - the scope of the permission + + """ + if not isinstance(permission, str): + raise exceptions.InvalidCommunityChannelPermissionError("Community channel permission must be a string.") + + reversed_mapping = {name: number for number, name in self.__permission_mapping.items()} + permission = permission.lower() + if permission not in reversed_mapping: + raise exceptions.InvalidCommunityChannelPermissionError(f"'{permission}' is not a valid community channel permission. It must be one of: {', '.join(reversed_mapping)}.") + + token_criteria = [] + if tokens: + if isinstance(tokens, models.TokenPermission): + tokens = [tokens] + + available_tokens = self.__account.get_tokens() + for token_permission in tokens: + query = (available_tokens["symbol"] == token_permission.symbol) & (available_tokens["chain_id"] == token_permission.chain_id) + selected_tokens = available_tokens.loc[query, ["address", "decimals"]].drop_duplicates().reset_index(drop=True).copy() + if len(selected_tokens) > 1 and not token_permission.address: + continue + + token_info = selected_tokens.to_dict("records")[0] + token_criteria.append({ + "type": 1, # ERC20 + "contract_addresses": {str(token_permission.chain_id): token_info["address"]}, + "symbol": token_permission.symbol, + "name": token_permission.symbol, + "amountInWei": str(token_permission.amount * (10 ** token_info["decimals"])) + }) + + params = { + "communityId": self.__community_id, + "type": reversed_mapping[permission], + "tokenCriteria": token_criteria, + "chat_ids": [self.id] + } + response = self.__account._call_rpc("messaging", "createCommunityTokenPermission", [params]) + if response.get("error"): + raise exceptions.InvalidCommunityChannelPermissionError(response["error"]["message"]) + + def delete_permission(self, id: str): + """ + Delete a permission. Channel permissions can be found property `permissions`. + + Parameters: + - `id` - the permission's ID as it is in property `permissions` + """ + params = [ + { + "communityId": self.__community_id, + "permissionId": id, + } + ] + response = self.__account._call_rpc("messaging", "deleteCommunityTokenPermission", params) + if response.get("error"): + raise exceptions.InvalidCommunityChannelPermissionError(response["error"]["message"]) + def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None, colour: Optional[str] = None, description: Optional[str] = None): """ Modify chat related properties. @@ -352,7 +428,7 @@ def __get_category_positions(self) -> dict: Get the largest position for each category """ largest = {} - for chat in (self.__get_community_info().get("chats") or {}).values(): + for chat in (utils.get_community_info(self.__account, self.__community_id).get("chats") or {}).values(): category_id: str = chat["categoryID"] current_position = chat["position"] if len(category_id) == 0: @@ -366,33 +442,11 @@ def __get_category_positions(self) -> dict: return largest - def __get_community_info(self) -> dict: - """ - Get up to date information for the community - - Output: - - up to date community data - """ - params = { - "communityKey": self.__community_id, - "waitForResponse": True, - "tryDatabase": True - } - response = self.__account._call_rpc("messaging", "fetchCommunity", [params]) - error: dict = response.get("error", {}) - if error: - raise exceptions.InvalidCommunityKeyError(error["message"]) - - if not response["result"]: - raise exceptions.CommunityNotFoundError(f"Community '{self.id}' was not found...") - - return response["result"] - def __get_channel_info(self) -> dict: """ Get information for the current channel. """ - response = self.__get_community_info() + response = utils.get_community_info(self.__account, self.__community_id) chats: dict[str, dict] = response["chats"] selected_chat: dict = chats.get(self.id.replace(self.__community_id, ""), {}) if not selected_chat: diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py index f9251ca..aaa68f0 100644 --- a/status_sdk/exceptions.py +++ b/status_sdk/exceptions.py @@ -77,6 +77,9 @@ class InvalidCommunityChannelEmojiError(ValueError): class InvalidCommunityChannelPositionError(ValueError): pass +class InvalidCommunityChannelPermissionError(ValueError): + pass + class ChatNotFoundError(Exception): pass diff --git a/status_sdk/models.py b/status_sdk/models.py index 9b5b411..254f399 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -105,3 +105,10 @@ class CommunityRequest: id: str state: str public_key: str + +@dataclass +class TokenPermission: + symbol: str + amount: float + chain_id: int = 1 + address: Optional[str] = None diff --git a/status_sdk/utils/__init__.py b/status_sdk/utils/__init__.py new file mode 100644 index 0000000..17a6a7e --- /dev/null +++ b/status_sdk/utils/__init__.py @@ -0,0 +1 @@ +from .external import launch_docker_container diff --git a/status_sdk/utils/community.py b/status_sdk/utils/community.py new file mode 100644 index 0000000..7d81ad0 --- /dev/null +++ b/status_sdk/utils/community.py @@ -0,0 +1,97 @@ +from ..account import Account +from .. import exceptions +from typing import Optional +import pandas as pd + +def get_community_info(account: Account, community_id: str) -> dict: + """ + Get up to date information for the community. + Reused in `class Community` and `class Channel` + + Parameters: + - `account` - already logged in account + - `community_id` - community's ID + + Output: + - up to date community data + """ + params = { + "communityKey": community_id, + "waitForResponse": True, + "tryDatabase": True + } + response = account._call_rpc("messaging", "fetchCommunity", [params]) + error: dict = response.get("error", {}) + if error: + raise exceptions.InvalidCommunityKeyError(error["message"]) + + if not response["result"]: + raise exceptions.CommunityNotFoundError(f"Community '{community_id}' was not found...") + + return response["result"] + + +def get_channel_permissions(account: Account, community_id: str, channel_id: Optional[str] = None) -> pd.DataFrame: + """ + Get the current permissions for the selected Community Channel + + Parameters: + - `account` - already logged in account + - `community_id` - community's ID + - `channel_id` - channel's ID from `community_id` + + Output: + - up to date community permissions + """ + community_info = get_community_info(account, community_id) + if not community_info["tokenPermissions"]: + return pd.DataFrame() + df = pd.DataFrame(community_info["tokenPermissions"].values()).explode("chat_ids") + + # Fill is_private and expand token_criteria + df = df.assign( + is_private=df["is_private"].fillna(False) + ).explode("token_criteria")\ + .reset_index(drop=True) + + # Normalize token_criteria dictionaries into columns + token_cols = pd.json_normalize(df["token_criteria"]) + if "decimals" not in token_cols: + token_cols["decimals"] = 0 + + token_cols = token_cols.assign( + df_index = df.index, + decimals = token_cols["decimals"].fillna(0).astype(int) + ).rename(columns={"amountInWei": "amount_in_wei", "type": "token_type"}) + contract_cols = [col for col in token_cols.columns if col.startswith("contract_addresses.")] + + # Convert the contract address columns into rows + token_cols = token_cols.melt( + id_vars=[ + col for col in token_cols.columns if col not in contract_cols + ], + value_vars=contract_cols, + var_name="chain_id", + value_name="contract_address" + ) + + # Extract chain ID from: + # contract_addresses.1 -> 1 + # contract_addresses.42161 -> 42161 + token_cols["chain_id"] = token_cols["chain_id"].str.removeprefix("contract_addresses.").astype(int) + + # Remove rows where there is no contract address + token_cols = token_cols.dropna(subset=["contract_address"]) + + # Join the expanded token data back onto the original dataframe + df = df.drop(columns=["token_criteria"])\ + .reset_index()\ + .rename(columns={"index": "df_index", "chat_ids": "chat_id"})\ + .merge(token_cols, on="df_index", how="left")\ + .drop(columns=["df_index"])\ + .reset_index(drop=True) + + if channel_id: + df = df.loc[df["chat_id"] == channel_id].reset_index(drop=True) + + return df.copy() diff --git a/status_sdk/utils.py b/status_sdk/utils/external.py similarity index 95% rename from status_sdk/utils.py rename to status_sdk/utils/external.py index 73a9f3c..59325a8 100644 --- a/status_sdk/utils.py +++ b/status_sdk/utils/external.py @@ -1,8 +1,11 @@ +""" +Used outside of `status_sdk` +""" import shutil, os, subprocess, sys, time, yaml from pathlib import Path from typing import Optional -from .logger import Logger -from . import exceptions +from ..logger import Logger +from .. import exceptions def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, platform: str = "linux/amd64", data_folder: Optional[str] = None): """ @@ -29,7 +32,7 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, logger.info(f"Running Docker on {system}") ref = commit if commit else "develop" - DOCKER_COMPOSE_PATH = os.path.join(os.path.dirname(__file__), "docker-compose.yaml") + DOCKER_COMPOSE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docker-compose.yaml") # Docker is reached through WSL on Windows, so local paths are passed as `/mnt//...` to_docker_path = lambda path: f"/mnt/{Path(path).drive.rstrip(':').lower()}/" + "/".join(Path(path).parts[1:]) if is_windows else path docker_path = to_docker_path(DOCKER_COMPOSE_PATH) From 3c96ca570ae27732b968e3b34cd256b2fc4a8d29 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Tue, 8 Sep 2026 20:05:43 +0300 Subject: [PATCH 07/13] account: Accept contact request - Accept incoming contact requests - Related to https://github.com/status-im/status-python-sdk/issues/53 --- docs/account.md | 15 +++++---------- status_sdk/account.py | 9 +++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/account.md b/docs/account.md index 62a7fff..d2da092 100644 --- a/docs/account.md +++ b/docs/account.md @@ -719,20 +719,14 @@ for mention in account.listen_message_mentions(): print(mention) ``` -#### `add_contact(public_key, display_name=None)` +#### `add_contact(public_key, request_id=None, display_name=None)` -Send a contact request or approve an existing contact request. The mode depends on how the contact shows up in [`contacts`](./account.md#contacts). Best practice would be to look at the the following [`contacts`](./account.md#contacts) keys: - -- `has_added_us` - `bool` value to check if the other user has added the account as a friend -- `added` - `bool` value to check if the account has added the other user as a friend -- `mutual` - `bool` value to check if the account and other user are in contacts -- `contact_state` - `str` value to see the account's current state -- `external_contact_state` - `str` value to see the other user's state as it is in your node +Send a contact request or approve an existing contact request. Modes: -- **Approve mode** - `has_added_us` is `True` and `added` is `False` -- **Add mode** - `has_added_us` is `False` +- **Approve mode** - use `public_key` and `request_id` +- **Add mode** - `public_key` and `display_name`. Display name can be omitted if the account shows up in [`contacts`](./account.md#contacts) The contact can be identified in three different ways, so you can pass whichever value you have at hand - the public key, the chat key as shown in Status App, or the profile link a user shares with you: @@ -747,6 +741,7 @@ When an account URL is passed, the public key is resolved from it automatically | Name | Type | Required | Description | |-----|-----|-----|-------------| | `public_key` | `str` | Yes | The contact's Status **public key** (`0x...`), **chat key** (`zQ...`) or **account URL** (`https://...`). | +| `request_id` | `str` | No | The `id` of an incoming contact request, from the `ContactRequest` yielded by [`listen_contact_requests`](./account.md#listen_contact_requests). Passing it switches the method into **approve mode** - the pending request is accepted instead of a new one being sent, and `display_name` is not used at all. Omit it to send a **new** contact request. | | `display_name` | `str` | Yes / No | Display name for the contact. If the contact already exists in [`contacts`](./account.md#contacts), the `display_name` parameter is optional and the existing name will be reused. If the contact has **never interacted with the bot before**, `display_name` must be provided so the contact can be created locally. | Returns the current `Account` instance, allowing method chaining. diff --git a/status_sdk/account.py b/status_sdk/account.py index 2eec68d..95ccd73 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -958,6 +958,7 @@ def get_messages(self, chat_id: str, start_timestamp: Optional[Union[str, dateti return all_messages def add_contact(self, public_key: str, display_name: Optional[str] = None): + def add_contact(self, public_key: str, request_id: Optional[str] = None, display_name: Optional[str] = None): """ Send a contact request / approve a contact. @@ -970,6 +971,14 @@ def add_contact(self, public_key: str, display_name: Optional[str] = None): if public_key == self.info["public_key"]: return self + if request_id: + params = [{"id": request_id, "contactID": public_key}] + response = self._call_rpc("messaging", "acceptContactRequest", params) + if response.get("error"): + raise exceptions.InvalidContactError(response["error"]["message"]) + + return self + if display_name: self.__validate_display_name(display_name) From bdde9601b650eec4363ac50297cbf760982cba5d Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Tue, 8 Sep 2026 20:07:32 +0300 Subject: [PATCH 08/13] account: Listen for removed requests - Related to https://github.com/status-im/status-python-sdk/issues/54 --- docs/account.md | 11 +++++++---- status_sdk/account.py | 32 +++++++++++++++++++++++--------- status_sdk/models.py | 6 ++++-- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/docs/account.md b/docs/account.md index d2da092..31b9c50 100644 --- a/docs/account.md +++ b/docs/account.md @@ -661,12 +661,13 @@ for msg in account.listen_messages(): #### `listen_contact_requests()` -Listen for contact requests **in real time**. Both **incoming** contact requests sent to the account and contact requests sent by the account that were **accepted** by the other user are yielded. Every yielded event carries a `request_type` key that tells the two apart: +Listen for contact requests **in real time**. . Every yielded event carries a `request_type` key that tells them apart: | `request_type` | Meaning | |-----|-----| -| `incoming` | Another user sent a contact request to the account. Approve it with [`add_contact`](./account.md#add_contactpublic_key-display_namenone). | +| `incoming` | Another user sent a contact request to the account. Approve it with [`add_contact`](./account.md#add_contactpublic_key-display_namenone). Use `id` and `public_key` properties from `ContactRequest` | | `accepted` | Another user accepted a contact request that the account had sent. The contact is now mutual. | +| `removed` | When a user has removed the account from their contacts. | ```python from status_sdk import Account @@ -695,10 +696,12 @@ params = { account.login(**params) for request in account.listen_contact_requests(): - if request["request_type"] == "incoming": + if request.incoming: print("New contact request received") - elif request["request_type"] == "accepted": + elif request.accepted: print("Contact request was accepted") + elif request.removed: + print(f"{request.public_key} has removed you") ``` #### `listen_message_mentions()` diff --git a/status_sdk/account.py b/status_sdk/account.py index 95ccd73..ddf405b 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -839,22 +839,36 @@ def listen_contact_requests(self) -> Generator[models.ContactRequest, None, None """ Listen for incoming contact requests and for contact requests that were accepted. Can be used for real time processing. """ - accepted_contact_request = re.compile(r"@(0x04[0-9a-fA-F]{128}) accepted your contact request") + key_mapping = { + 16: "accepted", + 17: "removed" + } for message in self.signal.listen(["local-notifications", "messages.new"]): event: dict = message.get("event", {}) if message["type"] == "local-notifications": category = event.get("category") if category == "contactRequest": - yield models.ContactRequest(message["event"]["body"]["message"]["from"], incoming=True) - - if message["type"] == "messages.new" and accepted_contact_request.search(str(message)): - for public_key in set(accepted_contact_request.findall(str(message))): - - if self.info["public_key"] == public_key: + yield models.ContactRequest( + message["event"]["body"]["message"]["id"], + message["event"]["body"]["message"]["from"], + incoming=True + ) + + if message["type"] == "messages.new" and "messages" in message["event"]: + messages: list[dict] = message["event"]["messages"] + for message in messages: + public_key: str = message["from"] + + if self.info["public_key"] == public_key or message["contentType"] not in key_mapping: continue - yield models.ContactRequest(public_key, accepted=True) + params = { + "id": message["id"], + "public_key": public_key, + key_mapping[message["contentType"]]: True + } + yield models.ContactRequest(**params) def listen_message_mentions(self) -> Generator[models.Message, None, None]: """ @@ -957,13 +971,13 @@ def get_messages(self, chat_id: str, start_timestamp: Optional[Union[str, dateti return all_messages - def add_contact(self, public_key: str, display_name: Optional[str] = None): def add_contact(self, public_key: str, request_id: Optional[str] = None, display_name: Optional[str] = None): """ Send a contact request / approve a contact. Parameters: - `public_key` - the contact's public key / chat key / URL + - `request_id` - the `id` from `ContactRequest` when using `listen_contact_request`. If not provided the user will be sent a friend request. If provided the user's request will be accepted. - `display_name` - this field is required if the `public_key` does not appear in your contacts. This will set their display name (can be different from the one the other user has chosen) """ public_key = self.get_public_key(public_key) diff --git a/status_sdk/models.py b/status_sdk/models.py index 254f399..e179918 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -4,13 +4,15 @@ @dataclass class ContactRequest: + id: str public_key: str incoming: bool = False accepted: bool = False + removed: bool = False def __post_init__(self): - if not (self.incoming or self.accepted): - raise ValueError("A ContactRequest must be `incoming` or `accepted`") + if not (self.incoming or self.accepted or self.removed): + raise ValueError(f"A {self.__class__.__name__} must be `incoming`, `accepted` or `removed`") @dataclass class PaymentRequest: From 26a463ac27fdcc3ad57076ef8d769dc2c86c00af Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Tue, 8 Sep 2026 14:27:29 +0300 Subject: [PATCH 09/13] bug: Invalid Docker ENV parameters Co-Authored-By: Andrey Bocharnikov --- README.md | 2 +- status_sdk/docker-compose.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f43b7f7..8e78a63 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ The compose file reads three variables from the environment. All of them have a | Variable | Default | Description | |-----|-----|-------------| | `STATUS_GO_COMMIT` | `develop` | The [`status-im/status-go`](https://github.com/status-im/status-go/) git ref (commit SHA, branch or tag) to build from. | -| `STATUS_GO_PLATFORM` | `linux/amd64` | The platform the image is built for. | +| `PLATFORM` | `linux/amd64` | The platform the image is built for. | ``` STATUS_GO_COMMIT=2bee8b6a38cdc8f92d74e2dbb8c4e77fbbeea149 PLATFORM=linux/amd64 docker compose -f status_sdk/docker-compose.yaml up -d diff --git a/status_sdk/docker-compose.yaml b/status_sdk/docker-compose.yaml index 449f9fe..79b6b21 100644 --- a/status_sdk/docker-compose.yaml +++ b/status_sdk/docker-compose.yaml @@ -1,8 +1,8 @@ services: backend: build: - context: https://github.com/status-im/status-go.git#${STATUS_GO_REF:-develop} - platform: ${STATUS_GO_PLATFORM:-linux/amd64} + context: https://github.com/status-im/status-go.git#${STATUS_GO_COMMIT:-develop} + platform: ${PLATFORM:-linux/amd64} container_name: status-backend ports: - 8080:8080 # backend_port From 500e4d699760a3f219d3e1d7c474ecd3096a0263 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Wed, 9 Sep 2026 13:24:25 +0300 Subject: [PATCH 10/13] bot: Send bridged messages --- docs/account.md | 63 +++++++++++++++++++++++++++++++++ docs/community.md | 42 ++++++++++++++++++++++ docs/group-chat.md | 40 +++++++++++++++++++++ status_sdk/account.py | 34 +++++++++++++++++- status_sdk/community/channel.py | 20 ++++++++++- status_sdk/group_chat.py | 18 ++++++++++ status_sdk/models.py | 34 ++++++++++++++++-- 7 files changed, 247 insertions(+), 4 deletions(-) diff --git a/docs/account.md b/docs/account.md index 31b9c50..d7aa203 100644 --- a/docs/account.md +++ b/docs/account.md @@ -515,6 +515,69 @@ account.send_image( ) ``` +#### `send_bridged_message(chat_id, message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into a Status chat. Status App renders it as a **bridged message** - the original author's name and avatar are shown, with the platform it came from, rather than the message appearing to come from the bot account itself. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `chat_id` | `str` | Yes | Identifier of the chat where the message will be sent. All available chat IDs come from the [`chats`](./account.md#chats) property. | +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id. Unlike in [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`send_image`](./account.md#send_imagechat_id-file_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. Passing a Status id will not thread. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, which is a different value from the `message_id` you passed in. It can be used with [`delete_message`](./account.md#delete_messageid) like any other sent message. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +chat = account.chats[0] + +status_id = account.send_bridged_message( + chat_id=chat["id"], + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + +Threading a reply that happened on the other platform: + +```python +account.send_bridged_message( + chat_id=chat["id"], + message="Yes, works on my machine", + name="Discord", + username="alex", + user_id="481920374651829301", + message_id="1180937812345678901", + # The Discord id of the message being replied to, relayed earlier + reply_to_message_id="1180937465829183498" +) +``` + +**Note**: `user_id` and `message_id` both fall back to a **freshly generated UUID** when omitted. + +1. Without `user_id`, every relayed message looks like it came from a different author even when the same person sent them all; +2. without `message_id`, nothing can ever reply to that message, because the id it would need to reference is discarded. A real bridge should pass both. + +The same method is available on [`GroupChat`](./group-chat.md#send_bridged_messagemessage-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) and on a community [`Channel`](./community.md#send_bridged_messagemessage-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) without the `chat_id` argument, since those already know their own chat. + #### `send_emoji_reaction(message_id, emoji_shortname, chat_id=None)` React to a message with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. diff --git a/docs/community.md b/docs/community.md index 54a636f..39858d8 100644 --- a/docs/community.md +++ b/docs/community.md @@ -1276,6 +1276,48 @@ message_id = channel.send_image("./meme-67.png", "Daily random meme") print(f"Sent image: {message_id}") ``` +### `send_bridged_message(message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into the channel. Status App renders it as a **bridged message**: the original author's name and avatar are shown, along with the platform it came from, instead of the message appearing to come from the bot account. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id, unlike in [`send_message`](./community.md#send_messagemessage-reply_to_message_idnone) and [`send_image`](./community.md#send_imagefile_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, delegated from [`send_bridged_message`](./account.md#send_bridged_messagechat_id-message-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) on `Account`. This is a different value from the `message_id` you passed in, and it can be used with [`delete_message`](./community.md#delete_messageid) like any other sent message. Returns `None` when [`can_post`](./community.md#can_post) is `False` - nothing is relayed and no error is raised. + +```python +from status_sdk import Account, Community + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9" +community = Community(account, url=url) + +channel = community["general"] + +status_id = channel.send_bridged_message( + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + ### `send_emoji_reaction(message_id, emoji_shortname)` React to a message in the channel with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. diff --git a/docs/group-chat.md b/docs/group-chat.md index 58e8b76..ec70f4e 100644 --- a/docs/group-chat.md +++ b/docs/group-chat.md @@ -252,6 +252,46 @@ message_id = group_chat.send_image("./meme-67.png", "Daily random meme") print(f"Sent image: {message_id}") ``` +### `send_bridged_message(message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into the group chat. Status App renders it as a **bridged message**: the original author's name and avatar are shown, along with the platform it came from, instead of the message appearing to come from the bot account. Use [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone) when the bot is speaking as itself. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id, unlike in [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone) and [`send_image`](./group-chat.md#send_imagefile_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, delegated from [`send_bridged_message`](./account.md#send_bridged_messagechat_id-message-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) on `Account`. This is a different value from the `message_id` you passed in, and it can be used with [`delete_message`](./group-chat.md#delete_messageid) like any other sent message. + +```python +from status_sdk import Account, GroupChat + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +chat = [chat for chat in account.chats if chat["type"] == "group_chat"][0] +group_chat = GroupChat(account, chat["id"]) + +status_id = group_chat.send_bridged_message( + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + ### `send_emoji_reaction(message_id, emoji_shortname)` React to a message in the group chat with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. diff --git a/status_sdk/account.py b/status_sdk/account.py index ddf405b..e47e8ca 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -677,6 +677,33 @@ def send_message(self, chat_id: str, message: str, reply_to_message_id: Optional """ return self.__send_content(chat_id, message, reply_to_message_id) + def send_bridged_message(self, chat_id: str, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> str: + """ + Forward a message from another messaging platform to the given chat. + + Parameters: + - `chat_id` - the chat ID can be found in `self.chats` + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + content = models.BridgedContent( + message=message, + name=name, + username=username, + user_id=user_id, + reply_to_message_id=reply_to_message_id, + message_id=message_id, + image_url=image_url + ) + return self.__send_content(chat_id, bridged_content=content) def send_emoji_reaction(self, message_id: str, emoji_shortname: str, chat_id: Optional[str] = None): """ @@ -711,7 +738,7 @@ def send_emoji_reaction(self, message_id: str, emoji_shortname: str, chat_id: Op if error: raise exceptions.ChatNotFoundError(error.get("message")) - def __send_content(self, chat_id: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_path: Optional[str] = None) -> str: + def __send_content(self, chat_id: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_path: Optional[str] = None, bridged_content: Optional[models.BridgedContent] = None) -> str: """ Send a message with optional media attached to the given chat. @@ -766,6 +793,11 @@ def validate_path(path: str): content_key = "imagePath" asset_subfolder = "images" + if bridged_content: + msg_params["text"] = "" + msg_params["contentType"] = 18 + msg_params["bridgeMessage"] = bridged_content.status_go_params + if asset_subfolder: docker_file_path.append(asset_subfolder) diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py index a969afb..0d7e573 100644 --- a/status_sdk/community/channel.py +++ b/status_sdk/community/channel.py @@ -269,7 +269,7 @@ def send_message(self, message: str, reply_to_message_id: Optional[str] = None) """ return self.__account.send_message(self.id, message, reply_to_message_id) if self.can_post else None - def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: + def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> Optional[str]: """ Send a image to the group chat. @@ -283,6 +283,24 @@ def send_image(self, file_path: str, message: Optional[str] = None, reply_to_mes """ return self.__account.send_image(self.id, file_path, message, reply_to_message_id) if self.can_post else None + def send_bridged_message(self, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> Optional[str]: + """ + Forward a message from another messaging platform to the Community chat. + + Parameters: + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + return self.__account.send_bridged_message(self.id, message, name, username, user_id, message_id, reply_to_message_id, image_url) if self.can_post else None + def send_emoji_reaction(self, message_id: str, emoji_shortname: str): """ Set / unset emoji reaction for a message in the channel. diff --git a/status_sdk/group_chat.py b/status_sdk/group_chat.py index ee65b6b..2fd1267 100644 --- a/status_sdk/group_chat.py +++ b/status_sdk/group_chat.py @@ -84,6 +84,24 @@ def send_image(self, file_path: str, message: Optional[str] = None, reply_to_mes """ return self.__account.send_image(self.id, file_path, message, reply_to_message_id) + def send_bridged_message(self, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> str: + """ + Forward a message from another messaging platform to the group chat. + + Parameters: + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + return self.__account.send_bridged_message(self.id, message, name, username, user_id, message_id, reply_to_message_id, image_url) + def send_emoji_reaction(self, message_id: str, emoji_shortname: str): """ Set / unset emoji reaction for a message in the group chat. diff --git a/status_sdk/models.py b/status_sdk/models.py index e179918..1a6e56c 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from typing import Self, Optional -import datetime +from typing import Self, Optional, Union +import datetime, uuid @dataclass class ContactRequest: @@ -34,6 +34,36 @@ def from_raw(cls, raw: dict) -> Self: } return cls(**params) +@dataclass +class BridgedContent: + message: str + name: Optional[str] = None + username: Optional[str] = None + user_id: Optional[Union[str, int]] = None + reply_to_message_id: Optional[Union[str, int]] = None + message_id: Optional[Union[str, int]] = None + image_url: Optional[str] = None + + def __post_init__(self): + to_string = lambda value: str(value) if isinstance(value, int) else value + + self.user_id = to_string(self.user_id) + self.reply_to_message_id = to_string(self.reply_to_message_id) + self.message_id = to_string(self.message_id) + + @property + def status_go_params(self) -> dict: + content = { + "bridgeName": self.name or "Unknown", + "userName": self.username or "Anon", + "userAvatar": self.image_url or "", + "userID": self.user_id or str(uuid.uuid4()), + "content": self.message, + "messageID": self.message_id or str(uuid.uuid4()), + "parentMessageID": self.reply_to_message_id or "", + } + return content + @dataclass class Message: id: str From a4c4ae152f26d6c6f0bb97f594c17a2f276a5cb5 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Wed, 9 Sep 2026 13:45:31 +0300 Subject: [PATCH 11/13] bot: Use external loggers --- status_sdk/account.py | 4 +--- status_sdk/logger.py | 24 ------------------------ status_sdk/utils/external.py | 5 ++--- 3 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 status_sdk/logger.py diff --git a/status_sdk/account.py b/status_sdk/account.py index e47e8ca..f4cffa8 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -10,7 +10,6 @@ from PIL.PngImagePlugin import PngImageFile from . import constants, models from .signal import Signal -from .logger import Logger class Account: # Enum mappings from original wakuext.py @@ -75,8 +74,7 @@ def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_po # NOTE: This might change for initial release self.__assets_local_folder = os.path.join(sdk_folder, "assets") os.makedirs(self.__assets_local_folder, exist_ok=True) - - self.__logger = Logger() + self.__logger = logging.getLogger(__name__) self.__timestamp_divisor = 1_000 self.__kd_iterations = 256000 self.__is_messenger_launched = False diff --git a/status_sdk/logger.py b/status_sdk/logger.py deleted file mode 100644 index 5ebc7d0..0000000 --- a/status_sdk/logger.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Optional -import logging - -class Logger: - instance: Optional[logging.Logger] = None - - def __new__(cls, name: str = "status-bot") -> logging.Logger: - - if cls.instance: - return logging.getLogger(name) - - cls.instance = logging.getLogger(name) - cls.instance.setLevel(logging.INFO) - cls.instance.propagate = False - - handler = logging.StreamHandler() - formatter = logging.Formatter( - f"[%(asctime)s] [%(levelname)s]\t%(message)s", - datefmt="%Y-%m-%d %H:%M:%S" - ) - - handler.setFormatter(formatter) - cls.instance.addHandler(handler) - return cls.instance diff --git a/status_sdk/utils/external.py b/status_sdk/utils/external.py index 59325a8..41aacbf 100644 --- a/status_sdk/utils/external.py +++ b/status_sdk/utils/external.py @@ -1,10 +1,9 @@ """ Used outside of `status_sdk` """ -import shutil, os, subprocess, sys, time, yaml +import shutil, os, subprocess, sys, time, yaml, logging from pathlib import Path from typing import Optional -from ..logger import Logger from .. import exceptions def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, platform: str = "linux/amd64", data_folder: Optional[str] = None): @@ -21,7 +20,7 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, - `platform` - the platform the image is built for. Defaults to `linux/amd64`. Run `docker buildx ls` to see the platforms your Docker installation supports. - `data_folder` - the local folder holding the accounts created in Status Backend. Necessary for Community nodes """ - logger = Logger() + logger = logging.getLogger(__name__) system = sys.platform is_windows = system == "win32" if not shutil.which("docker"): From af8f354a9c018980788f12a67afeab7fd0039480 Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Wed, 9 Sep 2026 14:07:41 +0300 Subject: [PATCH 12/13] real-time: Community Request flags --- examples/community-greet/main.py | 6 +++--- status_sdk/community/base.py | 7 ++++++- status_sdk/models.py | 5 ++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/community-greet/main.py b/examples/community-greet/main.py index 758a038..8038136 100644 --- a/examples/community-greet/main.py +++ b/examples/community-greet/main.py @@ -113,15 +113,15 @@ def main(channel_name: str, approve: bool): for request in community.listen_requests(): member_public_key: str = request.public_key - if request.state == "pending" and member_public_key not in pending_requests: + if request.pending and member_public_key not in pending_requests: pending_requests.append(member_public_key) - if approve and request.state == "pending": + if approve and request.pending: community.accept(request.id) account.logger.info(f"Accepted {member_public_key}") continue - if request.state != "accept" or member_public_key not in pending_requests: + if not request.accept or member_public_key not in pending_requests: continue message = generate_message(member_public_key) diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py index 34a9004..0763320 100644 --- a/status_sdk/community/base.py +++ b/status_sdk/community/base.py @@ -301,7 +301,12 @@ def listen_requests(self) -> Generator[models.CommunityRequest, None, None]: if not state: continue - yield models.CommunityRequest(request["id"], state, request["publicKey"]) + params = { + "id": request["id"], + "public_key": request["publicKey"], + state: True + } + yield models.CommunityRequest(**params) @property def categories(self) -> dict[str, str]: diff --git a/status_sdk/models.py b/status_sdk/models.py index 1a6e56c..0789098 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -135,8 +135,11 @@ def from_raw(cls, raw: dict) -> Self: @dataclass class CommunityRequest: id: str - state: str public_key: str + pending: bool = False + reject: bool = False + accept: bool = False + cancel: bool = False @dataclass class TokenPermission: From a649a794f256d73bf0c0da7f69cd9409a9f133ad Mon Sep 17 00:00:00 2001 From: Nick Ninov Date: Wed, 9 Sep 2026 14:08:20 +0300 Subject: [PATCH 13/13] real-time: Bridged message content --- status_sdk/models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/status_sdk/models.py b/status_sdk/models.py index 0789098..7bd1af3 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -119,9 +119,13 @@ def from_raw(cls, raw: dict) -> Self: params["content_type"] = "image" # Bridged Message elif content_type == 18: - params["content"] = raw["bridgeMessage"]["bridgeName"] + params["content"] = raw["bridgeMessage"]["content"] params["content_type"] = "text" params["bridge_id"] = raw["bridgeMessage"]["messageID"] + reply_id = raw["bridgeMessage"].get("parentMessageID") + if isinstance(reply_id, str) and len(reply_id) == 0: + reply_id = None + params["reply_id"] = reply_id payments = raw.get("paymentRequests", []) if payments: