diff --git a/.github/tests/version.py b/.github/tests/version.py
new file mode 100644
index 0000000..414e642
--- /dev/null
+++ b/.github/tests/version.py
@@ -0,0 +1,40 @@
+"""
+Verify that pyproject.toml version is greater than `main` branch want
+"""
+import tomllib
+import urllib.request
+from packaging.version import Version
+from pathlib import Path
+
+def read_version(raw: bytes) -> str:
+ """
+ Extract the `version` from `pyproject.toml`
+ """
+ return tomllib.loads(raw.decode())["project"]["version"]
+
+
+def fetch_current_version() -> str:
+ """
+ Read `pyproject.toml` file from the working tree.
+ """
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
+ return read_version(pyproject.read_bytes())
+
+
+def fetch_base_version(repo: str = "status-im/status-python-sdk", branch: str = "master") -> str:
+ """
+ Fetch `pyproject.toml` file from Github.
+ """
+ url = f"https://raw.githubusercontent.com/{repo}/{branch}/pyproject.toml"
+
+ with urllib.request.urlopen(url, timeout=30) as response:
+ return read_version(response.read())
+
+
+
+if __name__ == "__main__":
+ current_version = Version(fetch_current_version())
+ uploaded_version = Version(fetch_base_version())
+
+ if current_version <= uploaded_version:
+ raise Exception(f"Branch version ({current_version}) must be greater than GitHub `master` ({uploaded_version}). Please update pyproject.toml")
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f4f9293
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,26 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [master]
+ types: [opened, synchronize, reopened]
+
+jobs:
+ version:
+ name: Verify Version Bump
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install packaging
+ run: python -m pip install --upgrade packaging
+
+ - name: Compare versions
+ run: python .github/tests/version.py
diff --git a/README.md b/README.md
index 0c4ac8f..186832c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Status Python SDK
-
+
[Status](http://status.app/) is a decentralized, open-source super app combining a crypto wallet, messenger, and community spaces. It uses peer-to-peer technology so no central server can censor your messages or access your data.
@@ -26,7 +26,9 @@ graph TB
subgraph bot[status-im/status-python-sdk]
- REQUIREMENTS[requirements.txt]
+ GROUP_CHAT[class GroupChat]
+ COMMUNITY[class Community]
+ CHANNEL[class Channel]
SDK[class Account]
SIGNAL[class Signal]
end
@@ -37,6 +39,9 @@ graph TB
INFURA[Infura]
end
+ COMMUNITY --> CHANNEL
+ COMMUNITY <--> |logged in Account| SDK
+ GROUP_CHAT <--> |logged in Account| SDK
SDK --> SIGNAL
SDK --> |Port 8080| RPC
SDK --> |Port 8080| HTTP
@@ -71,6 +76,14 @@ sequenceDiagram
#### Install
+##### [PyPI](https://pypi.org/project/status-sdk/)
+
+```
+pip install status-sdk
+```
+
+##### Locally
+
Clone the repository and move into it:
```
diff --git a/docs/account.md b/docs/account.md
index 05ea879..dae4254 100644
--- a/docs/account.md
+++ b/docs/account.md
@@ -1,6 +1,6 @@
# Account
-
+
The account class allows you to easily work with a Status account.
@@ -46,7 +46,7 @@ If a display name does not follow these rules, a **`ValueError`** will be raised
Backup files (`.bkp`) can be both created in [Status App](https://our.status.im/status-desktop-v2-35-local-backups-new-home-page-performance-boosts-and-more/) and the [Python SDK](./account.md#backup).
-
+
[Status Backend](https://github.com/status-im/status-go) backup folder is exposed in a Docker volume so users can:
@@ -75,14 +75,44 @@ flowchart LR
app <--> shared <--> sdk <--> Vol1
```
-Because the filename is derived from the account's key rather than from whoever wrote it, the same account always maps to the same `.bkp` file - so neither side needs to know which tool produced the backup.
+Because the file name is derived from the account's key rather than from whoever wrote it, the same account always maps to the same `.bkp` file - so neither side needs to know which tool produced the backup.
+## Public keys
+
+Every Status account is identified by **one key**, but that key appears in three different forms depending on where you look at it.
+
+| Format | Example | What it is | Where to find it |
+|-------|--------|-----------|-----------------|
+| **Public key** | `0x04ebcad...` | The full, uncompressed key. This is what Status Backend works with internally, and what the SDK keys its data by. | `public_key` in [`info`](./account.md#info) / [`contacts`](./account.md#contacts) |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | The same key in its compressed form. This is the value Status App shows and what users copy when they share their chat key. | `compressed_key` in [`info`](./account.md#info) / [`contacts`](./account.md#contacts), or the **chat key** in Status App |
+| **Account URL** | `https://status.app/u/...` | A shareable profile link with the chat key embedded in it. This is what **Share profile** produces in Status App. | `url` in [`info`](./account.md#info) / [`contacts`](./account.md#contacts), or **Share profile** in Status App |
+
+Where a list is accepted, the formats can even be **mixed within the same list**, since each value is normalised on its own.
+
+
+
+**Note**: An **account URL** (`https://status.app/u/...`) is not the same as a **community URL** (`https://status.app/c/...`). Community URLs identify a community and belong in the [`Community`](./community.md#communityaccount-community_idnone-urlnone) constructor.
+
## Wallet
Wallet features are optional and can be omitted if not required for your use case. They provide functionality equivalent to the **Wallet** and **Market** tabs.
-
+
+
+## Installation ID
+
+Currently installation IDs can be found in **Debug Mode** only. To turn **Debug Mode**:
+
+
+
+Once **Debug Mode** is turned on and Status App is restarted, you can go to **Syncing** tab.
+
+
+
+The **Installation ID** should be used when calling [`sync`](./account.md#syncinstallation_id-namenone) and [`unsync`](./account.md#unsyncinstallation_id).
+
+
## `Account(domain="localhost", backend_port=8080, media_port=9000, is_secure=False, backup_folder=None, volume_folder=None)`
@@ -145,7 +175,7 @@ account = Account(volume_folder="/path/to/status-python-sdk/status_sdk")
Login to an existing Status account. If the account does not exist in the initialized data directory, a new account will be created and automatically logged in.
-
+
After a successful login, the decentralized messenger service is automatically started so the account can send and receive messages.
@@ -179,7 +209,7 @@ account.login(**params)
The code above is equivalent to the following screen on Status App:
-
+
**Note**: This assumes that `display_name` and is unique for every `key_uid`. If there are duplicated `display_names` then the first found match will be used. You can log in with `key_uid` if you have `display_name` duplicates.
@@ -198,7 +228,7 @@ account.login(**params)
You can purchase a **universal username** on Status App:
-
+
@@ -231,7 +261,7 @@ account.login(**params)
The code above is equivalent to the following screen on Status App:
-
+
**Note**: When in recovery mode, the display name is updated on Status App as well so it is consistent locally and to other users.
@@ -298,16 +328,75 @@ backup_path = account.backup()
print(f"Backup created at: {backup_path}")
```
+### `sync(installation_id, name=None)`
+
+Pair another **device** with the account, so messages, contacts and settings are synced between them. This is the SDK equivalent of **Sync new device** in Status App - useful for running a remotely while keeping the same account on a phone or desktop.
+
+Each device that logs into an account is registered with the backend as an **installation**, identified by an `installation_id`. A device reports its own id under `installation_id` in [`info`](./account.md#info), so pairing means passing the **other** device's id to this method. Both devices must have logged in to the same Status account for the installation to be known to the backend.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `installation_id` | `str` | Yes | The id of the device to pair with. It is the value that device reports under `installation_id` in its own [`info`](./account.md#info). |
+| `name` | `str` | No | The name of the paired device, so it is easier to recognise locally. When omitted, the device keeps whatever name it already has. |
+
+Returns `None`. Passing the logged-in account's **own** `installation_id` is a **no-op**, so a device can safely loop over a list of ids without filtering itself out first.
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# The id the other device reports under `installation_id` in its own `info`
+account.sync("6a2f9c1e-...", "raspberry-pi")
+```
+
+**Note**: [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) **deletes** every installation that is not enabled. A device that was never synced, or that was [unsynced](./account.md#unsyncinstallation_id), is therefore removed on the next login and has to be re-registered by logging in from that device again.
+
+### `unsync(installation_id)`
+
+Stop syncing with a device that was paired with [`sync`](./account.md#syncinstallation_id-namenone). The device stops receiving the account's messages, contacts and settings.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `installation_id` | `str` | Yes | The id of the device to stop syncing with, in the same format accepted by [`sync`](./account.md#syncinstallation_id-namenone). |
+
+Returns `None`. As with [`sync`](./account.md#syncinstallation_id-namenone), passing the account's **own** `installation_id` is a **no-op** - an account cannot unsync itself. A custom exception is raised if the backend rejects the call.
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+account.unsync("6a2f9c1e-...")
+```
+
+**Note**: unsyncing only **disables** the installation, so it can be paired again with [`sync`](./account.md#syncinstallation_id-namenone) within the same session. It does not survive a restart though - the next [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) deletes disabled installations, and the other device has to log in again before it can be synced.
+
### Chat
-#### `send_message(chat_id, message)`
+#### `send_message(chat_id, message, reply_to_message_id=None)`
-Send a text message to a specific chat. This method currently supports **text messages only**.
+Send a text message to a specific chat. This method currently supports **text messages only**. A message can also be sent as a **reply** to an existing message in the same chat, which renders in Status App with the original message quoted above it - the same as replying to a message in the app.
+
+A message can be **at most 2000 characters long**, matching the limit enforced by Status App. Sending a longer message raises a custom exception.
| Name | Type | Required | Description |
|-----|-----|-----|-------------|
| `chat_id` | `str` | Yes | Identifier of the chat where the message will be sent. All available chat IDs can be obtained from the [`chats`](./account.md#chats) property. |
-| `message` | `str` | Yes | The text message to send. |
+| `message` | `str` | Yes | The text message to send. Cannot be longer than **2000 characters**. |
+| `reply_to_message_id` | `str` | No | The `id` of the message being replied to. Message IDs can be obtained from the `id` key of [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone) or from a [`listen_messages`](./account.md#listen_messages) event. When omitted (default), the message is sent as a standalone message. |
+
+Returns `str` - the `id` of the message that was just sent. It is the same identifier that appears under the `id` key in [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone), so it can be passed straight into [`delete_message`](./account.md#delete_messageid) or used as the `reply_to_message_id` of a follow-up message, without having to fetch the chat's messages first.
```python
from status_sdk import Account
@@ -321,7 +410,33 @@ account.login(**params)
# This is under the assumption you already have a contact / joined a community
chat = account.chats[0]
-account.send_message(chat["id"], "Hello from my Status bot!")
+message_id = account.send_message(chat["id"], "Hello from my Status bot!")
+print(f"Sent message: {message_id}")
+```
+
+Reply to a message:
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+chat = account.chats[0]
+
+# Messages are returned newest first, so this is the latest message in the chat
+messages = account.get_messages(chat["id"])
+latest = messages[0]
+
+account.send_message(
+ chat_id=chat["id"],
+ message="Thanks for the update!",
+ reply_to_message_id=latest["id"]
+)
```
#### `get_messages(chat_id, start_timestamp=None, end_timestamp=None)`
@@ -364,6 +479,40 @@ for message in messages:
**Note**: If there are missing messages in a chat that might be because the node (Status Backend) has not received them yet. They may appear later.
+#### `delete_message(id)`
+
+Delete one of your **own** messages from a chat. The deletion is propagated to the other members of the chat, so the message disappears for everybody - the same as deleting a message in Status App.
+
+You can only delete messages that the logged-in account has sent. Messages sent by other accounts cannot be deleted, even in a [group chat](./group-chat.md) where the account is the [administrator](./group-chat.md#administrator).
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `id` | `str` | Yes | The `id` of the message to delete. Message IDs can be obtained from the `id` key of [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone), or directly from the return value of [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone). |
+
+Returns `bool`.
+
+| Value | Meaning |
+|------|--------|
+| `True` | The message was deleted. |
+| `False` | The message was not deleted, because the account does not have permission to delete it. |
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+chat = account.chats[0]
+message_id = account.send_message(chat["id"], "Oops, this was a mistake!")
+
+deleted = account.delete_message(message_id)
+print(f"Deleted: {deleted}")
+```
+
#### `listen_messages()`
Listen for new incoming messages **in real time**. This method yields raw message events as they are received from the Status Backend [signal](./account.md#signallisten) `messages.new`. This method is ideal for developing real time chat applications
@@ -403,9 +552,19 @@ Modes:
- **Approve mode** - `has_added_us` is `True` and `added` is `False`
- **Add mode** - `has_added_us` is `False`
+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:
+
+| Format | Example | Where to find it |
+|-------|--------|-----------------|
+| **Public key** | `0x04ebcad...` | `public_key` in [`contacts`](./account.md#contacts) / [`info`](./account.md#info) |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | `compressed_key` in [`contacts`](./account.md#contacts) / [`info`](./account.md#info), or the **chat key** in Status App |
+| **Account URL** | `https://status.app/u/...` | `url` in [`contacts`](./account.md#contacts) / [`info`](./account.md#info), or **Share profile** in Status App |
+
+When an account URL is passed, the public key is resolved from it automatically before the contact request is sent, so the contact is always added with the same identity regardless of which format you used.
+
| Name | Type | Required | Description |
|-----|-----|-----|-------------|
-| `public_key` | `str` | Yes | The contact's Status public key. |
+| `public_key` | `str` | Yes | The contact's Status **public key** (`0x...`), **chat key** (`zQ...`) or **account URL** (`https://...`). |
| `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.
@@ -423,7 +582,45 @@ account.login(**params)
# Send a contact request
account.add_contact(
public_key="0x04ebcad...",
- display_name="nickninov"
+ display_name="status-enjoyer"
+)
+```
+
+Add a contact with their **chat key**:
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Send a contact request
+account.add_contact(
+ public_key="zQ3shYSHp7...",
+ display_name="status-enjoyer"
+)
+```
+
+Add a contact with their **account URL**:
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Send a contact request
+account.add_contact(
+ public_key="https://status.app/u/...",
+ display_name="status-enjoyer"
)
```
@@ -442,9 +639,19 @@ Modes:
- **Remove** - `has_added_us` is `True` and `added` is `True`
- **Reject mode** - `has_added_us` is `True`
+Just like [`add_contact`](./account.md#add_contactpublic_key-display_namenone), the contact can be identified in three different ways:
+
+| Format | Example | Key in [`contacts`](./account.md#contacts) |
+|-------|--------|-----------------|
+| **Public key** | `0x04ebcad...` | `public_key` |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | `compressed_key` |
+| **Account URL** | `https://status.app/u/...` | `url` |
+
+Whichever format is used, the value is matched against [`contacts`](./account.md#contacts) - so it must belong to a user that has already interacted with the account.
+
| Name | Type | Required | Description |
|-----|-----|-----|-------------|
-| `public_key` | `str` | Yes | The contact's Status public key. This value corresponds to the key used in [`contacts`](./account.md#contacts). |
+| `public_key` | `str` | Yes | The contact's Status **public key** (`0x...`), **chat key** (`zQ...`) or **account URL** (`https://...`). All three values correspond to the ones exposed in [`contacts`](./account.md#contacts). |
Returns `bool`.
@@ -471,17 +678,23 @@ removed = account.remove_contact(contact["public_key"])
print(f"Removed: {removed}")
```
-#### `send_request_community(url)`
+#### `get_public_key(value)`
+
+Normalise any of the three account identifiers into a **public key** (`0x...`). This normalisation is used internally by the library as well, so methods that accept a contact identifier work the same regardless of which format is passed.
-Send a request to join a community using its invitation URL. The method parses the shared Status community URL and submits a join request using the currently logged-in account. The account's [wallet address](./account.md#info) is provided to the community.
+The behaviour depends on the format of `value`:
-**This method works with community invites instead of specific community channel ones. Method is currently unstable.**
+| Format | Example | Behaviour |
+|-------|--------|-----------|
+| **Public key** | `0x04ebcad...` | Returned as is - no backend call is made. |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | Uncompressed by Status Backend into the public key. |
+| **Account URL** | `https://status.app/u/...` | The chat key is parsed out of the URL and then uncompressed into the public key. |
| Name | Type | Required | Description |
|-----|-----|-----|-------------|
-| `url` | `str` | Yes | The shared Status community invitation URL. |
+| `value` | `str` | Yes | The **public key** (`0x...`), **chat key** (`zQ...`) or **account URL** (`https://...`) to resolve. All three values correspond to the `public_key`, `compressed_key` and `url` keys in [`contacts`](./account.md#contacts) / [`info`](./account.md#info). |
-Returns `datetime.datetime` representing when the join request was submitted.
+Returns `str` representing the account's **public key**, always prefixed with `0x`.
```python
from status_sdk import Account
@@ -493,11 +706,36 @@ params = {
}
account.login(**params)
-account.send_request_community(
- "https://status.app/c/community-invite-link"
-)
+# All three return the same public key
+for key in ["public_key", "url", "compressed_key"]:
+ value = account.info[key]
+ print(f"\n{key}: {value}\nkey: {account.get_public_key(value)}\n")
+```
+
+Look up a contact when all you have is a shared profile link:
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+public_key = account.get_public_key("https://status.app/u/...")
+# contacts are keyed by public key
+contact = account.contacts.get(public_key)
+if contact:
+ print(contact["display_name"], contact["contact_state"])
```
+**Note**: An **exception will be raised** when:
+- `value` does not start with `0x`, `zQ` or `http` (`PublicKeyError`)
+- the chat key cannot be uncompressed by Status Backend (`PublicKeyError`)
+- the URL cannot be parsed, or it is a **community / channel URL** rather than an account URL (`InvalidContactError`)
+
### Wallet
#### `get_tokens()`
@@ -1002,6 +1240,7 @@ Provides information about the currently logged-in account. If `login()` has not
| `password` | `str` | Password used to encrypt the account locally. |
| `wallet_address` | `str` | Ethereum wallet address associated with the account. |
| `ens` | `dict` | The account's [ENS](https://status.app/help/profile/transfer-your-ens-name-to-status) details. Contains `preferred_name` (`str` or `None`) - the ENS name the account has chosen to display - and `usernames` (`list[dict]`) - every ENS username registered to the account. Both are empty / `None` when no ENS name is set. |
+| `installation_id` | `str` | Id of **this** device's installation. Pass it to another device's [`sync`](./account.md#syncinstallation_id-namenone) to pair the two. `None` if the backend did not return one. |
| `logged_in_timestamp` | `datetime.datetime` | Timestamp when the account successfully logged in. |
```python
@@ -1050,7 +1289,7 @@ params = {
account.login(**params)
# Change the display name
-account.name = "status_bot_42"
+account.display_name = "status_bot_42"
print(account.display_name)
```
@@ -1152,6 +1391,54 @@ account.profile_picture.show()
When a new profile picture is set, any previous image in the **assets** folder is removed. The image is also copied into the Status Backend Docker volume so it is picked up by the backend when updating the account identity.
+### `status`
+
+Get or update the **presence status** of the currently logged‑in account. This is the same presence indicator shown next to the account in Status App, and it controls how the account appears to other users.
+
+Returns `str` when reading the property - one of the options below. After a successful [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone), the status is automatically set to `on`.
+
+The value is **case‑insensitive** and must be one of the following options:
+
+| Option | Description |
+|-------|-------------|
+| `on` | **Always online**. The account is shown as online to other users. This is the default after login. |
+| `auto` | **Automatic**. Status App decides the presence automatically based on activity. |
+| `dnd` | **Do Not Disturb**. The account is shown as do not disturb. **This is experimental**. |
+| `off` | **Inactive**. The account is shown as offline / inactive to other users. |
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Read the current status
+print(account.status)
+```
+
+You can update the status by assigning a new value:
+
+```python
+from status_sdk import Account
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Update the presence status
+account.status = "off"
+print(account.status)
+```
+
+**Note**: Assigning any value other than `on`, `auto`, `dnd` or `off` raises a custom exception. The comparison is case‑insensitive, so `ON` and `on` are equivalent.
+
### `signal`
The property exists in `Account` because signals require an **active logged‑in session**. Attempting to use signals before calling `login()` will raise an exception. Signals are low‑level events emitted by the Status Backend.
@@ -1206,7 +1493,7 @@ from status_sdk import Account
account = Account()
-account.logger.info("Starting Status bot")
+print("Starting Status bot")
account.logger.warning("This is a warning")
account.logger.error("Something went wrong")
```
@@ -1228,7 +1515,7 @@ The property always fetches the latest state directly from the Status Backend. T
- `received` - request received from another account
- `mutual` - both users have added each other
-Returns `dict[str, dict]` where the key is the contact's **public key**. This makes internal searching for account specific information faster.
+Returns `dict[str, dict]` where the key is the contact's **public key**. This makes internal searching for account specific information faster. If you only have a contact's **chat key** or **account URL**, pass it through [`get_public_key`](./account.md#get_public_keyvalue) to get the key used in this property.
| Key | Type | Description |
|----|----|-------------|
@@ -1264,14 +1551,14 @@ for contact in contacts.values():
#### `communities`
-Get all communities that the account is currently a member of. This property always fetches the **latest community state** directly from the Status Backend. This ensures dynamic values such as community metadata, members, and channel permissions are always up to date.
+Get all communities that the account is currently a member of. This property always fetches the **latest community state** directly from the Status Backend, so dynamic values such as community metadata and member count are always up to date.
Each community contains information about:
-- community metadata (name, description, tags)
+- community metadata (name, tags)
- membership status
- number of members
-- available channels and their permissions
+- every channel in the community, with the account's permissions on it
Returns `list[dict]` where each element represents a community.
@@ -1281,38 +1568,35 @@ Returns `list[dict]` where each element represents a community.
| `url` | `str` | The URL that can be shared with other users. |
| `name` | `str` | Name of the community. |
| `verified` | `bool` | Whether the community is verified. |
-| `description` | `str` | Community description. |
-| `dialog` | `str` | Intro message shown when joining the community. |
-| `leaving_message` | `str` | Message shown when leaving the community. |
| `tags` | `list[str]` | Tags associated with the community. |
| `is_member` | `bool` | Whether the account is currently a member of the community. |
-| `joined_timestamp` | `datetime.datetime` | Timestamp when the account joined the community. |
-| `requested_timestamp` | `datetime.datetime` | Timestamp when the join request was submitted. |
+| `joined` | `bool` | Whether the account has joined the community. |
+| `joined_timestamp` | `datetime.datetime`
`None` | Timestamp when the account joined the community. `None` when the account has not joined. |
+| `requested_timestamp` | `datetime.datetime`
`None` | Timestamp when the join request was submitted. `None` when no request was made. |
| `encrypted` | `bool` | Whether the community messaging is encrypted. |
| `members` | `int` | Total number of community members. |
-| `channels` | `list[dict]` | List of channels available in the community. |
+| `channels` | `list[dict]` | Every channel in the community. See [channels](./account.md#channels) below. |
-Each channel contains:
+##### `channels`
+
+Each entry of `channels` describes one channel and what the account is allowed to do in it.
| Key | Type | Description |
|----|----|-------------|
-| `id` | `str` | Channel identifier inside the community. |
-| `chat_id` | `str` | Combined community + channel ID used for sending messages. |
-| `url` | `str` | The URL that can be shared with other users. |
-| `name` | `str` | Channel name. |
-| `description` | `str` | Channel description. |
-| `permissions` | `dict` | Permissions for the channel. |
+| `id` | `str` | The channel's own id, **without** the community id in front of it. |
+| `chat_id` | `str` | The community id and channel id joined together. **This is the value to pass** to [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone) - `id` on its own will not work. |
+| `name` | `str` | The channel name, as shown in Status App. |
+| `description` | `str` | The channel description. |
+| `permissions` | `dict` | What the account can do in the channel - see below. |
-Channel `id` values can be used directly with [`send_message`](./account.md#send_messagechat_id-message)
-
-Channel permissions:
+`permissions` holds four booleans:
| Key | Type | Description |
|----|----|-------------|
-| `posting` | `bool` | Whether the account can post messages in the channel. |
-| `viewing` | `bool` | Whether the account can view messages in the channel. |
-| `reactions` | `bool` | Whether the account can react to messages. |
-| `token_gated` | `bool` | Whether the channel requires a token to participate. |
+| `posting` | `bool` | Whether the account can send messages to the channel. [`chats`](./account.md#chats) only lists channels where this is `True`. |
+| `viewing` | `bool` | Whether the account can read the channel. |
+| `reactions` | `bool` | Whether the account can post emoji reactions. |
+| `token_gated` | `bool` | Whether access to the channel is gated behind a token. |
```python
from status_sdk import Account
@@ -1326,45 +1610,23 @@ account.login(**params)
for community in account.communities:
print(community["name"], community["members"])
-
- for channel in community["channels"]:
- print(f"\t#{channel['name']} posting: {channel['permissions']['posting']}")
```
-#### `community_members`
-
-Get member information for all visible communities that the account is in. It can be useful to review community membership, identify suspicious profiles, or filter genuine community members. For each community member, an additional RPC call is made to fetch profile information such as `display_name`, `bio` and `url`. This can make the property slower for larger communities.
-
-Returns `pd.DataFrame`.
-
-| Column | Type | Description |
-|--------|------|-------------|
-| `community_id` | `str` | Unique identifier of the community. |
-| `community_name` | `str` | Name of the community that the member belongs to. |
-| `public_key` | `str` | Public key that uniquely identifies the community member. |
-| `chat_id` | `str` | Chat identifier used when sending messages. |
-| `display_name` | `str` | Current display name of the member. If unavailable, a fallback name is generated from the compressed key and Status URL. |
-| `url` | `str` | Shareable Status profile URL for the member. |
-| `bio` | `str` | Profile bio of the member, if available. |
-| `roles` | `list[int]` | Roles that the community member has. |
-| `compressed_key` | `str` | The member's compressed chat key as shown in Status App. |
-| `emoji_hash` | `str` | The member's compressed chat key as shown in Status App. |
-| `status_alias` | `str` | Initial display name of the member when the account was created. |
+Find every channel the account can post in, without going through [`chats`](./account.md#chats):
```python
-from status_sdk import Account
-
-account = Account()
-params = {
- "name": "status-app-bot",
- "password": "SNTPUMP"
-}
-account.login(**params)
+for community in account.communities:
+ for channel in community["channels"]:
+ if not channel["permissions"]["posting"]:
+ continue
-members = account.community_members
-print(community_members.head().to_markdown(index=False))
+ print(f"{community['name']} #{channel['name']}\t{channel['chat_id']}")
```
+**Note**: To work with a community's channels, members and settings, wrap its `id` in the [`Community`](./community.md) class - for example `Community(account, community["id"])`. `communities` is a read-only snapshot: it lists the channels but cannot create, edit or delete them.
+
+**Note**: `joined` currently returns the same value as `verified`, because [`communities`](../status_sdk/account.py#L498) reads `community["verified"]` for both. Use `is_member` to check membership until that is fixed.
+
#### `chats`
Get all chats that the account can **send messages to**. This includes:
@@ -1372,7 +1634,7 @@ Get all chats that the account can **send messages to**. This includes:
- [`communities`](./account.md#communities) - community channels where the account has **posting permission**
- Group chats that the account is in
-Returns `list[dict]` where each `dict` represents a chat that can be used with [`send_message`](./account.md#send_messagechat_id-message) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone).
+Returns `list[dict]` where each `dict` represents a chat that can be used with [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone).
| Key | Type | Description |
|----|----|-------------|
diff --git a/docs/community.md b/docs/community.md
new file mode 100644
index 0000000..a236944
--- /dev/null
+++ b/docs/community.md
@@ -0,0 +1,1178 @@
+# Community
+
+
+
+The community class lets you work with a [Status Community](https://status.app/help/communities) and its channels. A [`Community`](./community.md#communityaccount-community_idnone-urlnone) is always bound to a logged-in [`Account`](./account.md), and each of its channels is exposed as a [`Channel`](./community.md#channel).
+
+- [`Community`](./community.md#communityaccount-community_idnone-urlnone) - manages membership (members, join requests, bans) and the community's channels.
+- [`Channel`](./community.md#channel) - manages a single channel - its identity (name, description, emoji, colour) and messaging.
+
+You never construct a `Channel` directly. Instead you [create one](./community.md#create_channelname-description-emojinone-colournone-category_namenone) or fetch an existing one by name with [subscript access](./community.md#fetching-a-channel).
+
+## Membership
+
+
+As of now `Community` works with already created Status App communities. To get started, please read [**Create your community**](https://status.app/help/communities#create-your-community). A `Community` can be created two ways:
+
+- **By id** - wrap a community the account is **already a member of**, using its `community_id`.
+- **By invite URL** - pass a shared community `url`.
+
+
+If the account is already a member, the community is ready to use. Otherwise a **join request is sent** and the instance is left unusable until an administrator accepts it (see [Joining a community](./community.md#joining-a-community)).
+
+Only members can read a community's state, and only privileged members (owner / admin / token master) can [ban](./community.md#banpublic_keys-delete_messagesfalse), [accept](./community.md#acceptpending_request_id) or manage channels.
+
+## Roles
+
+Every member carries one or more **roles**, returned by [`get_members`](./community.md#get_membersdataframefalse). The raw `dict` form exposes the backend's numeric codes, while the `DataFrame` form resolves them to the names below.
+
+| Code | Name | Description |
+|-----|-----|-------------|
+| `0` | `none` | A regular member. Can read and post, but cannot manage the community. |
+| `1` | `owner` | The community's owner. Full control over members, channels and settings. |
+| `4` | `admin` | Can manage members (ban, kick, accept, decline) and channels. |
+| `5` | `token_master` | Manages the community's tokens and token-gated permissions. |
+
+**Note**: the backend **omits** the `roles` key entirely for regular members - `0` / `none` is the fallback applied by the SDK, so it shows up in the `DataFrame` but never in the raw payload. Only the codes above are recognised; a member carrying any other code cannot be resolved by [`get_members(dataframe=True)`](./community.md#get_membersdataframefalse).
+
+## `Community(account, community_id=None, url=None)`
+
+Create a `Community` instance bound to a **logged-in** [`Account`](./account.md). Provide **either** `community_id` **or** `url`.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `account` | `Account` | Yes | A **logged-in** [`Account`](./account.md). If the account is not logged in, a custom exception is raised. |
+| `community_id` | `str` | No* | The id of a community the account is **already a member of**. Community ids can be obtained from [`communities`](./account.md#communities) on `Account`. |
+| `url` | `str` | No* | A shared community invite URL. Used to join the community if the account is not already a member. See [Joining a community](./community.md#joining-a-community). |
+
+Wrap a community the account is already in:
+
+```python
+from status_sdk import Account, Community
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Community ids come from the account's communities
+community_id = account.communities[0]["id"]
+community = Community(account, community_id)
+
+print(community.id)
+```
+
+URL initialization:
+
+```python
+from status_sdk import Account, Community
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# Community ids come from the account's communities
+url = "https://status.app/c/G3QAAMQn9ueHRsR3W5Ouuy25fkCxziknAIEkCbYAoC04HjyGeQ6X8k45q3GVeyZiksbd38tQ4S_EfhrJKhRV3sDvjhmrCuSoDBIf2QJiEKwAOZipxis8ntNRVyPhC5IoWaEsj9X4P5zw093pcLofZzTV2gM=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9"
+community = Community(account, url=url)
+
+print(community.id)
+```
+
+When either `url` or `community_id` is provided, the constructor acts based on the account's membership current status:
+
+- **Already a member** - the community is ready to use immediately.
+- **Not a member** - a **join request is sent** on your behalf (revealing the account's wallet address), and the instance is left unusable until an administrator [accepts](./community.md#acceptpending_request_id) it.
+- **Request pending** - a warning is logged and the instance is left unusable until the request is accepted.
+
+**Note**: While a request is pending or has just been sent, the community's [`id`](./community.md#id) is unset and accessing it raises a custom exception. Re-create the `Community` by id once the request has been accepted.
+
+## Methods
+
+### `get_members(dataframe=False)`
+
+The current members of the community, returned in one of two shapes.
+
+By default a **raw `dict`** is returned as it comes back from the backend, keyed by public key. This costs a single call, so it is the shape to reach for in membership checks, lookups and bots where speed matters. Passing `dataframe=True` instead returns an enriched `pd.DataFrame` that resolves each member's contact details and profile URL - that costs **two additional calls per member**. It can be used for reporting and data pipelines rather than instant checks.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `dataframe` | `bool` | No | When `False` (the default), a raw `dict` keyed by public key is returned. When `True`, an enriched `pd.DataFrame` is returned. |
+
+#### Raw `dict` - `dataframe=False`
+
+Returns `dict[str, dict]`, keyed by the member's **public key**. An empty `dict` is returned when there are no members. Each value is the backend's member payload:
+
+| Key | Type | Description |
+|----|----|-------------|
+| `compressedKey` | `str` | The member's compressed chat key as shown in Status App. |
+| `emojiHash` | `list[str]` | The member's emoji identicon - a list of individual emojis, **not** a single string. Entries can be multi-codepoint (skin tones, ZWJ sequences), e.g. `🧑🏾✈️`. |
+| `alias` | `str` | The member's initial (generated) name, e.g. `Carefree Joyful Bushviper`. |
+| `colorId` | `int` | The id of the colour Status App assigns to the member's identicon. |
+| `last_update_clock` | `int` | The logical clock of the member's last update. |
+| `roles` | `list[int]` | The member's [role](./community.md#roles) codes - `1` owner, `4` admin, `5` token master. **Absent for regular members**, so read it with `member.get("roles", [0])`. |
+
+**Note**: this is the unmodified backend payload, so its keys are inconsistently cased (`compressedKey` next to `last_update_clock`), keys can be missing per member, and further keys may be present. The [`DataFrame`](./community.md#pddataframe---dataframetrue) form is the stable, documented shape.
+
+#### `pd.DataFrame` - `dataframe=True`
+
+Returns `pd.DataFrame`, one row per member. An empty `DataFrame` is returned when there are no members.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `public_key` | `str` | Public key that uniquely identifies the member. |
+| `chat_id` | `str` | Chat identifier used for direct messaging. |
+| `compressed_key` | `str` | The member's compressed chat key as shown in Status App. |
+| `emojis` | `list[str]` | The member's emoji identicon, passed through from `emojiHash` as a list of individual emojis. |
+| `display_name` | `str` | The member's display name. Members without one are shown as a short key + Status URL fragment. |
+| `alias` | `str` | The member's initial (generated) name. |
+| `roles` | `list[str]` | The member's [roles](./community.md#roles), resolved to their names. |
+| `bio` | `str` | The member's profile bio. |
+| `url` | `str` | Shareable Status profile URL for the member. |
+
+```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)
+
+# Raw dict - one call, keyed by public key
+for public_key, member in community.get_members().items():
+ print(public_key, member["alias"])
+
+# DataFrame - enriched, for data pipelines
+members = community.get_members(dataframe=True)
+print(members[["display_name", "roles"]].to_markdown(index=False))
+```
+
+
+
+### `ban(public_keys, delete_messages=False)`
+
+Ban one or more members from the community. Banned members appear in [`banned_members` property](./community.md#banned_members). A custom exception is raised if none of the provided public keys belong to the community.
+
+Each member 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:
+
+| Format | Example | Where to find it |
+|-------|--------|-----------------|
+| **Public key** | `0x04ebcad...` | The keys of [`get_members()`](./community.md#get_membersdataframefalse), or `public_key` in its `DataFrame` form |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | `compressedKey` in [`get_members()`](./community.md#get_membersdataframefalse), or the **chat key** in Status App |
+| **Account URL** | `https://status.app/u/...` | `url` in [`get_members(dataframe=True)`](./community.md#get_membersdataframefalse), or **Share profile** in Status App |
+
+Every value is normalised into the public key with [`get_public_key`](./account.md#get_public_keyvalue) before it is matched against the community's members, so the formats can be **mixed within the same list**.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to ban. A single value can be passed as a `str`. Current members can be obtained from [`get_members`](./community.md#get_membersdataframefalse). |
+| `delete_messages` | `bool` | No | When `True`, all messages sent by the banned members are also deleted. Defaults to `False`. |
+
+```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)
+
+member = next(iter(community.get_members()))
+community.ban(member, delete_messages=True)
+```
+
+
+
+---
+
+
+
+### `unban(public_keys)`
+
+Unban one or more previously banned members.
+
+Each member can be identified by their **public key**, **chat key** or **account URL**, and the formats can be mixed within the same list.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to unban. A single value can be passed as a `str`. Banned members can be obtained from [`banned_members` properties](./community.md#banned_members). |
+
+```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)
+
+community.unban(community.banned_members)
+```
+
+
+
+---
+
+
+
+
+### `kick(public_keys)`
+
+Remove one or more members from the community. Unlike [`ban`](./community.md#banpublic_keys-delete_messagesfalse), a kicked member is **not** added to [`banned_members`](./community.md#banned_members) and can request to join again. A custom exception is raised if none of the provided public keys belong to the community.
+
+Each member can be identified by their **public key**, **chat key** or **account URL**, and the formats can be mixed within the same list.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to remove. A single value can be passed as a `str`. Current members can be obtained from [`get_members`](./community.md#get_membersdataframefalse). |
+
+```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)
+
+member = next(iter(community.get_members()))
+community.kick(member)
+```
+
+
+
+---
+
+
+
+### `accept(pending_request_id)`
+
+Accept a pending join request. Members waiting to be accepted are found in [`pending_members`](./community.md#pending_members). A custom exception is raised if `pending_request_id` is not a pending (or declined) join request.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `pending_request_id` | `str` | Yes | The `request_id` of a member from [`pending_members`](./community.md#pending_members). |
+
+```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)
+
+for member in community.pending_members:
+ community.accept(member["request_id"])
+```
+
+
+
+---
+
+
+
+### `decline(pending_request_id)`
+
+Decline a pending join request. Declined members appear in [`declined_members`](./community.md#declined_members).
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `pending_request_id` | `str` | Yes | The `request_id` of a member from [`pending_members`](./community.md#pending_members). |
+
+```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)
+
+member = community.pending_members[0]
+community.decline(member["request_id"])
+```
+
+
+
+---
+
+
+
+### `leave()`
+
+Leave the community. After leaving, the `Community` instance can no longer be used - its [`id`](./community.md#id) is unset and accessing it raises a custom exception. Re-create the `Community` (by id or url) if you rejoin.
+
+```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)
+
+community.leave()
+```
+
+### `create_channel(name, description, emoji=None, colour=None, category_name=None)`
+
+Create a new channel in the community. Returns the created [`Channel`](./community.md#channel). An unknown `category_name` is ignored and the channel is created without a category. Channel creation raises a custom exception if the backend rejects it, and a separate one when a channel with that `name` **already exists** in the community - so a duplicate can be caught on its own and the existing channel [fetched](./community.md#fetching-a-channel) instead.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `name` | `str` | Yes | The channel name. Must follow the [channel name](./community.md#channel-name) rules. |
+| `description` | `str` | Yes | The channel description. Must follow the [channel description](./community.md#channel-description) rules. |
+| `emoji` | `str` | No | A single emoji for the channel. When omitted, a random default emoji is chosen. See [channel emoji](./community.md#channel-emoji). |
+| `colour` | `str` | No | The channel colour as a hex code, e.g. `#4360DF`. When omitted, a random default colour is chosen. See [channel colour](./community.md#channel-colour). |
+| `category_name` | `str` | No | The name of an existing category (from [`categories`](./community.md#categories)) to place the channel under. When omitted, the channel is uncategorised. |
+
+```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.create_channel(
+ name="announcements",
+ description="Community news and updates",
+ emoji="📢",
+ colour="#4360DF"
+)
+print(channel.id)
+```
+
+
+
+---
+
+
+
+### `delete_channel(channel_name)`
+
+Delete a channel by its name. Available channel names can be found in [`channels` property](./community.md#channels). A custom exception is raised if no channel with that name exists.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `channel_name` | `str` | Yes | The name of the channel to delete. |
+
+```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)
+
+community.delete_channel("announcements")
+```
+
+
+
+### `listen_requests()`
+
+Listen for join requests to the community **in real time**.
+
+Returns a `Generator` that yields one `dict` per request event:
+
+| Key | Type | Description |
+|----|----|-------------|
+| `request_id` | `str` | The join request id. Pass this to [`accept`](./community.md#acceptpending_request_id) or [`decline`](./community.md#declinepending_request_id). |
+| `state` | `str` | The state the request moved into - see the table below. |
+| `public_key` | `str` | Public key of the requesting member. |
+
+**Request states**
+
+| Code | State | Description |
+|-----|-----|-------------|
+| `1` | `pending` | The request is waiting to be [accepted](./community.md#acceptpending_request_id) or [declined](./community.md#declinepending_request_id). |
+| `2` | `reject` | The request was declined. |
+| `3` | `accept` | The request was accepted and the member joined. |
+| `4` | `cancel` | The request was cancelled. |
+
+Events belonging to **other communities**, and requests whose state is not one of the four above, are skipped - so everything yielded is a request for this community.
+
+```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)
+
+# Auto-accept everyone who asks to join
+for request in community.listen_requests():
+ print(f"{request['public_key']}\t{request['state']}")
+
+ if request["state"] != "pending":
+ continue
+
+ community.accept(request["request_id"])
+ community["general"].send_message("Welcome to the community!")
+```
+
+
+
+### Fetching a channel
+
+A `Channel` is retrieved by name with **subscript access** on the community. Available names come from [`channels` property](./community.md#channels).
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `channel_name` | `str` | Yes | The name of the channel to fetch. |
+
+```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"]
+channel.send_message("Hello from my Status bot!")
+```
+
+**Note**: A custom exception is raised if no channel with that name exists.
+
+### Counting members
+
+The total number of members in the community is obtained by passing the community to the built-in `len()`.
+
+Returns `int`. This is the same count as the number of entries returned by [`get_members`](./community.md#get_membersdataframefalse), without building the `dict` or the `DataFrame`.
+
+```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)
+
+print(f"The community has {len(community)} members")
+```
+
+## Properties
+
+### `id`
+
+The unique identifier of the community.
+
+Returns `str`. Raises a custom exception if the community is not usable (for example while a join request is pending).
+
+```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)
+
+print(community.id)
+```
+
+### `url`
+
+The shareable invite URL of the community. This is the same URL that can be passed to the [`Community`](./community.md#communityaccount-community_idnone-urlnone) constructor to join or wrap the community.
+
+Returns `str`, or `None` if the backend does not return one.
+
+```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)
+
+print(community.url)
+```
+
+### `name`
+
+The community's name.
+
+Returns `str`.
+
+```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)
+
+print(community.name)
+```
+
+
+
+### `description`
+
+The community's description.
+
+Returns `str`.
+
+```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)
+
+print(community.description)
+```
+
+
+
+### `introduction`
+
+The community's **introduction message** - the text shown to new members when they join.
+
+Returns `str`.
+
+```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)
+
+print(community.introduction)
+```
+
+
+
+### `leave_message`
+
+The community's **leave message** - the text shown to members when they leave the community.
+
+Returns `str`.
+
+```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)
+
+print(community.leave_message)
+```
+
+
+
+### `categories`
+
+The community's categories, keyed by **category name**.
+
+Returns `dict[str, dict]` where each key is a category name and the value contains:
+
+| Key | Type | Description |
+|----|----|-------------|
+| `id` | `str` | The category id. |
+| `position` | `int` | The category's position (ordering) in the community. |
+
+```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)
+
+for name, info in community.categories.items():
+ print(name, info["id"], info["position"])
+```
+
+### `channels`
+
+High level information for every channel in the community.
+
+Returns `list[dict]`, one entry per channel.
+
+| Key | Type | Description |
+|----|----|-------------|
+| `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. |
+
+```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)
+
+for channel in community.channels:
+ print(channel["name"], channel["category"])
+```
+
+
+
+### `banned_members`
+
+The public keys of members currently banned from the community.
+
+Returns `list[str]`.
+
+```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)
+
+print(community.banned_members)
+```
+
+
+
+### `pending_members`
+
+Members whose join request is waiting to be [accepted](./community.md#acceptpending_request_id) or [declined](./community.md#declinepending_request_id).
+
+Returns `list[dict]`, each entry containing:
+
+| Key | Type | Description |
+|----|----|-------------|
+| `public_key` | `str` | Public key of the requesting member. |
+| `request_id` | `str` | The join request id. Pass this to [`accept`](./community.md#acceptpending_request_id) or [`decline`](./community.md#declinepending_request_id). |
+
+```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)
+
+for member in community.pending_members:
+ print(member["public_key"], member["request_id"])
+```
+
+
+
+### `declined_members`
+
+Members whose join request has been declined.
+
+Returns `list[dict]` in the same shape as [`pending_members`](./community.md#pending_members).
+
+```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)
+
+print(community.declined_members)
+```
+
+
+
+# Channel
+
+A `Channel` represents a single channel inside a [`Community`](./community.md#community). **You never construct it directly**. Channels can be created with [Community `create_channel`](./community.md#create_channelname-description-emojinone-colournone-category_namenone) or by [subscript access](./community.md#fetching-a-channel).
+
+
+## Channel name
+
+The **channel name** identifies the channel. It is set when [creating a channel](./community.md#create_channelname-description-emojinone-colournone-category_namenone) and can be updated through the [`name` property](./community.md#name-1). Channel names must follow the validation rules enforced by the library and expected by the Status application. A valid channel name must satisfy all of the following conditions:
+
+- It may contain **letters (`A–Z`, `a–z`)**
+- It may contain **numbers (`0–9`)**
+- It may contain **underscores (`_`)**
+- It may contain **periods (`.`)**
+- It may contain **hyphens (`-`)**
+- **Whitespaces are replaced with hyphens (`-`)**
+- It must be **at least 1 character long**
+- It **cannot be more than 24 characters long**
+
+Characters such as punctuation, emojis, or other symbols are **not allowed**.
+
+### Valid examples
+
+```
+announcements
+general-chat
+dev.team-42
+SNT_PUMP
+9000
+```
+
+### Invalid examples
+
+| Example | Reason |
+|-------|--------|
+| | Too short (minimum length is 1) |
+| `a-channel-name-longer-than-24-chars` | Too long (maximum length is 24) |
+| `bot!123` | Contains invalid character `!` |
+| `chan 🚀` | Contains an emoji |
+
+**Note**: Whitespaces are automatically replaced with hyphens, so `my cool channel` becomes `my-cool-channel`.
+
+## Channel description
+
+The **channel description** is the short text shown under the channel. It is set when [creating a channel](./community.md#create_channelname-description-emojinone-colournone-category_namenone) and can be updated through the [`description`](./community.md#description-1) property.
+
+A valid channel description must satisfy all of the following conditions:
+
+- It may contain **letters (`A–Z`, `a–z`)**
+- It may contain **numbers (`0–9`)**
+- It may contain **underscores (`_`)**
+- It may contain **periods (`.`)**
+- It may contain **hyphens (`-`)**
+- It may contain **whitespaces (` `)**
+- It must be **at least 1 character long**
+- It **cannot be more than 140 characters long**
+
+Characters such as punctuation, emojis, or other symbols are **not allowed**.
+
+### Valid examples
+
+```
+Community news and updates
+General discussion
+dev.team-42 planning
+```
+
+### Invalid examples
+
+| Example | Reason |
+|-------|--------|
+| | Too short (minimum length is 1) |
+| `A description longer than one hundred and forty characters...` + more | Too long (maximum length is 140) |
+| `see the #general channel!` | Contains invalid character `!` |
+| `updates 🚀` | Contains an emoji |
+
+## Channel colour
+
+The **channel colour** is the accent colour of the channel. It can be set when [creating a channel](./community.md#create_channelname-description-emojinone-colournone-category_namenone) and updated through the [`colour`](./community.md#colour) property. When omitted at creation, a random default colour is chosen.
+
+A valid channel colour must be a **hex colour code** satisfying all of the following:
+
+- It must **start with a `#`**
+- It must be followed by **3 (`#RGB`) or 6 (`#RRGGBB`) hex digits**
+- Hex digits are **case-insensitive** (`0–9`, `a–f`, `A–F`)
+
+### Valid examples
+
+```
+#4360DF
+#FF7D46
+#7140fd
+#fff
+```
+
+### Invalid examples
+
+| Example | Reason |
+|-------|--------|
+| `4360DF` | Missing the leading `#` |
+| `#12` | Wrong number of digits (needs 3 or 6) |
+| `#GGGGGG` | Contains non-hex characters |
+| `blue` | Not a hex colour code |
+
+If a channel colour does not follow these rules, a custom exception will be raised.
+
+## Channel emoji
+
+The **channel emoji** is the icon shown next to the channel. It can be set when [creating a channel](./community.md#create_channelname-description-emojinone-colournone-category_namenone) and updated through the [`emoji`](./community.md#emoji) property. When omitted at creation, a random default emoji is chosen.
+
+A valid channel emoji must satisfy all of the following:
+
+- It must be a **single emoji**
+- **Skin tones, flags and Zero-Width Joiner (ZWJ) sequences are not supported**
+
+### Valid examples
+
+```
+📢
+🚀
+❤️
+⭐
+```
+
+### Invalid examples
+
+| Example | Reason |
+|-------|--------|
+| `AB` | Not an emoji |
+| `🎉🎉` | More than one emoji |
+| `👍🏽` | Uses a skin-tone modifier |
+| `🇬🇧` | Flag (multi-codepoint) |
+| `👨👩👧` | ZWJ sequence |
+
+## Methods
+
+### `send_message(message, reply_to_message_id=None)`
+
+Send a text message to the channel. Supports **text messages only**, optionally as a reply.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `message` | `str` | Yes | The text message to send. |
+| `reply_to_message_id` | `str` | No | The `id` of the message being replied to, from [`get_messages`](./community.md#get_messagesstart_timestampnone-end_timestampnone). When omitted, the message is sent standalone. |
+
+Returns `str` - the `id` of the message that was just sent, delegated from [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) on `Account`. It is the same identifier that appears under the `id` key in [`get_messages`](./community.md#get_messagesstart_timestampnone-end_timestampnone), so it can be passed straight into [`delete_message`](./community.md#delete_messageid) or used as the `reply_to_message_id` of a follow-up message, without having to fetch the channel's messages first.
+
+```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"]
+message_id = channel.send_message("Hello from my Status bot!")
+print(f"Sent message: {message_id}")
+```
+
+### `get_messages(start_timestamp=None, end_timestamp=None)`
+
+Retrieve messages from the channel within an optional time range. Messages are returned in **descending order** (newest to oldest).
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `start_timestamp` | `datetime.datetime` | No | The earliest timestamp to include. Messages older than this stop the fetch. |
+| `end_timestamp` | `datetime.datetime` | No | The latest timestamp to include. Messages newer than this are skipped. |
+
+Returns `list[dict]` of message objects. This delegates to [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone) on `Account`.
+
+```python
+from status_sdk import Account, Community
+import datetime
+
+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"]
+
+messages = channel.get_messages(start_timestamp=datetime.datetime(2024, 1, 1))
+for message in messages:
+ print(f"{message['timestamp']}\t{message['text']}")
+```
+
+### `delete_message(id)`
+
+Delete a message from the channel. You can delete your own messages, and if you are an administrator you can delete other members' messages too.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `id` | `str` | Yes | The `id` of the message to delete, from [`get_messages`](./community.md#get_messagesstart_timestampnone-end_timestampnone) or directly from the return value of [`send_message`](./community.md#send_messagemessage-reply_to_message_idnone). |
+
+Returns `bool` - `True` if the message was deleted, `False` if the account did not have permission.
+
+```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"]
+
+messages = channel.get_messages()
+deleted = channel.delete_message(messages[0]["id"])
+print(f"Deleted: {deleted}")
+```
+
+## Properties
+
+### `id`
+
+The channel's unique identifier - the community id combined with the channel id. This is the value used with [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone) on `Account`.
+
+Returns `str`.
+
+```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"]
+print(channel.id)
+```
+
+### `can_post`
+
+Whether the logged-in account is allowed to post in the channel.
+
+Returns `bool`.
+
+```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"]
+if channel.can_post:
+ channel.send_message("Hello!")
+```
+
+### `name`
+
+Get or update the channel's name. The name must follow the [channel name](./community.md#channel-name) validation.
+
+Returns `str` 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.name)
+
+# Update
+channel.name = "general-chat"
+```
+
+
+
+### `description`
+
+Get or update the channel's description. The description must follow the [channel description](./community.md#channel-description) validation.
+
+Returns `str` 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"]
+
+channel.description = "General discussion"
+print(channel.description)
+```
+
+
+
+### `colour`
+
+Get or update the channel's colour. The colour must follow the [channel colour](./community.md#channel-colour) validation.
+
+Returns `str` 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"]
+
+channel.colour = "#7140FD"
+print(channel.colour)
+```
+
+
+
+### `emoji`
+
+Get or update the channel's emoji. The emoji must follow the [channel emoji](./community.md#channel-emoji) validation.
+
+Returns `str` when reading, or `None` if the channel has no emoji.
+
+```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"]
+
+channel.emoji = "🚀"
+print(channel.emoji)
+```
+
+
diff --git a/docs/group-chat.md b/docs/group-chat.md
new file mode 100644
index 0000000..ea34b23
--- /dev/null
+++ b/docs/group-chat.md
@@ -0,0 +1,543 @@
+# Group Chat
+
+
+
+The group chat class allows you to easily work with a [Status Group Chat](https://status.app/help/messaging/create-a-group-chat). Group chats aren't the same as communities - they are meant for smaller groups of people. **A group chat can have 20 members at most**
+
+A `GroupChat` is always bound to a logged-in [`Account`](./account.md). It can either wrap an **existing** chat (by passing a `chat_id`) or create a brand new one. **A chat be created only with [mutual contacts](./account.md#contacts)** - accounts where the `mutual` key is `True`.
+
+## Administrator
+
+The account that creates a group chat becomes its **administrator**. Only the administrator can [remove](./group-chat.md#removepublic_keys) members from the chat. Every member (admin or not) can [add](./group-chat.md#addpublic_keys) members, [send_message](./group-chat.md#send_messagemessage-reply_to_message_idnone), [get messages](./group-chat.md#get_messagesstart_timestampnone-end_timestampnone) and [leave](./group-chat.md#leave).
+
+## Group chat name
+
+The **group chat name** is the human-readable name of the chat. It is set when [creating](./group-chat.md#createpublic_keys-name) the chat and can be updated through the [`name`](./group-chat.md#name) property.
+
+Group chat names must follow the validation rules enforced by the library and expected by the Status application. A valid group chat name must satisfy all of the following conditions:
+
+- It may contain **letters (`A–Z`, `a–z`)**
+- It may contain **numbers (`0–9`)**
+- It may contain **underscores (`_`)**
+- It may contain **periods (`.`)**
+- It may contain **hyphens (`-`)**
+- It may contain **whitespaces (` `)**
+- It must be **at least 1 character long**
+- It **cannot be more than 30 characters long**
+
+Characters such as punctuation, emojis, or other symbols are **not allowed**.
+
+### Valid examples
+
+```
+Status Bots
+status-bot.01
+SNT_PUMP
+dev.team-42
+a
+9000
+```
+
+### Invalid examples
+
+| Example | Reason |
+|-------|--------|
+| | Too short (minimum length is 1) |
+| `a-very-long-group-chat-name-42` + more | Too long (maximum length is 30) |
+| `bot!123` | Contains invalid character `!` |
+| `status 🚀` | Contains an emoji |
+
+If a group chat name does not follow these rules, a custom exception will be raised.
+
+**Note**: Unlike the [display name](./account.md#display-name), a group chat name **can** start or end with a whitespace.
+
+## `GroupChat(account, chat_id=None)`
+
+Create a new `GroupChat` instance. The constructor binds the group chat to a **logged-in** [`Account`](./account.md). If `chat_id` is not provided, an empty `GroupChat` is created and you must call [`create`](./group-chat.md#createpublic_keys-name) before the chat can be used.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `account` | `Account` | Yes | A **logged-in** [`Account`](./account.md). If the account is not logged in, a custom exception is raised. |
+| `chat_id` | `str` | No | The identifier of an existing group chat. Group chat IDs can be obtained from the [`chats`](./account.md#chats) property, where `type` is `group_chat`. If the chat does not exist, a `GroupChatNotFoundError` is raised. |
+
+Prepare an empty `GroupChat` to create a new chat:
+
+```python
+from status_sdk import Account, GroupChat
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+group_chat = GroupChat(account)
+```
+
+
+Wrap an existing group chat:
+
+```python
+from status_sdk import Account, GroupChat
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+
+# This is under the assumption you are already in a group chat
+chat = [chat for chat in account.chats if chat["type"] == "group_chat"][0]
+group_chat = GroupChat(account, chat["id"])
+
+print(group_chat.name)
+```
+
+## Methods
+
+### `create(public_keys, name)`
+
+Create a **new group chat** with the given members. The logged-in account becomes the [administrator](./group-chat.md#administrator) of the chat. **[Group chats can have up to 20 members.](https://status.app/help/messaging/create-a-group-chat)**
+
+Each member 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:
+
+| Format | Example | Where to find it |
+|-------|--------|-----------------|
+| **Public key** | `0x04ebcad...` | `public_key` in [`contacts`](./account.md#contacts) |
+| **Chat key** (compressed key) | `zQ3shYSHp7...` | `compressed_key` in [`contacts`](./account.md#contacts), or the **chat key** in Status App |
+| **Account URL** | `https://status.app/u/...` | `url` in [`contacts`](./account.md#contacts), or **Share profile** in Status App |
+
+Every value is normalised into the public key with [`get_public_key`](./account.md#get_public_keyvalue), so the formats can be **mixed within the same list**.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to create the chat with. A single value can be passed as a `str`. The members must be [mutual contacts](./account.md#contacts). |
+| `name` | `str` | Yes | The name of the group chat. Must follow the [group chat name](./group-chat.md#group-chat-name) rules. |
+
+Returns the current `GroupChat` instance, allowing method chaining.
+
+```python
+from status_sdk import Account, GroupChat
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+public_keys = [contact["public_key"] for contact in account.contacts.values() if contact["mutual"]]
+
+group_chat = GroupChat(account).create(public_keys, "Status Bots")
+print(group_chat.id)
+```
+
+
+
+Because the method returns the instance, calls can be chained:
+
+```python
+from status_sdk import Account, GroupChat
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+public_keys = [contact["public_key"] for contact in account.contacts.values() if contact["mutual"]]
+GroupChat(account).create(public_keys, "Status Bots").send_message("Hello!")
+```
+
+The formats can also be mixed:
+
+```python
+from status_sdk import Account, GroupChat
+
+account = Account()
+params = {
+ "name": "status-app-bot",
+ "password": "SNTPUMP"
+}
+account.login(**params)
+members = [
+ "0x04ebcad...",
+ "zQ3shYSHp7...",
+ "https://status.app/u/..."
+]
+
+group_chat = GroupChat(account).create(members, "Status Bots")
+```
+
+**Note**: The account's **own public key** is automatically filtered out of `public_keys`, since the creator is always a member of the chat. This happens after the values are normalised, so it also works when your own account is passed as a chat key or account URL.
+
+### `send_message(message, reply_to_message_id=None)`
+
+Send a text message to the group chat. This method currently supports **text messages only**. A message can also be sent as a **reply** to an existing message in the chat, which renders in Status App with the original message quoted above it - the same as replying to a message in the app.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `message` | `str` | Yes | The text message to send. |
+| `reply_to_message_id` | `str` | No | The `id` of the message being replied to. Message IDs can be obtained from the `id` key of [`get_messages`](./group-chat.md#get_messagesstart_timestampnone-end_timestampnone). When omitted (default), the message is sent as a standalone message. |
+
+Returns `str` - the `id` of the message that was just sent, delegated from [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) on `Account`. It is the same identifier that appears under the `id` key in [`get_messages`](./group-chat.md#get_messagesstart_timestampnone-end_timestampnone), so it can be passed straight into [`delete_message`](./group-chat.md#delete_messageid) or used as the `reply_to_message_id` of a follow-up message, without having to fetch the chat's messages first.
+
+```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"])
+
+first_id = group_chat.send_message("Hello from my Status bot #1!")
+# Reply to the message that was just sent, without fetching the chat's messages
+second_id = group_chat.send_message("Hello from my Status bot #2!", first_id)
+```
+
+Reply to a 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"])
+
+# Messages are returned newest first, so this is the latest message in the chat
+messages = group_chat.get_messages()
+latest = messages[0]
+
+group_chat.send_message("Thanks for the update!", latest["id"])
+```
+
+### `delete_message(id)`
+
+Delete one of your **own** messages from the group chat. The deletion is propagated to the other members, so the message disappears for everybody. You can only delete messages that the logged-in account has sent.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `id` | `str` | Yes | The `id` of the message to delete. Message IDs can be obtained from the `id` key of [`get_messages`](./group-chat.md#get_messagesstart_timestampnone-end_timestampnone), or directly from the return value of [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone). |
+
+Returns `bool`.
+
+| Value | Meaning |
+|------|--------|
+| `True` | The message was deleted. |
+| `False` | The message was not deleted, because the account does not have permission to delete it. |
+
+```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"])
+
+message_id = group_chat.send_message("Oops, this was a mistake!")
+
+deleted = group_chat.delete_message(message_id)
+print(f"Deleted: {deleted}")
+```
+
+### `get_messages(start_timestamp=None, end_timestamp=None)`
+
+Retrieve messages from the group chat within an optional time range. Messages are returned in **descending order** (newest to oldest).
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `start_timestamp` | `datetime.datetime` | No | The earliest timestamp to include. Messages older than this value will stop the fetch process. |
+| `end_timestamp` | `datetime.datetime` | No | The latest timestamp to include. Messages newer than this value will be skipped. |
+
+Returns `list[dict]` containing message objects. Timestamp fields returned by the backend are automatically converted into `datetime.datetime` objects.
+
+```python
+from status_sdk import Account, GroupChat
+import datetime
+
+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"])
+
+messages = group_chat.get_messages(start_timestamp=datetime.datetime(2024, 1, 1))
+
+for message in messages:
+ print(f"{message['timestamp']}\t{message['text']}")
+```
+
+**Note**: This is the group chat equivalent of [`delete_message`](./account.md#delete_messagemessage_id) on `Account`. The only difference is that it first verifies the group chat exists - a custom exception is raised if the chat has not been created or joined.
+
+### `add(public_keys)`
+
+Add members to the group chat.
+
+Just like [`create`](./group-chat.md#createpublic_keys-name), each member can be identified by their **public key**, **chat key** or **account URL**, and the formats can be mixed within the same list.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to add. A single value can be passed as a `str`. The members must be [mutual contacts](./account.md#contacts). |
+
+Returns the current `GroupChat` instance, allowing method chaining.
+
+```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"])
+
+public_keys = [contact["public_key"] for contact in account.contacts.values() if contact["mutual"]]
+group_chat.add(public_keys)
+
+print(group_chat.members.keys())
+```
+
+
+
+---
+
+
+
+### `remove(public_keys)`
+
+Remove members from the group chat. **Only the [administrator](./group-chat.md#administrator) of the chat can remove members.**
+
+Just like [`create`](./group-chat.md#createpublic_keys-name), each member can be identified by their **public key**, **chat key** or **account URL**, and the formats can be mixed within the same list. All three values are exposed in the [`members`](./group-chat.md#members) property as `public_key`, `compressed_key` and `url`.
+
+| Name | Type | Required | Description |
+|-----|-----|-----|-------------|
+| `public_keys` | `list[str]`
`str` | Yes | The **public keys** (`0x...`), **chat keys** (`zQ...`) or **account URLs** (`https://...`) of the members to remove. A single value can be passed as a `str`. The values must belong to current members of the chat, which can be obtained from the [`members`](./group-chat.md#members) property. |
+
+Returns the current `GroupChat` instance, allowing method chaining.
+
+```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"])
+
+member = list(group_chat.members.values())[0]
+group_chat.remove(member["public_key"])
+```
+
+
+
+**Alternative remove:**
+
+
+
+---
+
+
+
+
+### `leave()`
+
+Leave the group chat.
+
+Returns the current `GroupChat` instance, allowing method chaining.
+
+```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"])
+
+group_chat.leave()
+```
+
+
+
+**Note**: The `GroupChat` instance cannot be reused - accessing those properties raises a custom exception. To use it again, either [`create`](./group-chat.md#createpublic_keys-name) a new chat or ask somebody to add you in the chat.
+
+## Properties
+
+### `id`
+
+The unique identifier of the group chat. This is the same value found in the [`chats`](./account.md#chats) property where `type` is `group_chat`, and it can be used directly with [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone) on `Account`.
+
+Returns `str`. Raises a custom exception if the chat has not been created or joined.
+
+```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"])
+
+print(group_chat.id)
+```
+
+### `name`
+
+Get or update the **name** of the group chat. The name must follow the [group chat name](./group-chat.md#group-chat-name) rules.
+
+Returns `str` when reading the property. Raises a custom exception if the chat has not been created or joined.
+
+```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"])
+
+# Get the current group chat name
+print(group_chat.name)
+```
+
+
+
+You can update the name by assigning a new value:
+
+```python
+# Change the group chat name
+group_chat.name = "Status Bots Electric Boogaloo"
+print(group_chat.name)
+```
+
+
+
+---
+
+
+
+### `members`
+
+Get the current members of the group chat.
+
+Returns `dict[str, dict]` where the key is the member's **public key**. This makes internal searching for member specific information faster. Raises a custom exception if the chat has not been created or joined.
+
+| Key | Type | Description |
+|----|----|-------------|
+| `public_key` | `str` | Public key that uniquely identifies the member. |
+| `url` | `str` | The URL that can be shared with other users. |
+| `display_name` | `str` | The current display name of the member. |
+| `compressed_key` | `str` | The member's compressed chat key as shown in Status App. |
+| `admin` | `bool` | Whether the member is the [administrator](./group-chat.md#administrator) of the group chat. |
+
+```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"])
+
+for member in group_chat.members.values():
+ print(member["display_name"], member["admin"])
+```
+
+### `available_slots`
+
+The number of members that can still be [added](./group-chat.md#addpublic_keys) to the group chat.
+
+Returns `int`. Raises a custom exception if the chat has not been created or joined.
+
+```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"])
+
+print(f"{group_chat.available_slots} slots left")
+```
+
+This is useful to check before adding members, since the group chat is full when there are no slots left:
+
+```python
+public_keys = [contact["public_key"] for contact in account.contacts.values() if contact["mutual"]]
+
+if group_chat.available_slots >= len(public_keys):
+ group_chat.add(public_keys)
+```
+
+
+### `is_admin`
+
+Whether the logged-in [`Account`](./account.md) is the [administrator](./group-chat.md#administrator) of the group chat.
+
+Returns `bool`.
+
+```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"])
+
+if group_chat.is_admin:
+ print("Account is admin!")
+```
diff --git a/docs/images/account/backup.png b/docs/images/account/backup.png
new file mode 100644
index 0000000..778b4bd
Binary files /dev/null and b/docs/images/account/backup.png differ
diff --git a/docs/images/account/debug-mode.png b/docs/images/account/debug-mode.png
new file mode 100644
index 0000000..0cdb184
Binary files /dev/null and b/docs/images/account/debug-mode.png differ
diff --git a/docs/images/account/ens.png b/docs/images/account/ens.png
new file mode 100644
index 0000000..1b3c01f
Binary files /dev/null and b/docs/images/account/ens.png differ
diff --git a/docs/images/account/login/create.png b/docs/images/account/login/create.png
new file mode 100644
index 0000000..f459672
Binary files /dev/null and b/docs/images/account/login/create.png differ
diff --git a/docs/images/account/login/log-in.png b/docs/images/account/login/log-in.png
new file mode 100644
index 0000000..992b617
Binary files /dev/null and b/docs/images/account/login/log-in.png differ
diff --git a/docs/images/account/login/recover.png b/docs/images/account/login/recover.png
new file mode 100644
index 0000000..dbd24db
Binary files /dev/null and b/docs/images/account/login/recover.png differ
diff --git a/docs/images/account/overview.png b/docs/images/account/overview.png
new file mode 100644
index 0000000..d22c622
Binary files /dev/null and b/docs/images/account/overview.png differ
diff --git a/docs/images/account/public-keys.png b/docs/images/account/public-keys.png
new file mode 100644
index 0000000..50bd98c
Binary files /dev/null and b/docs/images/account/public-keys.png differ
diff --git a/docs/images/account/syncing-1.png b/docs/images/account/syncing-1.png
new file mode 100644
index 0000000..b9e6471
Binary files /dev/null and b/docs/images/account/syncing-1.png differ
diff --git a/docs/images/account/syncing-2.png b/docs/images/account/syncing-2.png
new file mode 100644
index 0000000..e22b831
Binary files /dev/null and b/docs/images/account/syncing-2.png differ
diff --git a/docs/images/account/wallet.png b/docs/images/account/wallet.png
new file mode 100644
index 0000000..991c21f
Binary files /dev/null and b/docs/images/account/wallet.png differ
diff --git a/docs/images/backup.png b/docs/images/backup.png
deleted file mode 100644
index fd3034b..0000000
Binary files a/docs/images/backup.png and /dev/null differ
diff --git a/docs/images/community/ban.png b/docs/images/community/ban.png
new file mode 100644
index 0000000..04bc107
Binary files /dev/null and b/docs/images/community/ban.png differ
diff --git a/docs/images/community/banned-members.png b/docs/images/community/banned-members.png
new file mode 100644
index 0000000..de66a26
Binary files /dev/null and b/docs/images/community/banned-members.png differ
diff --git a/docs/images/community/channel-delete.png b/docs/images/community/channel-delete.png
new file mode 100644
index 0000000..53e8f2f
Binary files /dev/null and b/docs/images/community/channel-delete.png differ
diff --git a/docs/images/community/channels.png b/docs/images/community/channels.png
new file mode 100644
index 0000000..554717a
Binary files /dev/null and b/docs/images/community/channels.png differ
diff --git a/docs/images/community/create-channel-1.png b/docs/images/community/create-channel-1.png
new file mode 100644
index 0000000..66923d4
Binary files /dev/null and b/docs/images/community/create-channel-1.png differ
diff --git a/docs/images/community/create-channel-2.png b/docs/images/community/create-channel-2.png
new file mode 100644
index 0000000..00883f6
Binary files /dev/null and b/docs/images/community/create-channel-2.png differ
diff --git a/docs/images/community/declined-members.png b/docs/images/community/declined-members.png
new file mode 100644
index 0000000..6f2edae
Binary files /dev/null and b/docs/images/community/declined-members.png differ
diff --git a/docs/images/community/edit-channel-colour.png b/docs/images/community/edit-channel-colour.png
new file mode 100644
index 0000000..5109668
Binary files /dev/null and b/docs/images/community/edit-channel-colour.png differ
diff --git a/docs/images/community/edit-channel-description.png b/docs/images/community/edit-channel-description.png
new file mode 100644
index 0000000..0528f40
Binary files /dev/null and b/docs/images/community/edit-channel-description.png differ
diff --git a/docs/images/community/edit-channel-emoji.png b/docs/images/community/edit-channel-emoji.png
new file mode 100644
index 0000000..f1cb324
Binary files /dev/null and b/docs/images/community/edit-channel-emoji.png differ
diff --git a/docs/images/community/edit-channel-name.png b/docs/images/community/edit-channel-name.png
new file mode 100644
index 0000000..999de8c
Binary files /dev/null and b/docs/images/community/edit-channel-name.png differ
diff --git a/docs/images/community/intro-message.png b/docs/images/community/intro-message.png
new file mode 100644
index 0000000..4a28869
Binary files /dev/null and b/docs/images/community/intro-message.png differ
diff --git a/docs/images/community/kick.png b/docs/images/community/kick.png
new file mode 100644
index 0000000..351bef2
Binary files /dev/null and b/docs/images/community/kick.png differ
diff --git a/docs/images/community/leave-message.png b/docs/images/community/leave-message.png
new file mode 100644
index 0000000..bd10bec
Binary files /dev/null and b/docs/images/community/leave-message.png differ
diff --git a/docs/images/community/members.png b/docs/images/community/members.png
new file mode 100644
index 0000000..a6ddffe
Binary files /dev/null and b/docs/images/community/members.png differ
diff --git a/docs/images/community/name.png b/docs/images/community/name.png
new file mode 100644
index 0000000..8e62f06
Binary files /dev/null and b/docs/images/community/name.png differ
diff --git a/docs/images/community/overview.webp b/docs/images/community/overview.webp
new file mode 100644
index 0000000..81eb493
Binary files /dev/null and b/docs/images/community/overview.webp differ
diff --git a/docs/images/community/pending-members.png b/docs/images/community/pending-members.png
new file mode 100644
index 0000000..20ea071
Binary files /dev/null and b/docs/images/community/pending-members.png differ
diff --git a/docs/images/community/pending.png b/docs/images/community/pending.png
new file mode 100644
index 0000000..3152588
Binary files /dev/null and b/docs/images/community/pending.png differ
diff --git a/docs/images/community/settings.png b/docs/images/community/settings.png
new file mode 100644
index 0000000..2873be9
Binary files /dev/null and b/docs/images/community/settings.png differ
diff --git a/docs/images/community/unban.png b/docs/images/community/unban.png
new file mode 100644
index 0000000..8ae408e
Binary files /dev/null and b/docs/images/community/unban.png differ
diff --git a/docs/images/ens.png b/docs/images/ens.png
deleted file mode 100644
index 55e8eb4..0000000
Binary files a/docs/images/ens.png and /dev/null differ
diff --git a/docs/images/group-chat/add.png b/docs/images/group-chat/add.png
new file mode 100644
index 0000000..6372f41
Binary files /dev/null and b/docs/images/group-chat/add.png differ
diff --git a/docs/images/group-chat/base.png b/docs/images/group-chat/base.png
new file mode 100644
index 0000000..9ca325b
Binary files /dev/null and b/docs/images/group-chat/base.png differ
diff --git a/docs/images/group-chat/create.png b/docs/images/group-chat/create.png
new file mode 100644
index 0000000..5d5a8d6
Binary files /dev/null and b/docs/images/group-chat/create.png differ
diff --git a/docs/images/group-chat/fetch-name.png b/docs/images/group-chat/fetch-name.png
new file mode 100644
index 0000000..5e38bdb
Binary files /dev/null and b/docs/images/group-chat/fetch-name.png differ
diff --git a/docs/images/group-chat/leave.png b/docs/images/group-chat/leave.png
new file mode 100644
index 0000000..3651c46
Binary files /dev/null and b/docs/images/group-chat/leave.png differ
diff --git a/docs/images/group-chat/overview.png b/docs/images/group-chat/overview.png
new file mode 100644
index 0000000..873a88b
Binary files /dev/null and b/docs/images/group-chat/overview.png differ
diff --git a/docs/images/group-chat/remove-alternative.png b/docs/images/group-chat/remove-alternative.png
new file mode 100644
index 0000000..bf9ed0d
Binary files /dev/null and b/docs/images/group-chat/remove-alternative.png differ
diff --git a/docs/images/group-chat/remove.png b/docs/images/group-chat/remove.png
new file mode 100644
index 0000000..7a880e7
Binary files /dev/null and b/docs/images/group-chat/remove.png differ
diff --git a/docs/images/group-chat/set-name-1.png b/docs/images/group-chat/set-name-1.png
new file mode 100644
index 0000000..181efc3
Binary files /dev/null and b/docs/images/group-chat/set-name-1.png differ
diff --git a/docs/images/group-chat/set-name-2.png b/docs/images/group-chat/set-name-2.png
new file mode 100644
index 0000000..bfd03f9
Binary files /dev/null and b/docs/images/group-chat/set-name-2.png differ
diff --git a/docs/images/group-chat/settings.png b/docs/images/group-chat/settings.png
new file mode 100644
index 0000000..e48c846
Binary files /dev/null and b/docs/images/group-chat/settings.png differ
diff --git a/docs/images/login/create.png b/docs/images/login/create.png
deleted file mode 100644
index 65e18ef..0000000
Binary files a/docs/images/login/create.png and /dev/null differ
diff --git a/docs/images/login/log-in.png b/docs/images/login/log-in.png
deleted file mode 100644
index 9a90162..0000000
Binary files a/docs/images/login/log-in.png and /dev/null differ
diff --git a/docs/images/login/recover.png b/docs/images/login/recover.png
deleted file mode 100644
index f75f4c7..0000000
Binary files a/docs/images/login/recover.png and /dev/null differ
diff --git a/docs/images/mac-docker.png b/docs/images/mac-docker.png
deleted file mode 100644
index 6141369..0000000
Binary files a/docs/images/mac-docker.png and /dev/null differ
diff --git a/docs/images/overview-account.png b/docs/images/overview-account.png
deleted file mode 100644
index eee4ef3..0000000
Binary files a/docs/images/overview-account.png and /dev/null differ
diff --git a/docs/images/overview-header.png b/docs/images/overview-header.png
deleted file mode 100644
index d69edff..0000000
Binary files a/docs/images/overview-header.png and /dev/null differ
diff --git a/docs/images/overview-utils.png b/docs/images/overview-utils.png
deleted file mode 100644
index 5716076..0000000
Binary files a/docs/images/overview-utils.png and /dev/null differ
diff --git a/docs/images/readme/overview.png b/docs/images/readme/overview.png
new file mode 100644
index 0000000..3005758
Binary files /dev/null and b/docs/images/readme/overview.png differ
diff --git a/docs/images/utils/mac-docker.png b/docs/images/utils/mac-docker.png
new file mode 100644
index 0000000..f6f5307
Binary files /dev/null and b/docs/images/utils/mac-docker.png differ
diff --git a/docs/images/utils/overview.png b/docs/images/utils/overview.png
new file mode 100644
index 0000000..a8c1296
Binary files /dev/null and b/docs/images/utils/overview.png differ
diff --git a/docs/images/utils/wsl-docker.png b/docs/images/utils/wsl-docker.png
new file mode 100644
index 0000000..3b23f5a
Binary files /dev/null and b/docs/images/utils/wsl-docker.png differ
diff --git a/docs/images/wallet.png b/docs/images/wallet.png
deleted file mode 100644
index fe5b2bc..0000000
Binary files a/docs/images/wallet.png and /dev/null differ
diff --git a/docs/images/wsl-docker.png b/docs/images/wsl-docker.png
deleted file mode 100644
index f9f2990..0000000
Binary files a/docs/images/wsl-docker.png and /dev/null differ
diff --git a/docs/utils.md b/docs/utils.md
index dabb72b..b5ab807 100644
--- a/docs/utils.md
+++ b/docs/utils.md
@@ -1,8 +1,8 @@
# Utils
-
+
-Helper functions for setting up the Status Backend environment.
+Helper functions for setting up the Status Backend environment, and package level metadata.
## Methods
@@ -58,7 +58,7 @@ launch_docker_container(platform="linux/arm64")
In Docker go to `Settings > Resources > WSL integration` and make sure `Enable integration with my default WSL distro` and `Ubuntu` are **turned on**.
-
+
Docker Desktop creates internal intermediary mounts inside its WSL 2 environment when bind-mounting paths from a WSL distribution into a container. In some cases, these mounts can become **stale**, and the container may fail to start with:
@@ -77,7 +77,7 @@ WSL boots back up on demand, so no manual step is needed. Docker Desktop does ne
In Docker go to `Settings > Resources > File Sharing` and make sure the SDK repository is added to **Virtual file shares**.
-
+
#### Linux
@@ -95,3 +95,45 @@ sudo chown -R $USER:$USER /path/to/status_sdk
sudo chmod -R a+rw /path/to/status_sdk
```
+## Properties
+
+### `__version__`
+
+The version of the installed `status-sdk` package. Returns `str`, matching the version published on [PyPI](https://pypi.org/project/status-sdk/).
+
+The value is read from the installed package metadata at import time, so it always reflects the version that is actually installed in your environment - not the version of any source checkout you happen to be standing in.
+
+```python
+import status_sdk
+
+print(status_sdk.__version__)
+# 1.1.0
+```
+
+It can also be imported directly:
+
+```python
+from status_sdk import __version__
+
+print(__version__)
+# 1.1.0
+```
+
+Please include it when [reporting an issue](https://github.com/status-im/status-python-sdk/issues), together with the [`status-go`](https://github.com/status-im/status-go) ref you passed to [`launch_docker_container`](./utils.md#launch_docker_containercommitnone-wait_seconds5-platformlinuxamd64) - the two together describe the exact setup a bug happened on:
+
+```python
+import status_sdk
+
+print(f"status-sdk {status_sdk.__version__}")
+```
+
+#### Running from a source
+
+`__version__` falls back to `dev` when the package has no installed metadata to read - which happens if you cloned the repository and imported `status_sdk` from the project folder without installing it. Install the repository in editable mode and the real version is reported again:
+
+```bash
+pip install -e .
+```
+
+Treat `dev` as "not installed" rather than as a real release - it is deliberately lower than every published version, so the `packaging` check above will fail against it.
+
diff --git a/examples/agents/README.md b/examples/agents/README.md
index 11cc596..64101bb 100644
--- a/examples/agents/README.md
+++ b/examples/agents/README.md
@@ -39,7 +39,7 @@ Each tool is a thin wrapper around the [Python SDK](../../README.md). They are d
| `search_external_balance` | [`get_balance`](../../docs/account.md#get_balancetoken_addresses-chain_ids1-walletsnone-ccynone) | Read the balance of **any** wallet address, not just the account's. |
| `search_messages` | [`get_messages`](../../docs/account.md#get_messageschat_id-start_timestampnone-end_timestampnone) | Read chat history for a date range, including payment requests. |
| `search_transactions` | [`get_transactions`](../../docs/account.md#get_transactionsrefreshfalse) | Read historical wallet transactions. |
-| `send_message` | [`send_message`](../../docs/account.md#send_messagechat_id-message) | **Send a message** to any chat. |
+| `send_message` | [`send_message`](../../docs/account.md#send_messagechat_id-message-reply_to_message_idnone) | **Send a message** to any chat. |
| `send_transaction` | [`send_transaction`](../../docs/account.md#send_transactionaddress-symbol-amount-chain_id1) | **Send crypto** to any address. |
| `swap_tokens` | [`swap_tokens`](../../docs/account.md#swap_tokensfrom_token-to_token-amount-chain_id1) | **Swap tokens** in the wallet. |
@@ -49,10 +49,16 @@ Each tool is a thin wrapper around the [Python SDK](../../README.md). They are d
### 1. Install
-From the **repository root**, install the SDK with the `agent` dependencies:
+Install the SDK from [PyPI](https://pypi.org/project/status-sdk/) with the `agents` dependencies:
```
-pip install ".[agent]"
+pip install "status-sdk[agents]"
+```
+
+Or, if you are working from a clone of the repository, install the same extra from the **repository root**:
+
+```
+pip install ".[agents]"
```
### 2. Configure
diff --git a/examples/agents/env.example b/examples/agents/env.example
index 2b772f3..8f1d8a2 100644
--- a/examples/agents/env.example
+++ b/examples/agents/env.example
@@ -11,4 +11,4 @@ GROQ_API_KEY = "your-groq-api-key"
GROQ_MODEL = "groq-model-name"
# Monitor messages from provided public key
-FROM_PUBLIC_KEY = "account-public-key
+FROM_PUBLIC_KEY = "account-public-key"
diff --git a/examples/agents/tools.py b/examples/agents/tools.py
index 493fc09..5190389 100644
--- a/examples/agents/tools.py
+++ b/examples/agents/tools.py
@@ -221,12 +221,12 @@ def _run(self, chain_ids: Optional[list[int]], token_symbols: Optional[list[str]
class SendMessagesTool(StatusBaseTool):
name: str = "send_message"
- description: str = "Send a message to the specified chat IT"
+ description: str = "Send a message to the specified chat ID"
args_schema: Type[BaseModel] = models.MessageInput
def _run(self, chat_id: str, message: Optional[str], start_date: Optional[models.DateStr], end_date: Optional[models.DateStr]) -> str:
- self.account.send_message(chat_id, message)
- return f"Message was sent successfully in chat ID {chat_id}!"
+ message_id = self.account.send_message(chat_id, message)
+ return f"Message [{message_id}] was sent successfully in chat ID {chat_id}!"
class SendTransactionTool(StatusBaseTool):
diff --git a/examples/community-greet/README.md b/examples/community-greet/README.md
new file mode 100644
index 0000000..4413be5
--- /dev/null
+++ b/examples/community-greet/README.md
@@ -0,0 +1,107 @@
+# Community Greeter
+
+A **welcome bot** for a [Status Community](https://status.app/help/communities). The script logs into a Status account, listens for join requests **in real time**, optionally **accepts** them, and posts a unique LLM-written greeting for every new member in a channel of your choice. The greeting is generated locally with [Ollama](https://ollama.com/), so no messages ever leave your machine.
+
+## How it works
+
+On startup the bot [creates the channel](../../docs/community.md#create_channelname-description-emojinone-colournone-category_namenone) it greets in or reuses it if a channel with that name already exists. The then the bot starts listening for different states:
+
+- `pending` - the request is waiting on a decision. With `--approve` (the default) the bot accepts it and moves on. The greeting is *not* sent here - accepting produces a follow-up `accept` event, which is what triggers the message.
+- `accept` - the member is in the community. The bot asks the local model for a greeting and [`send_message`](../../docs/community.md#send_messagemessage-reply_to_message_idnone)s it to the channel, mentioning the new member by public key.
+3. `reject` / `cancel` - ignored.
+
+With `--no-approve` the bot never decides membership itself; it only greets members an administrator has accepted.
+
+```mermaid
+sequenceDiagram
+ actor Member as New Member
+ participant Bot
+ participant Ollama
+
+ Member->>Bot: requests to join
state = pending
+ alt -a / --approve
+ Bot->>Member: Accept request
+ else No flag
+ Note over Bot: waits for an admin to accept
+ end
+ Member->>Bot: state = accept
+ Bot->>Ollama: prompt with public key
+ Ollama-->>Bot: Random greeting
+ Bot->>Member: Send message
+```
+
+The persona lives entirely in the prompt inside [`generate_message`](./main.py) - by default a "Herald of [Battle World](https://marvel.fandom.com/wiki/Battleworld_(Latverion))" that welcomes members in an RPG tone. Rewrite that prompt (and the sampling options below it) for your own community.
+
+**Note**: Creating channels and accepting requests requires the account to be a privileged member of the community.
+
+## Setup
+
+### 1. Install
+
+Install the SDK from [PyPI](https://pypi.org/project/status-sdk/) with the `community-greet` dependencies:
+
+```
+pip install "status-sdk[community-greet]"
+```
+
+Or, if you are working from a clone of the repository, install the same extra from the **repository root**:
+
+```
+pip install ".[community-greet]"
+```
+
+### 2. Install Ollama
+
+The greeting is generated by a **local** model, so [Ollama](https://ollama.com/download) must be installed and running. Pull the model you want to use:
+
+```
+ollama pull llama3.2
+```
+
+The name you pull is what goes into `MODEL_NAME` below.
+
+### 3. Configure
+
+Copy [`env.example`](./env.example) to `.env` in this folder and fill it in:
+
+```
+cp env.example .env
+```
+
+| Variable | What it is |
+|-----|-------------|
+| `PASSWORD` | The password of the greeting Status account. |
+| `NAME` | The [display name](../../docs/account.md#display-name) or ENS name of the account. If you have previously logged in with the SDK you can provide an ENS. For first time log ins, it is best to provide a [display name](../../docs/account.md#display-name). |
+| `MNEMONIC` | The [recovery phrase](https://status.app/help/profile/understand-your-status-keys-and-recovery-phrase) of the account. Used to recover it into the container. |
+| `COMMUNITY_URL` | The invite [`url`](../../docs/community.md#url) of the community to greet in. If the account is not a member yet, constructing the [`Community`](../../docs/community.md#communityaccount-community_idnone-urlnone) sends a join request instead - see [Membership](../../docs/community.md#membership). |
+| `MODEL_NAME` | The Ollama model used for the greeting, e.g. `llama3.2`. |
+
+### 4. Run
+
+The script loads its `.env` from the current directory, so run it from inside this folder:
+
+```
+cd examples/community-greet
+python main.py
+```
+
+On the first run building the image takes a few minutes. Then the bot starts listening:
+
+```
+[INFO] Successfully logged in!
+[INFO] Channel 'intro' created
+[INFO] Listening for incoming Battle World [0x03...] requests
+```
+
+The bot runs until you stop it with `Ctrl+C`.
+
+### Options
+
+| Flag | Default | What it does |
+|-----|-----|-------------|
+| `-c`, `--channel-name` | `intro` | The channel to greet new members in. Created if it does not exist yet. |
+| `-a`, `--approve` | `False` | Accept pending join requests automatically. If not passed then members will only be greeted once an admin has accepted. |
+
+```
+python main.py --channel-name welcome --no-approve
+```
diff --git a/examples/community-greet/env.example b/examples/community-greet/env.example
new file mode 100644
index 0000000..0d5ca8d
--- /dev/null
+++ b/examples/community-greet/env.example
@@ -0,0 +1,10 @@
+# Status Account setup
+PASSWORD = "your-password-here"
+NAME = "status-display-name"
+MNEMONIC = "phrase_1 phrase_2 phrase_3 phrase_4 phrase_5 phrase_6 phrase_7 phrase_8 phrase_9 phrase_10 phrase_11 phrase_12"
+
+# Community setup
+COMMUNITY_URL = "https://status.app/c/your-channel-name"
+
+# LLM setup
+MODEL_NAME = "your-model-name"
diff --git a/examples/community-greet/main.py b/examples/community-greet/main.py
new file mode 100644
index 0000000..953895e
--- /dev/null
+++ b/examples/community-greet/main.py
@@ -0,0 +1,135 @@
+from dotenv import load_dotenv
+from status_sdk import Account, Community, launch_docker_container, exceptions
+import os, ollama, argparse
+
+
+def parse_args() -> argparse.Namespace:
+ """
+ Parse the command line arguments of the greeter.
+
+ Output:
+ - the parsed `channel_name` and `approve` arguments
+ """
+ parser = argparse.ArgumentParser(
+ prog="community-greet",
+ description="Greet new Status Community members with an LLM generated message."
+ )
+ parser.add_argument(
+ "-c", "--channel-name",
+ default="intro",
+ help="The channel to greet new members in. Created if it does not exist yet. Defaults to '%(default)s'."
+ )
+ parser.add_argument(
+ "-a", "--approve",
+ action=argparse.BooleanOptionalAction,
+ default=False,
+ help="Accept pending join requests automatically. Skip the argument to only greet members an admin has accepted."
+ )
+ return parser.parse_args()
+
+
+def generate_message(public_key: str) -> str:
+ """
+ Generate greeting message for new users.
+
+ Parameters:
+ - `public_key` - the Status public key of the new joiner
+
+ Output:
+ - Personalized LLM message
+ """
+ prompt = """
+ You are the Herald of Battle World, a dark realm inspired by Marvel's Battleworld.
+
+ Welcome each new member with a unique, epic greeting as if they have just entered Battle World.
+
+ Rules:
+ - Always include the literal text "{public_key}" exactly as written.
+ - Never replace or modify "{public_key}".
+ - Write 1 short sentence.
+ - Use a mysterious, battle-hardened, RPG tone.
+ - Mention Battle World.
+ - Refer to the newcomer as a warrior, champion, survivor, contender, or traveller.
+ - End with a short call to action about battle, alliances, or survival.
+ - Do not mention AI, assistants, Discord, Status, or apps.
+ - No emojis or Markdown.
+ - Output only the greeting.
+
+ Example:
+ A new champion steps onto the scarred lands of Battle World. Welcome, {public_key}. The coming war will test your resolve—forge your legend.
+ """
+
+ response = ollama.chat(
+ model=os.environ.get("MODEL_NAME"),
+ messages=[{'role': 'user', 'content': prompt}],
+ options={
+ "temperature": 0.7,
+ "top_p": 0.9,
+ "top_k": 40,
+ "repeat_penalty": 1.1,
+ "num_predict": 100,
+ }
+ )
+
+ placeholder = "{public_key}"
+ mention = f"@{public_key}"
+ output = str(response.message.content).replace("\"", "").replace("—", "-")
+
+ # `str.format` would parse the whole model output, so any stray brace it writes raises
+ if placeholder in output:
+ output = output.replace(placeholder, mention)
+ else:
+ output = f"{mention}\n{output}"
+
+ return output
+
+def main(channel_name: str, approve: bool):
+ """
+ Listen for community join requests and greet every new member in `channel_name`.
+
+ Parameters:
+ - `channel_name` - the channel to greet new members in. Created if it does not exist yet
+ - `approve` - if `True`, pending join requests are accepted automatically. If `False`, only members accepted by an admin are greeted
+ """
+ launch_docker_container()
+ account = Account(backup_folder=os.path.dirname(__file__))
+ account.login(
+ password=os.environ["PASSWORD"],
+ name=os.environ["NAME"],
+ mnemonic=os.environ["MNEMONIC"]
+ )
+ community = Community(account, url=os.environ["COMMUNITY_URL"])
+
+ try:
+ community.create_channel(channel_name, "Greet new community members")
+ account.logger.info(f"Channel '{channel_name}' created")
+ except exceptions.CommunityDuplicateError:
+ account.logger.info(f"Channel '{channel_name}' already exists")
+
+ channel = community[channel_name]
+
+ account.logger.info(f"Listening for incoming {community.name} [{community.id}] requests")
+ pending_requests = []
+ for request in community.listen_requests():
+ member_public_key: str = request["public_key"]
+ request_id: str = request["request_id"]
+ if request["state"] == "pending" and member_public_key not in pending_requests:
+ pending_requests.append(member_public_key)
+
+ if approve and request["state"] == "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:
+ continue
+
+ message = generate_message(member_public_key)
+ channel.send_message(message)
+ pending_requests.remove(member_public_key)
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ load_dotenv()
+ main(args.channel_name, args.approve)
diff --git a/examples/group-chat-moderator/README.md b/examples/group-chat-moderator/README.md
new file mode 100644
index 0000000..14aaf4c
--- /dev/null
+++ b/examples/group-chat-moderator/README.md
@@ -0,0 +1,110 @@
+# Group Chat Moderator
+
+An **automatic moderator** for a [Group Chat](https://status.app/help/messaging/create-a-group-chat). The script logs into a Status account, listens for new messages **in real time**, and scores each one for toxicity with [Detoxify](https://github.com/unitaryai/detoxify) (local model). Authors of toxic messages are **warned**, and after a specified number of warnings they are **removed** from the chat.
+
+## How it works
+
+Every message that lands in the chat is handled in its **own thread** by [`check_message`](./main.py). The thread:
+
+1. Skips messages sent by the bot itself.
+2. Scores the message text with [Detoxify](https://github.com/unitaryai/detoxify) and takes the highest label (`toxicity`, `insult`, `threat`, ...).
+3. Ignores anything below the `threshold` (default `0.6`).
+4. Otherwise increments the author's warning count under a lock - the `warnings` dict is shared across threads, so the read-modify-write must be atomic.
+5. Sends a warning reply, or [`remove`](../../docs/group-chat.md#removepublic_keys)s the author once they hit the `warning_limit` (default `3`).
+
+```mermaid
+sequenceDiagram
+ actor Member as Group Chat Member
+ participant Listen as listen_messages
+ participant Check as check_message thread
+ participant Model as Detoxify model
+ participant Warnings as warning counts
+
+ Member->>Listen: sends message
+ Listen->>Check: spawns per-message thread
+ Check->>Model: score message text
+ Model-->>Check: highest label + score
+
+ alt Below Threshold
+ Check-->>Check: ignore
+ else At / Above Threshold
+ Check->>Warnings: increment author's count
+ Warnings-->>Check: current count
+ alt count < warning_limit
+ Check->>Member: send warning reply
+ else count >= warning_limit
+ Check->>Member: remove from chat
+ end
+ end
+```
+
+
+This is just one moderation policy. [`check_message`](./main.py) is self-contained, so you can rewrite it for your own use case - swap in a different model or keyword filter, adjust `threshold` and `warning_limit` or escalate through different labels. The listener loop stays the same and only the per-message logic changes.
+
+**Note**: Removing members requires the account to be the [administrator](../../docs/group-chat.md#administrator) of the chat. See [Moderation power](./README.md#moderation-power).
+
+## Setup
+
+### 1. Install
+
+Install the SDK from [PyPI](https://pypi.org/project/status-sdk/) with the `group-chat-moderator` dependencies:
+
+```
+pip install "status-sdk[group-chat-moderator]"
+```
+
+Or, if you are working from a clone of the repository, install the same extra from the **repository root**:
+
+```
+pip install ".[group-chat-moderator]"
+```
+
+This pulls in [Detoxify](https://github.com/unitaryai/detoxify) and its [PyTorch](https://pytorch.org/) backend. The first run downloads the model weights.
+
+
+**Note**: `detoxify` installs the CPU build of PyTorch by default. For faster inference on a CUDA GPU, uninstall `torch` and `torchvision`, then reinstall the GPU builds by following the instructions on [PyTorch's website](https://pytorch.org/).
+
+### 2. Configure
+
+Copy [`env.example`](./env.example) to `.env` in this folder and fill it in:
+
+```
+cp env.example .env
+```
+
+| Variable | What it is |
+|-----|-------------|
+| `PASSWORD` | The password of the moderating Status account. |
+| `NAME` | The [display name](../../docs/account.md#display-name) or ENS name of the account. If you have previously logged in with the SDK you can provide an ENS. For first time log ins, it is best to provide a [display name](../../docs/account.md#display-name). |
+| `MNEMONIC` | The [recovery phrase](https://status.app/help/profile/understand-your-status-keys-and-recovery-phrase) of the account. Used to recover it into the container. |
+| `GROUP_CHAT_ID` | The `id` of the group chat to moderate. Group chat IDs come from the [`chats`](../../docs/account.md#chats) property, where `type` is `group_chat`. |
+
+### 3. Run
+
+The script loads its `.env` from the current directory, so run it from inside this folder:
+
+```
+cd examples/group-chat-moderator
+python main.py
+```
+
+On the first run, [`launch_docker_container`](../../docs/utils.md#launch_docker_container) builds the Status Backend image, which takes a few minutes. Tthe bot starts listening:
+
+```
+[INFO] Successfully logged in!
+[INFO] Loading Detoxify [cpu]
+[INFO] Listening Group Chat Status Bots
+```
+
+Detoxify runs on the **GPU** automatically when CUDA is available (`[cuda]` above), and falls back to the CPU otherwise. The bot runs until you stop it with `Ctrl+C`.
+
+## Moderation power
+
+**This account acts as the moderator of the group chat.** To warn members it only needs to be in the chat, but to **remove** them it must be the [administrator](../../docs/group-chat.md#administrator) - only the admin can remove members. Point the moderator at a chat it created (or was made admin of), otherwise removals are rejected and members can only be warned.
+
+The moderation logic in this example is deliberately simple:
+
+- **One model, one threshold.** Every message is scored by [Detoxify](https://github.com/unitaryai/detoxify); anything scoring `0.6` or higher on any label counts as toxic. Tune `threshold` and `warning_limit` in [`check_message`](./main.py) to make moderation stricter or more lenient.
+- **Warnings are per public key.** The count lives only in memory, so restarting the bot resets everyone's warnings to zero.
+
+Detoxify is a machine-learning model and will make mistakes - both false positives and false negatives. Treat it as a first line of moderation, not a final judge.
diff --git a/examples/group-chat-moderator/env.example b/examples/group-chat-moderator/env.example
new file mode 100644
index 0000000..32ac755
--- /dev/null
+++ b/examples/group-chat-moderator/env.example
@@ -0,0 +1,7 @@
+# Status Account setup
+PASSWORD = "your-password-here"
+NAME = "status-display-name"
+MNEMONIC = "phrase_1 phrase_2 phrase_3 phrase_4 phrase_5 phrase_6 phrase_7 phrase_8 phrase_9 phrase_10 phrase_11 phrase_12"
+
+# Monitor messages from public chat
+GROUP_CHAT_ID = "group-chat-id"
diff --git a/examples/group-chat-moderator/main.py b/examples/group-chat-moderator/main.py
new file mode 100644
index 0000000..9875d0a
--- /dev/null
+++ b/examples/group-chat-moderator/main.py
@@ -0,0 +1,64 @@
+from dotenv import load_dotenv
+from status_sdk import Account, GroupChat, launch_docker_container
+from detoxify import Detoxify
+import os, threading, torch
+
+# `warnings` is shared by every check_message thread, so the
+# read-modify-write of a member's warning count must be atomic
+warnings_lock = threading.Lock()
+
+def check_message(account: Account, message: dict, warnings: dict, group_chat: GroupChat, model: Detoxify, threshold: float = 0.6, warning_limit: int = 3):
+ """
+ Score a single message and warn (or remove) its author.
+ """
+ public_key = message["from"]
+ if public_key == account.info["public_key"]:
+ return
+
+ label, score = max(model.predict(message["text"]).items(), key=lambda item: item[1])
+ account.logger.info(f"Message: '{message['text']}'\t\t{label} - {(score * 100):.2f}%")
+
+ if score < threshold:
+ return
+
+ with warnings_lock:
+ warnings[public_key] = warnings.get(public_key, 0) + 1
+ count = warnings[public_key]
+
+ if count < warning_limit:
+ group_chat.send_message(f"Warning {count} /{warning_limit} - @{public_key} please keep it civil.", message["id"])
+ account.logger.info(f"Sent warning to {public_key}")
+ elif count >= warning_limit:
+ group_chat.send_message(f"Removing @{public_key} member after {warning_limit} warnings.")
+ account.logger.info(f"Removed {public_key} from {group_chat.name}")
+ group_chat.remove(public_key)
+
+def main():
+ launch_docker_container()
+ load_dotenv()
+ account = Account(backup_folder=os.path.dirname(__file__))
+ account.login(
+ password=os.environ["PASSWORD"],
+ name=os.environ["NAME"],
+ mnemonic=os.environ["MNEMONIC"]
+ )
+ group_chat = GroupChat(account, os.environ["GROUP_CHAT_ID"])
+ warnings = {}
+
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ account.logger.info(f"Loading Detoxify [{device}]")
+ model = Detoxify("original", device=device)
+ account.logger.info(f"Listening Group Chat {group_chat.name}")
+ for message in account.listen_messages():
+ for chat in message["event"]["chats"]:
+ if chat["id"] != group_chat.id:
+ continue
+
+ threading.Thread(
+ target=check_message,
+ args=(account, chat["lastMessage"], warnings, group_chat, model),
+ daemon=True
+ ).start()
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 361afc2..4f979ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "status-sdk"
-version = "1.0.0"
+version = "1.1.0"
description = "Private chat. Communities. Multi-chain wallet. Browser. dApps all in one app, powered by SNT."
readme = "README.md"
-requires-python = ">=3.12"
+requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE.txt"]
authors = [{ name = "Status Research & Development GmbH" }]
@@ -16,7 +16,7 @@ classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
@@ -36,6 +36,14 @@ agents = [
"langchain-groq",
"python-dotenv",
]
+group-chat-moderator = [
+ "detoxify",
+ "python-dotenv"
+]
+community-greet = [
+ "ollama",
+ "python-dotenv"
+]
[project.urls]
Source = "https://github.com/status-im/status-python-sdk"
@@ -44,7 +52,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"]
+packages = ["status_sdk", "status_sdk.community"]
[tool.setuptools.package-data]
status_sdk = ["docker-compose.yaml"]
diff --git a/status_sdk/__init__.py b/status_sdk/__init__.py
index ac8d1b0..e9dd45f 100644
--- a/status_sdk/__init__.py
+++ b/status_sdk/__init__.py
@@ -1,5 +1,13 @@
+from importlib.metadata import PackageNotFoundError, version as _version
+
from .account import Account
+from .group_chat import GroupChat
+from .community.base import Community
from .utils import launch_docker_container
from . import exceptions
-__all__ = ["Account", "launch_docker_container", "exceptions"]
+try:
+ __version__ = _version("status-sdk")
+except PackageNotFoundError:
+ # Running from a source checkout that was never installed
+ __version__ = "dev"
diff --git a/status_sdk/account.py b/status_sdk/account.py
index 5537fc5..eba4df0 100644
--- a/status_sdk/account.py
+++ b/status_sdk/account.py
@@ -28,13 +28,20 @@ class Account:
"urls": "sharedurls",
"wallets": "wallet",
"account": "accounts",
- "identity": "multiaccounts"
+ "identity": "multiaccounts",
+ "settings": "settings"
}
__keccak256_selectors = {
"transfer": "a9059cbb" # keccak256("transfer(address,uint256)")[:4]
}
__ETH_ADDRESS = "0x0000000000000000000000000000000000000000"
-
+ __status_types = {
+ "auto": 1,
+ "dnd": 2,
+ "on": 3,
+ "off": 4
+ }
+ __INSTALLATION_NAME = "python-sdk"
def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_port: int = 9000, is_secure: bool = False, backup_folder: Optional[str] = None, volume_folder: Optional[str] = None):
"""
Work with your own Status App account
@@ -97,12 +104,16 @@ def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_po
"create_backup": f"{self.__http_base_url}PerformLocalBackup",
"load_backup": f"{self.__http_base_url}LoadLocalBackup",
"rpc": f"{self.__http_base_url}CallRPC",
- "transaction": f"{self.__http_base_url}SendTransactionV2"
+ "transaction": f"{self.__http_base_url}SendTransactionV2",
+ "sync_input_string": f"{self.__http_base_url}InputConnectionStringForBootstrappingV2",
+ "compress_key": f"{self.__http_base_url}SerializeLegacyKey",
+ "uncompress_key": f"{self.__http_base_url}MultiformatDeserializePublicKeyV2",
},
"socket": {
"signals": f"{self.__ws_base_url}signals"
}
}
+ self.__status = "on"
self.__media_port = media_port
self.__signal = Signal(self.__urls["socket"]["signals"])
# Initialize profile
@@ -227,9 +238,12 @@ def login(self, password: str, key_uid: Optional[str] = None, name: Optional[str
"preferred_name": event.get("preferred-name"),
"usernames": ens_info
},
+ "installation_id": None,
"logged_in_timestamp": datetime.datetime.now()
}
- self.__info["url"] = self.__call_rpc("urls", "shareUserURLWithData", [event["public-key"]]).get("result")
+ self.__info["url"] = self._call_rpc("urls", "shareUserURLWithData", [event["public-key"]]).get("result")
+ result = self._call_rpc("settings", "getSettings").get("result") or {}
+ self.__info["installation_id"] = result.get("installation-id")
# Messenger can be activated only when logged in
self.__start_messenger()
if is_recovery:
@@ -238,6 +252,14 @@ def login(self, password: str, key_uid: Optional[str] = None, name: Optional[str
self.logger.info("Successfully updated display name!")
self.__load_backup()
+ if self.__info["installation_id"]:
+ self._call_rpc("messaging", "setInstallationName", [self.__info["installation_id"], self.__INSTALLATION_NAME])
+
+ for sync_info in self._call_rpc("messaging", "getOurInstallations").get("result") or []:
+
+ if not sync_info["enabled"]:
+ self._call_rpc("messaging", "deleteInstallation", [sync_info["id"]])
+
return self
def logout(self):
@@ -303,7 +325,7 @@ def display_name(self) -> str:
@display_name.setter
def display_name(self, name: str):
self.__validate_display_name(name)
- output = self.__call_rpc("messaging", "setDisplayName", [name])
+ output = self._call_rpc("messaging", "setDisplayName", [name])
# It seems that if a valid name is given, it will be instantly updated
# However after tracing the signals, an `envelope.sent` is sent a bit
# after the name has been changed.
@@ -328,7 +350,7 @@ def bio(self, bio: Any):
if len(bio) > CHARACTERS:
raise exceptions.InvalidDisplayNameError(f"Bio cannot be longer than {CHARACTERS} characters...")
- self.__call_rpc("messaging", "setBio", [bio])
+ self._call_rpc("messaging", "setBio", [bio])
# It seems that if a valid bio is given, it will be instantly updated
# However after tracing the signals, an `envelope.sent` is sent a bit
# after the bio has been updated.
@@ -344,7 +366,7 @@ def profile_picture(self) -> Optional[Union[JpegImageFile, PngImageFile]]:
"""
Get current profile picture
"""
- identity_images = self.__call_rpc("identity", "getIdentityImages", [self.info["key_uid"]])
+ identity_images = self._call_rpc("identity", "getIdentityImages", [self.info["key_uid"]])
latest = max(identity_images.get("result", []), key=lambda item: item["clock"], default=None)
if not latest:
return None
@@ -392,7 +414,7 @@ def profile_picture(self, file_path: str):
*img.size
]
self.logger.info(f"Setting {file_path} as profile picture")
- self.__call_rpc("identity", "storeIdentityImage", params)
+ self._call_rpc("identity", "storeIdentityImage", params)
self.logger.info(f"Profile picture has been updated!")
@property
@@ -402,17 +424,17 @@ def contacts(self) -> dict[str, dict]:
This includes contacts that have interacted with us. If a contact has removed us (or the bot has removed us)
NOTE: We do not use internal state so we can get dynamic values such as:
- - Is currently active
- - Is currently blocked
- - Current display name
- - Current bio
+ - Is currently active
+ - Is currently blocked
+ - Current display name
+ - Current bio
Terminology for Status contact requests:
- approved - when both `contact_state` and `external_contact_state` are `mutual`
- sent request - when `contact_state` is `sent` and `external_contact_state` is `none`
- received request - when `contact_state` is `received`
"""
- data = self.__call_rpc("messaging", "contacts")
+ data = self._call_rpc("messaging", "contacts")
raw: list[dict] = data.get("result", [])
if not raw:
return {}
@@ -422,7 +444,7 @@ def contacts(self) -> dict[str, dict]:
contacts = {
contact["id"]: {
"public_key": contact["id"],
- "url": self.__call_rpc("urls", "shareUserURLWithData", [contact["id"]]).get("result"),
+ "url": self._call_rpc("urls", "shareUserURLWithData", [contact["id"]]).get("result"),
"chat_id": contact["id"],
"compressed_key": contact["compressedKey"],
"emojis": contact["emojiHash"],
@@ -459,7 +481,7 @@ def communities(self) -> list[dict]:
- Current number of community members
- Current channels' names, descriptions and permissions
"""
- data = self.__call_rpc("messaging", "communities")
+ data = self._call_rpc("messaging", "communities")
raw: list[dict] = data.get("result", [])
if not raw:
return []
@@ -468,12 +490,9 @@ def communities(self) -> list[dict]:
communities = [
{
"id": community["id"],
- "url": self.__call_rpc("urls", "shareCommunityURLWithData", [community["id"]]).get("result"),
+ "url": self._call_rpc("urls", "shareCommunityURLWithData", [community["id"]]).get("result"),
"name": community["name"],
"verified": community["verified"],
- "description": community["description"],
- "dialog": community["introMessage"],
- "leaving_message": community["outroMessage"],
"tags": community["tags"],
"is_member": community["isMember"],
"joined": community["verified"],
@@ -485,7 +504,6 @@ def communities(self) -> list[dict]:
{
"id": chat["id"],
"chat_id": community["id"] + chat["id"],
- "url": self.__call_rpc("urls", "shareCommunityChannelURLWithData", [community["id"], chat["id"]]).get("result"),
"name": chat["name"],
"description": chat["description"],
"permissions": {
@@ -520,7 +538,7 @@ def chats(self) -> list[dict]:
]
# Group chats in RPC endpoint are chat type 3
- data = self.__call_rpc("messaging", "activeChats")
+ data = self._call_rpc("messaging", "activeChats")
result: Optional[list[dict]] = data.get("result", [])
if not result:
result = []
@@ -544,7 +562,7 @@ def chains(self) -> dict[int, str]:
if self.__chains:
return self.__chains
- result = self.__call_rpc("wallets", "getEthereumChains").get("result", [])
+ result = self._call_rpc("wallets", "getEthereumChains").get("result", [])
key = "Prod"
self.__chains = {chain[key]["chainId"]: chain[key]["chainName"] for chain in result if chain.get(key)}
return self.__chains
@@ -557,7 +575,7 @@ def balance(self) -> pd.DataFrame:
empty = pd.DataFrame(columns=["timestamp", "address", "chain_id", "amount", "symbol"])
params = [[self.info["wallet_address"]], True]
- results = self.__call_rpc("wallets", "fetchOrGetCachedWalletBalances", params).get("result", {}).get(self.info["wallet_address"].lower(), [])
+ results = self._call_rpc("wallets", "fetchOrGetCachedWalletBalances", params).get("result", {}).get(self.info["wallet_address"].lower(), [])
if not results:
return empty.copy()
@@ -582,59 +600,20 @@ def balance(self) -> pd.DataFrame:
return balance.copy()
@property
- def community_members(self) -> pd.DataFrame:
+ def status(self) -> str:
"""
- Get enriched member data for all visible communities that the account belongs to.
-
- NOTE: This performs an additional RPC call for each member to fetch profile
- details, so it can be slower for large communities.
-
- This can be useful for analyzing community membership, such as identifying
- suspicious profiles or filtering for genuine community members.
+ Get the current active status of the account
"""
- data = self.__call_rpc("messaging", "communities")
- raw: list[dict] = data.get("result", [])
-
- if not raw:
- return pd.DataFrame()
-
- members = []
- for community in raw:
- for public_key, info in community.get("members", {}).items():
- response: dict = self.__call_rpc("messaging", "getContactByID", [public_key])
- result: dict = response.get("result") or {}
-
- url = self.__call_rpc("urls", "shareUserURLWithData", [public_key]).get("result")
-
- members.append({
- "community_id": community["id"],
- "community_name": community["name"],
- "public_key": public_key,
- "chat_id": public_key,
- "display_name": result.get("displayName"),
- "url": url,
- "bio": result.get("bio", ""),
- **info,
- })
-
- if not members:
- return pd.DataFrame()
+ return self.__status
- members = pd.DataFrame(members)
- members.columns = [self.__camel_to_snake(column) for column in members.columns]
-
- members = members.assign(
- # Accounts with no display names are populated as they appear in the Status URL
- display_name = members["display_name"].fillna(
- members["compressed_key"].str[:3] + "..." + members["url"].str[-6:]
- )
- ).drop(["last_update_clock", "color_id"], axis=1)\
- .rename(
- # Initial display name of the account when it was created
- columns={"alias": "status_alias"}
- )
+ @status.setter
+ def status(self, new_status: str):
+ selected = self.__status_types.get(new_status.lower())
+ if not selected:
+ raise exceptions.InvalidUserStatusError(f"Selected status '{selected}' is invalid... Available options: {' / '.join(self.__status_types.keys())}")
- return members.copy()
+ self.__status = new_status.lower()
+ self._call_rpc("messaging", "setUserStatus", [selected, ""])
def __getitem__(self, key: str) -> pd.DataFrame:
"""
@@ -647,7 +626,7 @@ def __getitem__(self, key: str) -> pd.DataFrame:
balance = self.balance
tokens = (balance["chain_id"].astype(str) + "-" + balance["address"]).to_list()
- result = self.__call_rpc("wallets", "fetchPrices", [tokens, [ccy]]).get("result", {})
+ result = self._call_rpc("wallets", "fetchPrices", [tokens, [ccy]]).get("result", {})
if result:
rates = pd.DataFrame([
{
@@ -670,21 +649,51 @@ def __getitem__(self, key: str) -> pd.DataFrame:
return balance.copy()
- def send_message(self, chat_id: str, message: str):
+ def send_message(self, chat_id: str, message: str, reply_to_message_id: Optional[str] = None) -> str:
"""
Send a message to the given chat.
Parameters:
- `chat_id` - the chat ID can be found in `self.chats`
- `message` - the message that will be sent. Currently only text messages are supported
+ - `reply_to_message_id` - the `id` of the message to reply to, as it appears in `self.get_messages()`. If not provided, the message is sent as a standalone message.
+
+ Output:
+ - The message ID
"""
+ self.info
+ if len(message) > 2_000:
+ raise exceptions.MessageTooLongError(f"Message cannot be longer than 2000 characters (got {len(message)})...")
+
params = [{
"chatId": chat_id,
"text": message,
"contentType": 1, # Send message only. Future versions can have different message types (audio, image, etc.)
- "responseTo": ""
+ "responseTo": reply_to_message_id if reply_to_message_id else ""
}]
- self.__call_rpc("messaging", "sendChatMessage", params)
+ response = self._call_rpc("messaging", "sendChatMessage", params)
+ error = response.get("error", {})
+ if error:
+ raise exceptions.InvalidContactError(error["message"])
+
+ return response["result"]["messages"][0]["id"]
+
+ def delete_message(self, id: str) -> bool:
+ """
+ Delete one of your own messages from a chat.
+
+ Parameters:
+ - `id` - the `id` of the message from `account.get_messages()`
+
+ Output:
+ - if `True` then the message was deleted. If `False` then the message was not deleted due to permissions.
+ """
+ self.info
+ response = self._call_rpc("messaging", "deleteMessageAndSend", [id])
+ error: dict = response.get("error", {})
+ if error:
+ self.logger.warning(f"Could not delete Message {id}... {error.get('message')}")
+ return not bool(error)
def listen_messages(self) -> Generator:
"""
@@ -705,6 +714,9 @@ def get_messages(self, chat_id: str, start_timestamp: Optional[datetime.datetime
- `chat_id` - the chat ID can be found in `self.chats`
- `start_timestamp` - the start timestamp for message extraction. If not provided all early messages will be fetched.
- `end_timestamp` - the end timestamp for message extraction. If not provided all latest messages will be fetched.
+
+ Output:
+ - All messages within the given range
"""
# NOTE: Order of params matters when making the RCP call
params = {
@@ -718,7 +730,7 @@ def get_messages(self, chat_id: str, start_timestamp: Optional[datetime.datetime
finished = False
while not finished:
- data = self.__call_rpc("messaging", "chatMessages", list(params.values()))
+ data = self._call_rpc("messaging", "chatMessages", list(params.values()))
result: dict[str, Union[str, list[dict]]] = data.get("result", {})
messages: Optional[list[dict]] = result.get("messages")
cursor: Optional[str] = result.get("cursor")
@@ -759,26 +771,23 @@ def add_contact(self, public_key: str, display_name: Optional[str] = None):
Send a contact request / approve a contact.
Parameters:
- - `public_key` - the contact's public key
+ - `public_key` - the contact's public key / chat key / URL
- `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)
+
if public_key == self.info["public_key"]:
return self
- contacts = list(self.contacts.values())
if not display_name:
- for contact in contacts:
- if public_key != contact["public_key"]:
- continue
-
- display_name = contact["display_name"]
- break
+ contacts = self.contacts
+ display_name = contacts.get(public_key, {}).get("display_name")
if not display_name:
raise exceptions.InvalidContactError(f"Cannot add contact {public_key}...\nPlease make sure you add display_name for contacts that you are sending friend requests to and have never interacted with before!")
params = [{"id": public_key, "nickname": "", "displayName": display_name, "ensName": ""}]
- self.__call_rpc("messaging", "addContact", params)
+ self._call_rpc("messaging", "addContact", params)
return self
def remove_contact(self, public_key: str) -> bool:
@@ -786,12 +795,21 @@ def remove_contact(self, public_key: str) -> bool:
Remove the contact / decline a contact request.
Parameters:
- - `public_key` - the contact's public key
+ - `public_key` - the contact's public key / chat key / URL
Output:
- If `True` the user has been removed. If `False` the user has not been removed (either not a contact or not a friend)
"""
- contact_info = self.contacts.get(public_key, {})
+ contacts = self.contacts
+ public_key = self.get_public_key(public_key)
+ contact_info = contacts.get(public_key, {})
+ if not contact_info:
+ for current_key, current_contact in contacts.items():
+ if public_key != current_contact["compressed_key"]:
+ continue
+ contact_info = current_contact
+ public_key = current_key
+ break
# Cannot remove a contact that is not in your contact
if not contact_info:
return False
@@ -799,35 +817,64 @@ def remove_contact(self, public_key: str) -> bool:
if contact_info["contact_state"] == "none":
return False
params = [public_key]
- self.__call_rpc("messaging", "removeContact", params)
+ self._call_rpc("messaging", "removeContact", params)
return True
- def send_request_community(self, url: str) -> Optional[datetime.datetime]:
+ def get_public_key(self, value: str) -> str:
"""
- Send a request to join a community
+ Extract the public key from the URL / Chat key.
+ If a URL is passed, it the contact key is converted to the public key.
+ If a contact key is passed, it is converted to the public key.
Parameters:
- - `url` - the community's URL
+ - `key` - contact key / public key / account URL
Output:
- - the timestamp the request was sent
+ - Public key which is longer than the
"""
- data = self.__call_rpc("urls", "parseSharedURL", [url])
- raw: dict = data.get("result", {})
- community_key = raw["community"]["communityId"]
+ def to_public_key(compressed_key: str) -> str:
+ """
+ Conver the compressed key that is in Status App to the actual public key
- params = [{"communityKey": community_key, "waitForResponse": True, "tryDatabase": True}]
- data = self.__call_rpc("messaging", "fetchCommunity", params)
- raw: dict = data.get("result", {})
- community_id = raw["id"]
+ Parameters:
+ - `compressed_key` - the Chat key from Status App
- params = [{
- "communityId": community_id,
- "addressesToReveal": [self.info["wallet_address"]],
- "airdropAddress": self.info["wallet_address"]
- }]
- data = self.__call_rpc("messaging", "requestToJoinCommunity", params)
- return datetime.datetime.fromtimestamp(raw.get("requestedToJoinAt", datetime.datetime.now().timestamp()))
+ Output:
+ - the public key
+ """
+ body = json.dumps({"key": compressed_key, "outBase": "f"})
+ public_key = requests.post(self.__urls["http"]["uncompress_key"], data=body).content.decode()
+ if "{" in public_key:
+ data = json.loads(public_key)
+ raise exceptions.PublicKeyError(data["error"])
+
+ return "0x" + public_key[5:]
+
+ self.info
+ if value.startswith("0x"):
+ return value
+
+ if value.startswith("zQ"):
+ return to_public_key(value)
+
+ if not value.startswith("http"):
+ raise exceptions.PublicKeyError(f"Invalid key {value}...\nPlease provide a public key (starts with `0x`), a chat key (starts with `zQ`) or an account URL (starts with `http`)!")
+
+ response = self._call_rpc("urls", "parseSharedURL", [value])
+ if response.get("error"):
+ raise exceptions.InvalidContactError(response["error"]["message"])
+
+ result: dict = response.get("result", {})
+ compressed_key: str = (result.get("contact") or {}).get("publicKey", "")
+ if len(compressed_key) > 0:
+ return to_public_key(compressed_key)
+
+ public_key = (result.get("community") or {}).get("communityId", "")
+
+ if len(public_key) == 0:
+ raise exceptions.InvalidContactError(f"Cannot extract a public key from {value}...\nPlease make sure that the URL is a Status account URL and not a community / channel one!")
+
+ return public_key
def backup(self) -> str:
"""
@@ -865,7 +912,7 @@ def get_tokens(self) -> pd.DataFrame:
columns = ["chainId", "address", "symbol", "decimals", "crossChainId"]
info = []
- result: list[dict] = self.__call_rpc("wallets", "getAllTokenLists").get("result", [])
+ result: list[dict] = self._call_rpc("wallets", "getAllTokenLists").get("result", [])
for current in result:
if len(current["tokens"]) == 0:
continue
@@ -930,7 +977,7 @@ def get_balance(self, token_addresses: Union[list[str], str], chain_ids: Union[l
tokens = self.__get_valid_tokens(chain_ids, token_addresses)
- result: dict[str, dict[str, dict[str, str]]] = self.__call_rpc("wallets", "getBalancesByChain", [wallets, tokens]).get("result", {})
+ result: dict[str, dict[str, dict[str, str]]] = self._call_rpc("wallets", "getBalancesByChain", [wallets, tokens]).get("result", {})
data = [
{
"chain_id": int(chain_id),
@@ -964,7 +1011,7 @@ def get_balance(self, token_addresses: Union[list[str], str], chain_ids: Union[l
if not ccy:
return data.copy()
- result = self.__call_rpc("wallets", "fetchPrices", [tokens, ccy]).get("result", {})
+ result = self._call_rpc("wallets", "fetchPrices", [tokens, ccy]).get("result", {})
if not result:
return data.copy()
@@ -1015,7 +1062,7 @@ def get_market(self, token_addresses: Union[list[str], str], chain_ids: Union[li
"currency": ccy,
**info
}
- for token_address, info in self.__call_rpc("wallets", "fetchMarketValues", [tokens, ccy]).get("result", {}).items()
+ for token_address, info in self._call_rpc("wallets", "fetchMarketValues", [tokens, ccy]).get("result", {}).items()
])
market_info: pd.DataFrame = market_info.assign(
timestamp = datetime.datetime.now(),
@@ -1195,7 +1242,7 @@ def verify(from_address: str, amount: float):
# (1) Get suggested routes
self.signal.connect()
with self.signal.expect("wallet.suggested.routes") as exp:
- self.__call_rpc("wallets", "getSuggestedRoutesAsync", [params])
+ self._call_rpc("wallets", "getSuggestedRoutesAsync", [params])
suggested_routes = exp.result
error = suggested_routes["event"].get("ErrorResponse", {})
@@ -1206,7 +1253,7 @@ def verify(from_address: str, amount: float):
params = [suggested_routes["event"]["Uuid"]]
# (2) Build transaction from Route
with self.signal.expect("wallet.router.sign-transactions") as exp:
- self.__call_rpc("wallets", "buildTransactionsFromRoute", params)
+ self._call_rpc("wallets", "buildTransactionsFromRoute", params)
# (3) Sign transaction
signed_transaction = exp.result
@@ -1214,7 +1261,7 @@ def verify(from_address: str, amount: float):
signatures = {}
for hash in event["signingDetails"]["hashes"]:
params = [hash, self.info["wallet_address"], self.info["password"]]
- sig = self.__call_rpc("wallets", "signMessage", params).get("result")
+ sig = self._call_rpc("wallets", "signMessage", params).get("result")
# Strip 0x
raw = sig[2:]
signatures[hash] = {
@@ -1226,7 +1273,7 @@ def verify(from_address: str, amount: float):
# (4) Send transaction
with self.signal.expect("wallet.router.transactions-sent") as exp:
params = [{"uuid": transaction_uuid, "signatures": signatures}]
- self.__call_rpc("wallets", "sendRouterTransactionsWithSignatures", params)
+ self._call_rpc("wallets", "sendRouterTransactionsWithSignatures", params)
event: dict[str, dict] = exp.result["event"]
# Usually just 1
@@ -1251,8 +1298,6 @@ def verify(from_address: str, amount: float):
return __swap_tokens(from_token, to_token, amount, chain_id)
-
-
def get_transactions(self, refresh: bool = False) -> pd.DataFrame:
"""
Get wallet transactions from all Alchemy chains.
@@ -1339,6 +1384,51 @@ def get_transactions(self, refresh: bool = False) -> pd.DataFrame:
self.__transactions = final.copy()
return self.__transactions.copy()
+ def sync(self, installation_id: str, name: Optional[str] = None):
+ """
+ Pair another device (installation) with the account, so accounts are synced.
+ Both devices must be logged in to the same Status account for the installation
+ to be known to the backend.
+
+ Parameters:
+ - `installation_id` - the id of the device to pair with.
+ - `name` - the name of the paired device.
+ """
+ if installation_id == self.info["installation_id"]:
+ return
+
+ params = [{"installationId": installation_id}]
+ output = self._call_rpc("messaging", "enableInstallationAndPair", params)
+ error = (output.get("error") or {}).get("message", "")
+ if error:
+ raise exceptions.DeviceSyncError(f"Could not sync with installation '{installation_id}' - {error}")
+
+ if not name:
+ return
+
+ params = [installation_id, {"name": name}]
+ output = self._call_rpc("messaging", "setInstallationMetadata", params)
+ error = (output.get("error") or {}).get("message", "")
+ # The device is already paired at this point, so a failed rename is not worth failing the sync over
+ if error:
+ self.logger.warning(f"Synced with installation '{installation_id}' but could not name it - {error}")
+
+ def unsync(self, installation_id: str):
+ """
+ Stop syncing with a device (installation) that was paired with `sync`.
+ The device can be paired again with `sync`.
+
+ Parameters:
+ - `installation_id` - the id of the device to stop syncing with. The account's own id is under `installation_id` in `info`
+ """
+ if installation_id == self.info["installation_id"]:
+ return
+
+ output = self._call_rpc("messaging", "disableInstallation", [installation_id])
+ error = (output.get("error") or {}).get("message", "")
+ if error:
+ raise exceptions.DeviceSyncError(f"Could not unsync from installation '{installation_id}' - {error}")
+
def __start_messenger(self):
"""
Start the decentralized messaging service.
@@ -1347,10 +1437,14 @@ def __start_messenger(self):
if self.__is_messenger_launched:
return
self.logger.info("Starting messaging")
- self.__call_rpc("messaging", "startMessenger")
- self.__signal.get("waku.connection.status.change")
+ self.signal.connect()
+ with self.signal.expect("waku.connection.status.change", timeout=60) as exp:
+ self._call_rpc("messaging", "startMessenger")
+
+ self.signal.disconnect()
self.__is_messenger_launched = True
self.logger.info("Messaging launched")
+ self.status = "on"
def __del__(self):
"""
@@ -1367,12 +1461,6 @@ def __del__(self):
except Exception:
pass
- def call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
- """
- For faster development purposes. Used only for development.
- """
- return self.__call_rpc(prefix, method_name, params)
-
def __load_backup(self):
"""
Try to load every file in the Docker volume
@@ -1405,7 +1493,7 @@ def __load_backup(self):
else:
self.logger.warning(error)
- def __call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
+ def _call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
"""
Make RPC calls to Status Backend
@@ -1435,7 +1523,6 @@ def __call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list,
}
if params:
data["params"] = params
-
response = requests.get(self.__urls["http"]["rpc"], json=data)
return response.json()
@@ -1454,7 +1541,7 @@ def __get_fiat_ccy(self) -> list[str]:
self.__iso4217_ccy = [
ccy.upper()
- for ccy in self.__call_rpc("wallets", "getCachedCurrencyFormats").get("result", {}).keys()
+ for ccy in self._call_rpc("wallets", "getCachedCurrencyFormats").get("result", {}).keys()
if len(ccy) == 3 and ccy.upper() != "XXX"
]
return self.__iso4217_ccy
@@ -1492,7 +1579,7 @@ def __camel_to_snake(self, name: str) -> str:
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
- def __validate_display_name(self, name: str) -> bool:
+ def __validate_display_name(self, name: str):
"""
Validate the display name based on Status App rules.
Validation most probably is dealt with on the GUI side
@@ -1520,5 +1607,3 @@ def __validate_display_name(self, name: str) -> bool:
if not re.fullmatch(r"[A-Za-z0-9 _-]+", name):
raise exceptions.InvalidDisplayNameError("Display name can contain only A-Z, 0-9, hyphens (-), underscores (_) and spaces.")
-
- return True
diff --git a/status_sdk/community/__init__.py b/status_sdk/community/__init__.py
new file mode 100644
index 0000000..a537ad8
--- /dev/null
+++ b/status_sdk/community/__init__.py
@@ -0,0 +1 @@
+from .base import Community
diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py
new file mode 100644
index 0000000..705377c
--- /dev/null
+++ b/status_sdk/community/base.py
@@ -0,0 +1,469 @@
+from ..account import Account
+from .. import exceptions
+from .channel import Channel
+from typing import Union, Optional, Generator
+import pandas as pd
+
+class Community:
+
+ __role_mapping = {
+ 0: "none",
+ 1: "owner",
+ 4: "admin",
+ 5: "token_master"
+ }
+
+ __request_states = {
+ 1: "pending",
+ 2: "reject",
+ 3: "accept",
+ 4: "cancel"
+ }
+
+ def __init__(self, account: Account, community_id: Optional[str] = None, url: Optional[str] = None):
+ """
+ Work with Status App Communities
+
+ Parameters:
+ - `account` - a logged in `Account`
+ - `community_id` - the Community's ID. If unknown, please provide `url`.
+ - `url` - the Community's URL. If unknown, please provide `community_id`
+ """
+ # Verify that the user is logged in
+ account.info
+ self.__account = account
+
+ if community_id:
+ self.__id = community_id
+ return
+
+ response = account._call_rpc("urls", "parseSharedURL", [url])
+ error = response.get("error", {})
+ if error:
+ raise exceptions.CommunityNotFoundError(error["message"])
+
+ self.__id = response["result"]["community"]["communityId"]
+ result: dict = self.__get_community_info()
+ # Account is a member -> actions can be used
+ if result["joined"]:
+ return
+
+ if result["requestedToJoinAt"] != 0:
+ # Account is pending -> no actions can be taken until approved
+ self.__account.logger.warning(f"Request for community {self.__id} is pending.")
+ self.__id = None
+ return
+
+ params = [account.info["public_key"], self.__id, [account.info["wallet_address"]]]
+ sign_params = account._call_rpc("messaging", "generateJoiningCommunityRequestsForSigning", params)["result"]
+ for p in sign_params:
+ p["password"] = account.info["password"]
+
+ signatures = account._call_rpc("messaging", "signData", [sign_params])["result"]
+ params = [{
+ "communityId": self.__id,
+ "addressesToReveal": [self.__account.info["wallet_address"]],
+ "airdropAddress": self.__account.info["wallet_address"],
+ "signatures": signatures
+ }]
+ result = self.__account._call_rpc("messaging", "requestToJoinCommunity", params)
+ self.__account.logger.info(f"Sent request to community {self.__id}")
+ self.__id = None
+
+ def leave(self):
+ """
+ Leave the community
+ """
+ self.__account._call_rpc("messaging", "leaveCommunity", [self.id])
+ self.__id = None
+
+ def kick(self, public_keys: Union[str, list[str]]):
+ """
+ Kick a member from the community.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs to kick. The formats can be mixed within the same list. Current members can be found in `members`
+ """
+ public_keys = self.__normalise_public_keys(public_keys)
+ for public_key in public_keys:
+ params = [self.id, self.__account.get_public_key(public_key)]
+ self.__account._call_rpc("messaging", "removeUserFromCommunity", params)
+
+ def ban(self, public_keys: Union[str, list[str]], delete_messages: bool = False):
+ """
+ Ban a member from the community. Banned members will appear in `banned_members`.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs to ban. The formats can be mixed within the same list. Current members can be found in `members`
+ - `delete_messages` - if `True`, all messages sent by the banned members are also deleted
+ """
+ public_keys = self.__normalise_public_keys(public_keys)
+ for public_key in public_keys:
+ params = [{"communityId": self.id, "user": self.__account.get_public_key(public_key), "deleteAllMessages": delete_messages}]
+ self.__account._call_rpc("messaging", "banUserFromCommunity", params)
+
+ def unban(self, public_keys: Union[str, list[str]]):
+ """
+ Unban a member from the community. Banned members can be found in `banned_members`.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs to unban. The formats can be mixed within the same list. Banned members can be found in `banned_members`
+ """
+ public_keys = self.__normalise_public_keys(public_keys)
+ for public_key in public_keys:
+ params = [{"communityId": self.id, "user": public_key}]
+ self.__account._call_rpc("messaging", "unbanUserFromCommunity", params)
+
+ def accept(self, pending_request_id: str):
+ """
+ Accept a pending member into the community. Pending members can be found in `pending_members`
+
+ Parameters:
+ - `pending_request_id` - the `request_id` of a member from `pending_members`
+ """
+ self.__accept_or_decline(pending_request_id, "accept")
+
+ def decline(self, pending_request_id: str):
+ """
+ Decline a pending member into the community. Pending members can be found in `pending_members`
+
+ Parameters:
+ - `pending_request_id` - the `request_id` of a member from `pending_members`
+ """
+ self.__accept_or_decline(pending_request_id, "decline")
+
+ def __accept_or_decline(self, pending_request_id: str, mode: str):
+ """
+ Shared logic for `accept` and `decline`. Resolves the `mode` to its RPC
+ call and validates that `pending_request_id` is an actual pending join
+ request before acting on it.
+
+ Parameters:
+ - `pending_request_id` - the `request_id` of a member from `pending_members`
+ - `mode` - either `accept` or `decline`, selecting which action to perform
+ """
+ mode_mapping = {
+ "accept": "acceptRequestToJoinCommunity",
+ "decline": "declineRequestToJoinCommunity"
+ }
+ rpc_call = mode_mapping[mode]
+ pending_request_ids = [member["request_id"] for member in self.pending_members + self.declined_members]
+ if pending_request_id not in pending_request_ids:
+ raise exceptions.CommunityPendingMemberError(f"Cannot {mode} '{pending_request_id}' - it is not a pending join request...")
+
+ params = [{"id": pending_request_id}]
+ self.__account._call_rpc("messaging", rpc_call, params)
+
+ def create_channel(self, name: str, description: str, emoji: Optional[str] = None, colour: Optional[str] = None, category_name: Optional[str] = None) -> Channel:
+ """
+ Create a new community channel.
+
+ Parameters:
+ - `name` - the channel name
+ - `description` - the channel description
+ - `emoji` - the channel emoji
+ - `colour` - the channel colour as a hex code, e.g. `#4360DF`. When omitted, a random default colour is chosen
+ - `category_name` - the name of an existing category to place the channel under, from `categories`. When omitted, the channel is not categorised
+
+ Output:
+ - the created `Channel`
+ """
+ category_id = self.categories.get(category_name, {}).get("id")
+ return Channel(self.__account, self.id, name=name, description=description, emoji=emoji, colour=colour, category_id=category_id)
+
+ def delete_channel(self, channel_name: str):
+ """
+ Delete a community channel by its name. Available channel names can be found in `channels`.
+
+ Parameters:
+ - `channel_name` - the name of the channel to delete
+ """
+ channel = self.__getitem__(channel_name)
+ params = [self.id, channel.id.replace(self.id, "")]
+ self.__account._call_rpc("messaging", "deleteCommunityChat", params)
+
+ def listen_requests(self) -> Generator:
+ """
+ Listen for commnunity requests
+ """
+ key = "requestsToJoinCommunity"
+ for message in self.__account.signal.listen("messages.new"):
+ event: dict = message.get("event", {})
+
+ if key not in event:
+ continue
+
+ for request in event[key]:
+ if request.get("communityId") != self.id:
+ continue
+
+ state = self.__request_states.get(request["state"])
+ if not state:
+ continue
+
+ yield {
+ "request_id": request["id"],
+ "state": state,
+ "public_key": request["publicKey"]
+ }
+
+ @property
+ def categories(self) -> dict[str, str]:
+ """
+ The community's categories, keyed by category ID.
+ Each category id has the `name` and `position` of the ID.
+ """
+ mapping = {
+ info["name"]: {
+ "id": community_id,
+ "position": info["position"]
+ }
+ for community_id, info in self.__get_community_info().get("categories", {}).items()
+ }
+ return mapping
+
+ @property
+ def name(self) -> str:
+ """
+ The community's name
+ """
+ result = self.__get_community_info()
+ return result["name"]
+
+ @property
+ def description(self) -> str:
+ """
+ The community's description
+ """
+ result = self.__get_community_info()
+ return result["description"]
+
+ @property
+ def introduction(self) -> str:
+ """
+ The community's introduction message when new users join
+ """
+ result = self.__get_community_info()
+ return result["introMessage"]
+
+ @property
+ def leave_message(self) -> str:
+ """
+ The community's leave message when a member leaves.
+ """
+ result = self.__get_community_info()
+ return result["outroMessage"]
+
+ def get_members(self, dataframe: bool = False) -> Union[dict[str, dict], pd.DataFrame]:
+ """
+ Current community members.
+
+ Parameters:
+ - `dataframe` - if `True` then a `dict` of the existing members will be returned. Use this where speed matters.
+ If `False` then a `pd.DataFrame` of the existing members will be returned. Use this for data related pipelines.
+
+ Output:
+ - `dict` or `DataFrame` of the current community members
+ """
+ raw_data: dict[str, dict] = self.__get_community_info().get("members", {})
+ if not dataframe:
+ return raw_data
+
+ members = []
+ for public_key, member_info in raw_data.items():
+ response: dict = self.__account._call_rpc("messaging", "getContactByID", [public_key])
+ result: dict = response.get("result", {})
+ if not result:
+ result = {}
+
+ url = self.__account._call_rpc("urls", "shareUserURLWithData", [public_key]).get("result")
+ members.append({
+ "public_key": public_key,
+ "chat_id": public_key,
+ "compressed_key": member_info["compressedKey"],
+ "emojis": member_info["emojiHash"],
+ "display_name": result.get("displayName"),
+ "alias": member_info["alias"],
+ "roles": [self.__role_mapping[role] for role in member_info.get("roles", [0])],
+ "bio": result.get("bio", ""),
+ "url": url
+ })
+ if not members:
+ return pd.DataFrame()
+
+ members = pd.DataFrame(members)
+ members = members.assign(
+ # Accounts with no display names are populated as they appear in the Status URL
+ display_name = members["display_name"].fillna(
+ members["compressed_key"].str[:3] + "..." + members["url"].str[-6:]
+ )
+ )
+ return members.copy()
+
+ @property
+ def channels(self) -> list[dict]:
+ """
+ High level information for all community channels
+ """
+ result = self.__get_community_info()
+ available_chats = [
+ {
+ "id": current["id"],
+ "name": current["name"],
+ "category": current["categoryID"] if len(current["categoryID"]) > 0 else None
+ }
+ for current in result["chats"].values()
+ ]
+ return available_chats
+
+ @property
+ def banned_members(self) -> list[str]:
+ """
+ Currently banned public keys
+ """
+ result = self.__get_community_info()
+ banned_states = [0, 4] # Banned, BanWithAllmessagesDeleted
+ public_keys = [
+ public_key
+ for public_key, member_state in result.get("pendingAndBannedMembers", {}).items()
+ if member_state in banned_states
+ ]
+ return public_keys
+
+ @property
+ def pending_members(self) -> list[dict[str, str]]:
+ """
+ Members who have to be accepted or rejected
+ """
+ return self.__pending_declined_members("pending")
+
+ @property
+ def declined_members(self) -> list[dict[str, str]]:
+ """
+ Members who have to be accepted or rejected
+ """
+ return self.__pending_declined_members("declined")
+
+ def __pending_declined_members(self, mode: str) -> list[dict[str, str]]:
+ """
+ Shared logic for `pending_members` and `declined_members`. Resolves the
+ `mode` to its RPC call and returns the members for that request state.
+
+ Parameters:
+ - `mode` - either `pending` or `declined`, selecting which requests to fetch
+
+ Output:
+ - a list of `{"public_key": ..., "request_id": ...}` for each request,
+ or an empty list if there are none
+ """
+ mode_mapping = {
+ "pending": "pendingRequestsToJoinForCommunity",
+ "declined": "declinedRequestsToJoinForCommunity"
+ }
+ selected_rpc_call = mode_mapping[mode]
+ members: Optional[list[dict]] = self.__account._call_rpc("messaging", selected_rpc_call, [self.id])["result"]
+ if not members:
+ return []
+
+ public_keys = [{"public_key": member["publicKey"], "request_id": member["id"]} for member in members]
+ return public_keys
+
+ @property
+ def id(self) -> str:
+ """
+ Get the Community's ID
+ """
+ if not self.__id:
+ raise exceptions.CommunityNotFoundError()
+
+ return self.__id
+
+ @property
+ def url(self) -> Optional[str]:
+ """
+ Get the URL of the community
+ """
+ return self.__account._call_rpc("urls", "shareCommunityURLWithChatKey", [self.id]).get("result")
+
+ 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()
+ category_mapping = {
+ category_id: info["name"]
+ for category_id, info in result.get("categories", {}).items()
+ }
+
+ chat_info = None
+ chat_mapping: dict[str, dict] = result["chats"]
+ for chat in self.channels:
+ if chat["name"] != channel_name:
+ continue
+
+ chat_info: Optional[dict] = chat_mapping.get(chat["id"])
+ break
+
+ if not chat_info:
+ raise exceptions.CommunityChannelNotFoundError(f"No community channel with id or name '{channel_name}' was found...")
+
+ chat_info["categoryName"] = category_mapping.get(chat_info["categoryID"])
+ return Channel(self.__account, self.id, chat_info)
+
+ def __len__(self) -> int:
+ """
+ Get the total number of members in the community
+ """
+ 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
+
+ Parameters:
+ - `public_keys` - a single or a list of public keys / chat keys / URLs
+
+ Output:
+ - the provided public keys that exist in the community
+ """
+ if isinstance(public_keys, str):
+ public_keys = [public_keys]
+
+ public_keys = pd.Series([
+ self.__account.get_public_key(public_key)
+ for public_key in public_keys
+ ]).str.lower()
+ members = self.members
+ query = members["public_key"].str.lower().isin(public_keys)
+ if query.sum() > 0:
+ return members.loc[query, "public_key"].to_list()
+
+ banned = pd.Series(self.banned_members)
+ query = banned.str.lower().isin(public_keys)
+ if query.sum() > 0:
+ return banned.loc[query].to_list()
+
+ raise exceptions.CommunityMembersError("None of the provided Public Keys were found in the community...")
diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py
new file mode 100644
index 0000000..deba03b
--- /dev/null
+++ b/status_sdk/community/channel.py
@@ -0,0 +1,348 @@
+from ..account import Account
+from .. import exceptions
+from typing import Optional
+import re, datetime, random, unicodedata
+
+class Channel:
+
+ __perimission_mapping = {
+ 0: "unknown",
+ 1: "auto_accept",
+ 2: "manual_accept"
+ }
+
+ __STATUS_COLOURS = [
+ "#FF7D46", # Orange
+ "#F6B03C", # Yellow
+ "#1992D7", # Sky
+ "#7140FD" # Purple
+ ]
+
+ # Common single-codepoint emoji ranges (heuristic, not the full Unicode emoji data)
+ __EMOJI_RANGES = (
+ (0x1F300, 0x1FAFF), # symbols & pictographs (emoticons, transport, supplemental, extended-A)
+ (0x2600, 0x27BF), # misc symbols & dingbats
+ (0x2B00, 0x2BFF), # misc symbols & arrows
+ (0x2300, 0x23FF), # misc technical (e.g. ⌚, ⏳)
+ )
+
+ # Picked at random as the channel emoji when none is provided
+ __DEFAULT_EMOJIS = (
+ "😀", "🤖", "🚀", "🌟", "🔥", "💬", "📢", "🎨", "🧠", "⚡",
+ "💡", "📌", "🎯", "🌈", "🎮", "📚", "🔔", "💎", "🌍", "🛰",
+ )
+
+ def __init__(self, account: Account, community_id: str, chat_info: Optional[dict] = None, name: Optional[str] = None, description: Optional[str] = None, emoji: Optional[str] = None, colour: Optional[str] = None, category_id: Optional[str] = None):
+ """
+ Work with Status App Community Channels (chats).
+ This class is automatically handled in `class Community`
+
+ Parameters:
+ - `account` - a logged in `Account`
+ - `community_id` - the Community's ID
+ - `chat_info` - channel information from
+ """
+ account.info
+ # Verify that the user is logged in
+ self.__account = account
+ self.__community_id = community_id
+
+ if chat_info:
+ self.__id: str = community_id + chat_info["id"]
+ return
+
+ self.__validate_name(name)
+ self.__validate_description(description)
+
+ if not colour:
+ colour = random.choice(self.__STATUS_COLOURS)
+
+ self.__validate_colour(colour)
+
+ if not emoji:
+ emoji = random.choice(self.__DEFAULT_EMOJIS)
+
+ self.__validate_emoji(emoji)
+ payload = {
+ "identity": {
+ "display_name": name,
+ "color": colour,
+ "description": description,
+ "emoji": emoji
+ },
+ "viewersCanPostReactions": True,
+ "hideIfPermissionsNotMet": True,
+ "permissions": {"access": 1},
+ }
+ if category_id:
+ payload["category_id"] = category_id
+
+ response: dict = account._call_rpc("messaging", "createCommunityChat", [community_id, payload])
+ result = response.get("result", {})
+ if not result:
+ error: dict = response.get("error", {})
+ message: str = error.get("message", f"Could not create channel '{name}' in community '{community_id}'...")
+ if "duplicate" in message:
+ raise exceptions.CommunityDuplicateError(f"Channel '{name}' already exists in community '{community_id}'...")
+ raise exceptions.CommunityChannelCreationError(message)
+
+ chat: dict = result["chats"][0]
+ self.__id = chat["id"]
+
+ @property
+ def id(self) -> str:
+ """
+ Chat ID is a combination of Community ID and channel ID
+ """
+ if not self.__id:
+ raise exceptions.CommunityChannelNotFoundError()
+ return self.__id
+
+ @property
+ def can_post(self) -> bool:
+ """
+ If the account is allowed to send messages
+ """
+ return self.__get_channel_info()["canPost"]
+
+ @property
+ def description(self) -> str:
+ """
+ The Community Chat's description
+ """
+ return self.__get_channel_info()["description"]
+
+ @description.setter
+ def description(self, value: str):
+ self.__validate_description(value)
+ self.__edit_channel(description=value)
+
+ @property
+ def name(self) -> str:
+ """
+ Get the current name of the Community Chat
+ """
+ return self.__get_channel_info()["name"]
+
+ @name.setter
+ def name(self, value: str):
+ self.__validate_name(value)
+ self.__edit_channel(name=value)
+
+ @property
+ def colour(self) -> str:
+ """
+ Get the current colour of the Community Chat
+ """
+ return self.__get_channel_info()["color"]
+
+ @colour.setter
+ def colour(self, value: str):
+ self.__validate_colour(value)
+ self.__edit_channel(colour=value)
+
+ @property
+ def emoji(self) -> Optional[str]:
+ """
+ Get the current emoji of the Community Chat
+ """
+ selected_emojis: str = self.__get_channel_info()["emoji"]
+ return selected_emojis.strip() if len(selected_emojis) > 0 else None
+
+ @emoji.setter
+ def emoji(self, value: str):
+ """
+ Skin-tone and Zero-Width Joiner sequences are not supported.
+ """
+ self.__validate_emoji(value)
+ self.__edit_channel(emoji=value)
+
+ def send_message(self, message: str, reply_to_message_id: Optional[str] = None):
+ """
+ Send a message to the Community chat.
+
+ Parameters:
+ - `message` - the message that will be sent. Currently only text messages are supported
+ - `reply_to_message_id` - the `id` of the message to reply to, as it appears in `self.get_messages()`. If not provided, the message is sent as a standalone message.
+
+ Output:
+ - The message ID
+ """
+ return self.__account.send_message(self.id, message, reply_to_message_id)
+
+
+ def get_messages(self, start_timestamp: Optional[datetime.datetime] = None, end_timestamp: Optional[datetime.datetime] = None) -> list[dict]:
+ """
+ Get all of the messages in the given start and end timestamps.
+ Messages are returned in descending order (newest to oldest).
+ Messages can be fetched for removed contacts as well.
+
+ Parameters:
+ - `start_timestamp` - the start timestamp for message extraction. If not provided all early messages will be fetched.
+ - `end_timestamp` - the end timestamp for message extraction. If not provided all latest messages will be fetched.
+
+ Output:
+ - All messages within the given range
+ """
+ return self.__account.get_messages(self.id, start_timestamp, end_timestamp)
+
+ def delete_message(self, id: str) -> bool:
+ """
+ Delete one of your own Community messages. If you are an admin,
+ you can delete other users' messages as well.
+
+ Parameters:
+ - `id` - the `id` of the message from `community["community-chat-id"].get_messages()`.
+
+ Output:
+ - if `True` then the message was deleted. If `False` then the message was not deleted due to permissions.
+ """
+ self.name
+ return self.__account.delete_message(id)
+
+ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None, colour: Optional[str] = None, description: Optional[str] = None):
+ """
+ Modify chat related properties.
+
+ NOTE: `editCommunityChat` overwrites the whole chat identity, so any field
+ left out of `chat_setup` is cleared. That is why every current value is filled
+ in first, and only the provided arguments override it.
+ """
+ channel_setup = {
+ "identity": {
+ "display_name": self.name,
+ "emoji": self.emoji,
+ "color": self.colour,
+ "description": self.description
+ },
+ "category_id": self.__get_channel_info()["categoryID"],
+ "position": self.__get_channel_info()["position"]
+ }
+
+ if name:
+ channel_setup["identity"]["display_name"] = name
+
+ if emoji:
+ channel_setup["identity"]["emoji"] = emoji
+
+ if colour:
+ channel_setup["identity"]["color"] = colour
+
+ if description:
+ channel_setup["identity"]["description"] = description
+
+ params = [self.__community_id, self.id, channel_setup]
+ self.__account._call_rpc("messaging", "editCommunityChat", params)
+
+ def __get_channel_info(self) -> dict:
+ """
+ Get information for the current channel.
+ """
+ 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"])
+
+ chats: dict[str, dict] = response["result"]["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()
+ }
+ selected_chat["categoryName"] = category_mapping.get(selected_chat["categoryID"])
+ return selected_chat
+
+ def __validate_name(self, name: str):
+ """
+ Validate and normalize a community channel name based on Status App rules.
+
+ Status App validation rules:
+ - Only letters, numbers, underscores (_), periods (.) and hyphens (-) allowed
+ - Whitespaces are replaced with hyphens (-)
+ - Cannot be more than 24 characters long
+
+ Parameters:
+ - `name` - the community channel name to validate
+ """
+ if not isinstance(name, str):
+ raise exceptions.InvalidCommunityChannelNameError("Community channel name must be a string.")
+
+ # Status App replaces whitespaces with hyphens in channel names
+ name = name.replace(" ", "-")
+
+ if not 1 <= len(name) <= 24:
+ raise exceptions.InvalidCommunityChannelNameError("Community channel name must be between 1 and 24 characters long.")
+
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", name):
+ raise exceptions.InvalidCommunityChannelNameError("Community channel name can contain only letters, numbers, underscores (_), periods (.) and hyphens (-).")
+
+ def __validate_description(self, description: str) -> bool:
+ """
+ Validate a community channel description based on Status App rules.
+
+ Status App validation rules:
+ - Only letters, numbers, underscores (_), periods (.), whitespaces and hyphens (-) allowed
+ - Must be between 1 and 140 characters long
+
+ Parameters:
+ - `description` - the community channel description to validate
+ """
+ if not isinstance(description, str):
+ raise exceptions.InvalidCommunityChannelDescriptionError("Community channel description must be a string.")
+
+ if not 1 <= len(description) <= 140:
+ raise exceptions.InvalidCommunityChannelDescriptionError("Community channel description must be between 1 and 140 characters long.")
+
+ if not re.fullmatch(r"[A-Za-z0-9_. -]+", description):
+ raise exceptions.InvalidCommunityChannelDescriptionError("Community channel description can contain only letters, numbers, underscores (_), periods (.), whitespaces and hyphens (-).")
+
+ def __validate_colour(self, colour: str):
+ """
+ Validate a community channel colour based on Status App rules.
+
+ Status App validation rules:
+ - Must be a hex colour code, e.g. `#4360DF`
+ - Starts with a `#` followed by 3 (`#RGB`) or 6 (`#RRGGBB`) hex digits
+ - Hex digits are case-insensitive (`0-9`, `a-f`, `A-F`)
+
+ Parameters:
+ - `colour` - the community channel colour to validate
+ """
+ if not isinstance(colour, str):
+ raise exceptions.InvalidCommunityChannelColourError("Community channel colour must be a string.")
+
+ if not re.fullmatch(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})", colour):
+ raise exceptions.InvalidCommunityChannelColourError("Community channel colour must be a hex colour code, e.g. #4360DF.")
+
+ def __validate_emoji(self, emoji: str):
+ """
+ Validate that `emoji` is a single "normal" emoji.
+
+ NOTE: This is a stdlib-only heuristic based on common emoji Unicode
+ ranges. It covers standard single-codepoint emoji, optionally with a
+ trailing variation selector (e.g. ❤️), but does not handle
+ multi-codepoint sequences such as skin tones, flags or ZWJ emoji
+ (e.g. 👨👩👧). For exact validation use the `emoji` package.
+
+ Parameters:
+ - `emoji` - the community channel emoji to validate
+ """
+ if not isinstance(emoji, str):
+ raise exceptions.InvalidCommunityChannelEmojiError("Community channel emoji must be a string.")
+
+ # Drop the variation selector (U+FE0F) so ❤️ is treated the same as ❤
+ normalized = "".join(char for char in emoji if ord(char) != 0xFE0F)
+ if len(normalized) != 1:
+ raise exceptions.InvalidCommunityChannelEmojiError("Community channel emoji must be a single emoji.")
+
+ codepoint = ord(normalized)
+ if not any(start <= codepoint <= end for start, end in self.__EMOJI_RANGES):
+ raise exceptions.InvalidCommunityChannelEmojiError(f"'{emoji}' is not a valid emoji.")
diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py
index dccea64..ed2140d 100644
--- a/status_sdk/exceptions.py
+++ b/status_sdk/exceptions.py
@@ -11,13 +11,78 @@ class WalletNotConfiguredError(Exception):
def __init__(self, msg: Optional[str] = None):
super().__init__(msg or "Cannot use this wallet method without setting `infura_token`, `alchemy_token` and `coingecko_api_key` when calling `login`.")
+class InvalidCommunityKeyError(ValueError):
+ pass
+
+class CommunityNotFoundError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "Please initialize the class with a valid `community_id` / make sure that you have been accepted in the community to use the class...")
+
+class CommunityChannelNotFoundError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "The community channel was not found! The channel does not exist...")
+
+class CommunityMembersError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "Please provide valid Public Keys from the community only...")
+
+class CommunityPendingMemberError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "The given request id is not a pending join request...")
+
+class CommunityChannelCreationError(Exception):
+ pass
+
+class CommunityDuplicateError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "A community item with this name already exists! Please pick a different name...")
+
+class InvalidUserStatusError(ValueError):
+ pass
+
class InvalidDisplayNameError(ValueError):
pass
+class InvalidGroupChatNameError(ValueError):
+ pass
+
+class InvalidCommunityChannelNameError(ValueError):
+ pass
+
+class InvalidCommunityChannelDescriptionError(ValueError):
+ pass
+
+class InvalidCommunityChannelColourError(ValueError):
+ pass
+
+class InvalidCommunityChannelEmojiError(ValueError):
+ pass
+
+class GroupChatCreationError(Exception):
+ pass
+
+class GroupChatAlreadyExistsError(Exception):
+ pass
+
+class GroupChatNotFoundError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "Please `create` the chat or initialize the class with `chat_id`")
+
+class GroupChatMembersError(Exception):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "The Group Chat has no members...")
+
+class PublicKeyError(Exception):
+ pass
+
class InvalidContactError(ValueError):
def __init__(self, msg: Optional[str] = None):
super().__init__(msg or "Please provide either a Key Unique Identifier (key_uid) or a Display Name / ENS (name)...")
+class MessageTooLongError(ValueError):
+ def __init__(self, msg: Optional[str] = None):
+ super().__init__(msg or "Message cannot be longer than 2000 characters...")
+
class InvalidCurrencyError(Exception):
pass
@@ -27,6 +92,9 @@ class InvalidTokenError(Exception):
class BackupError(Exception):
pass
+class DeviceSyncError(Exception):
+ pass
+
class ProfilePictureError(Exception):
pass
diff --git a/status_sdk/group_chat.py b/status_sdk/group_chat.py
new file mode 100644
index 0000000..014a687
--- /dev/null
+++ b/status_sdk/group_chat.py
@@ -0,0 +1,325 @@
+from .account import Account
+from . import exceptions
+from typing import Union, Optional
+import re, datetime
+
+class GroupChat:
+
+ __TOTAL_MEMBERS = 20 # https://status.app/help/messaging/create-a-group-chat
+ def __init__(self, account: Account, chat_id: Optional[str] = None):
+ """
+ Work with your own Status App Group Chats.
+
+ Parameters:
+ - `account` - a logged in `Account`
+ - `chat_id` - a group chat `id` from `.chats` in `Account`
+ """
+ # Verify that the user is logged in
+ account.info
+ self.__account = account
+ self.__id = None
+
+ if not chat_id:
+ return
+
+ chat = self.__get_group_info(chat_id)
+ self.__id = chat["id"]
+ is_admin = self.__extract_admin_public_key(chat["id"]) == self.__account.info["public_key"]
+ self.__account.logger.info(f"Account is{'' if is_admin else ' NOT'} admin.")
+
+ def create(self, public_keys: Union[list[str], str], name: str):
+ """
+ Create a Group Chat from the given public keys.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs of the members to create the chat with. The formats can be mixed within the same list and can be found in `contacts` in `Account`
+ - `name` - the name of the Group Chat. Must follow the Status App naming rules
+
+ Output:
+ - the `GroupChat` itself, so calls can be chained
+ """
+ if self.__id:
+ raise exceptions.GroupChatAlreadyExistsError("Chat has already been created! To create a new one, please initialize a new `GroupChat`...")
+
+ self.__validate_name(name)
+ public_keys = self.__get_public_keys(public_keys)
+ if len(public_keys) > self.__TOTAL_MEMBERS:
+ raise exceptions.GroupChatCreationError(f"Group chats can have up to {self.__TOTAL_MEMBERS} members. Please consider creating a Status Community...")
+
+ response: dict = self.__account._call_rpc("messaging", "createGroupChatWithMembers", [name, public_keys])
+ error = response.get("error")
+ if error:
+ raise exceptions.GroupChatCreationError(error["message"])
+
+ chat: dict = response["result"]["chats"][0]
+ self.__id = chat["id"]
+ self.__account.logger.info(f"Created group chat {name} [{self.id}]")
+ return self
+
+ def send_message(self, message: str, reply_to_message_id: Optional[str] = None) -> str:
+ """
+ Send a message to the group chat.
+
+ Parameters:
+ - `message` - the message that will be sent. Currently only text messages are supported
+ - `reply_to_message_id` - the `id` of the message to reply to, as it appears in `self.get_messages()`. If not provided, the message is sent as a standalone message.
+
+ Output:
+ - The message ID
+ """
+ return self.__account.send_message(self.id, message, reply_to_message_id)
+
+
+ def get_messages(self, start_timestamp: Optional[datetime.datetime] = None, end_timestamp: Optional[datetime.datetime] = None) -> list[dict]:
+ """
+ Get all of the messages in the given start and end timestamps.
+ Messages are returned in descending order (newest to oldest).
+ Messages can be fetched for removed contacts as well.
+
+ Parameters:
+ - `start_timestamp` - the start timestamp for message extraction. If not provided all early messages will be fetched.
+ - `end_timestamp` - the end timestamp for message extraction. If not provided all latest messages will be fetched.
+
+ Output:
+ - All messages within the given range
+ """
+ return self.__account.get_messages(self.id, start_timestamp, end_timestamp)
+
+ def remove(self, public_keys: Union[list[str], str]):
+ """
+ Remove members from the Group Chat. Only the administrator of the chat can remove members.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs of the members to remove. The formats can be mixed within the same list and can be found in `self.members`
+
+ Output:
+ - the `GroupChat` itself, so calls can be chained
+ """
+
+ if len(self.members.keys()) == 0:
+ self.__account.logger.error("There are no members to remove from the Group Chat...")
+ return
+
+ if not self.is_admin:
+ raise exceptions.GroupChatMembersError("Only administrators can remove members from")
+
+ public_keys = self.__get_public_keys(public_keys)
+ current_members = self.members.keys()
+ public_keys = [public_key for public_key in public_keys if public_key in current_members]
+ if len(public_keys) == 0:
+ raise exceptions.GroupChatMembersError("Please provide valid Public Keys from the chat only...")
+
+ params = [self.id, public_keys]
+ response: dict = self.__account._call_rpc("messaging", "removeMembersFromGroupChat", params)
+ self.__action_log(public_keys, "remove")
+ return self
+
+ def add(self, public_keys: Union[list[str], str]):
+ """
+ Add members to the Group Chat.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs of the members to add. The formats can be mixed within the same list and can be found in `contacts` in `Account`
+
+ Output:
+ - the `GroupChat` itself, so calls can be chained
+ """
+ public_keys = self.__get_public_keys(public_keys)
+ current_members = list(self.members.keys())
+ public_keys = [
+ public_key
+ for public_key in public_keys
+ if public_key not in current_members
+ ]
+ if len(public_keys) + len(current_members) > self.__TOTAL_MEMBERS:
+ self.__account.logger.warning(f"Too many members in the Group Chat! Group chats can have up to {self.__TOTAL_MEMBERS} members. Please consider creating a Status Community...")
+ return
+
+ params = [self.id, public_keys]
+ response: dict = self.__account._call_rpc("messaging", "addMembersToGroupChat", params)
+ error = response.get("error", {})
+ if error:
+ raise exceptions.GroupChatMembersError(response["error"]["message"])
+
+ self.__action_log(public_keys, "add")
+ return self
+
+ def leave(self):
+ """
+ Leave the Group Chat. The internal state is cleared afterwards,
+ so the `GroupChat` must be re-initialized with a `chat_id`
+ (or a new one must be created) before it can be used again.
+
+ Output:
+ - the `GroupChat` itself, so calls can be chained
+ """
+ self.__account._call_rpc("messaging", "leaveGroupChat", [self.id, True])
+ self.__id = None
+ self.__account.logger.info(f"Left group chat {self.name} [{self.id}]")
+ return self
+
+ def delete_message(self, id: str) -> bool:
+ """
+ Delete one of your own Group Chat messages.
+
+ Parameters:
+ - `id` - the `id` of the message from `group_chat.get_messages()`.
+
+ Output:
+ - if `True` then the message was deleted. If `False` then the message was not deleted due to permissions.
+ """
+ self.name
+ return self.__account.delete_message(id)
+
+ @property
+ def members(self) -> dict[str, dict]:
+ """
+ The current members in the chat, mapped by their public key.
+ """
+ members = {}
+ chat = self.__get_group_info(self.id)
+ for member in chat["members"]:
+ response: dict = self.__account._call_rpc("messaging", "getContactByID", [member["id"]])
+ result = response["result"]
+ members[member["id"]] = {
+ "public_key": result["id"],
+ "url": self.__account._call_rpc("urls", "shareUserURLWithData", [result["id"]]).get("result"),
+ "display_name": result["displayName"],
+ "compressed_key": result["compressedKey"],
+ "admin": self.__extract_admin_public_key(self.id) == result["id"]
+ }
+ return members
+
+ @property
+ def is_admin(self) -> bool:
+ """
+ If `True` then the `Account` is the administrator of the group
+ """
+ chat = self.__get_group_info(self.id)
+ return self.__extract_admin_public_key(chat["id"]) == self.__account.info["public_key"]
+
+ @property
+ def name(self) -> str:
+ """
+ Get the current chat's name
+ """
+ chat = self.__get_group_info(self.id)
+ return chat["name"]
+
+ @name.setter
+ def name(self, name: str):
+ self.__validate_name(name)
+ self.__account._call_rpc("messaging", "changeGroupChatName", [self.id, name])
+ self.__account.signal.get("envelope.sent")
+
+ @property
+ def id(self) -> str:
+ """
+ Get the chat's ID
+ """
+ if not self.__id:
+ raise exceptions.GroupChatNotFoundError()
+
+ return self.__id
+
+ def __extract_admin_public_key(self, chat_id: str) -> str:
+ """
+ Extract the Admin's public key from a Group Chat.
+ A Group Chat's ID has the administrator's public key appended to it.
+
+ Parameters:
+ - `chat_id` - a valid Group Chat ID
+
+ Output:
+ - the public key of the Group Chat's administrator
+ """
+ return chat_id[chat_id.index("0x"):]
+
+ def __get_public_keys(self, public_keys: Union[list[str], str]) -> list[str]:
+ """
+ Convert user input public keys to a list. The `Account`'s own public key is filtered out,
+ as the `Account` cannot add or remove itself from a Group Chat.
+
+ Parameters:
+ - `public_keys` - a single value or a list of public keys / chat keys / account URLs. The formats can be mixed within the same list
+
+ Output:
+ - the unique public keys as a list, without the `Account`'s own public key
+ """
+ if not isinstance(public_keys, (list, str)):
+ pass
+
+ if isinstance(public_keys, str):
+ public_keys = [public_keys]
+
+ public_keys = [
+ self.__account.get_public_key(public_key)
+ for public_key in public_keys
+ if public_key not in [self.__account.info["public_key"], self.__account.info["compressed_key"], self.__account.info["url"]]
+ ]
+ if len(public_keys) == 0:
+ raise exceptions.PublicKeyError("No public keys were given to the method...")
+
+ return list(set(public_keys))
+
+ @property
+ def available_slots(self) -> bool:
+ return self.__TOTAL_MEMBERS - len(self.members)
+
+ def __validate_name(self, name: str):
+ """
+ Validate the Group chat name based on Status App rules.
+
+ Status App validation rules:
+ - Only letters, numbers, underscores (_), periods (.), whitespaces and hyphens (-) allowed
+ - Must be between 1 and 30 characters long
+
+ Parameters:
+ - `name` - the group chat name to validate
+
+ Output:
+ - `True` if the name is valid. Otherwise a custom exception is raised
+ """
+
+ if not isinstance(name, str):
+ raise exceptions.InvalidGroupChatNameError("Group chat name must be a string.")
+
+ if not 1 <= len(name) <= 30 or name == " ":
+ raise exceptions.InvalidGroupChatNameError("Group chat name must be between 1 and 30 characters long.")
+
+ if not re.fullmatch(r"[A-Za-z0-9_. \t-]+", name):
+ raise exceptions.InvalidGroupChatNameError("Group chat name can contain only letters, numbers, underscores (_), periods (.), whitespaces and hyphens (-).")
+
+ def __action_log(self, public_keys: list[str], action: str):
+ """
+ Log how many members were affected by an `add` / `remove` action.
+ The `action` is converted to its past tense, so `add` is logged as
+ `Added` and `remove` is logged as `Removed`.
+
+ Parameters:
+ - `public_keys` - the public keys that the action was performed on
+ - `action` - the action that was performed. Either `add` or `remove`
+ """
+ past_tense = f"{action}{'d' if action.endswith('e') else 'ed'}".title()
+ preposition = "from" if action == "remove" else "to"
+ total = len(public_keys)
+ self.__account.logger.info(f"{past_tense} {total} contact{'s' if total != 1 else ''} {preposition} '{self.name}'")
+
+ def __get_group_info(self, chat_id: str) -> dict:
+ """
+ Fetch latest group information. Other chat members can also modify some group information.:
+ - Added / remove member
+ - Change group name
+
+ Parameters:
+ - `chat_id` - the current chat's ID
+
+ Output:
+ - Overall chat information
+ """
+ response: dict = self.__account._call_rpc("messaging", "confirmJoiningGroup", [chat_id])
+ if response.get("error"):
+ raise exceptions.GroupChatNotFoundError(f"Group Chat not found...\nChat ID: {chat_id}")
+
+ chat: dict = response["result"]["chats"][0]
+ return chat