mirror of
https://github.com/status-im/status-python-sdk.git
synced 2026-08-27 12:11:05 +00:00
bot: Control Node
This commit is contained in:
+1
-1
@@ -215,5 +215,5 @@ __marimo__/
|
||||
*.DS_Store
|
||||
*.pkl
|
||||
# Docker volumes for Status
|
||||
/data-dir
|
||||
data/
|
||||
*.bkp
|
||||
|
||||
@@ -121,7 +121,7 @@ You can set it up in **two** ways.
|
||||
|
||||
#### With Python
|
||||
|
||||
Use [`launch_docker_container`](./docs/utils.md#launch_docker_containercommitnone-wait_seconds5-platformlinuxamd64), which builds and starts the container for you. This is the recommended option, as it handles platform selection and (on Windows) recovers from stale Docker mounts:
|
||||
Use [`launch_docker_container`](./docs/utils.md#launch_docker_containercommitnone-wait_seconds5-platformlinuxamd64-data_foldernone), which builds and starts the container for you. This is the recommended option, as it handles platform selection and (on Windows) recovers from stale Docker mounts:
|
||||
|
||||
```python
|
||||
from status_sdk import launch_docker_container
|
||||
@@ -136,13 +136,14 @@ Run the compose file yourself. It lives inside the installed package, so point D
|
||||
docker compose -f status_sdk/docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
The compose file reads two variables from the environment. Both have a default, so the command above works as-is, but they can be overridden:
|
||||
The compose file reads three variables from the environment. All of them have a default, so the command above works as-is, but they can be overridden:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-----|-----|-------------|
|
||||
| `STATUS_GO_REF` | `develop` | The [`status-im/status-go`](https://github.com/status-im/status-go/) git ref (commit SHA, branch or tag) to build from. |
|
||||
| `STATUS_GO_PLATFORM` | `linux/amd64` | The platform the image is built for. |
|
||||
| `STATUS_GO_COMMIT` | `develop` | The [`status-im/status-go`](https://github.com/status-im/status-go/) git ref (commit SHA, branch or tag) to build from. |
|
||||
| `PLATFORM` | `linux/amd64` | The platform the image is built for. |
|
||||
| `DATA_DIR` | `./data` | The folder on your machine where Status Backend keeps the accounts it creates. Use an absolute path, or one starting with `./` - a bare relative path is read as a Docker volume name. Required for a community [control node](./docs/community.md#control-node). |
|
||||
|
||||
```
|
||||
STATUS_GO_REF=2bee8b6a38cdc8f92d74e2dbb8c4e77fbbeea149 STATUS_GO_PLATFORM=linux/amd64 docker compose -f status_sdk/docker-compose.yaml up -d
|
||||
STATUS_GO_COMMIT=2bee8b6a38cdc8f92d74e2dbb8c4e77fbbeea149 PLATFORM=linux/amd64 DATA_DIR=./data docker compose -f status_sdk/docker-compose.yaml up -d
|
||||
```
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ Where a list is accepted, the formats can even be **mixed within the same list**
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
**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-data_foldernone) constructor.
|
||||
|
||||
## Wallet
|
||||
|
||||
|
||||
+120
-6
@@ -1,10 +1,10 @@
|
||||
# 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).
|
||||
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-data_foldernone) 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.
|
||||
- [`Community`](./community.md#communityaccount-community_idnone-urlnone-data_foldernone) - manages membership (members, join requests, bans), the community's channels and reports its [minted tokens](./community.md#get_collectibles).
|
||||
- [`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).
|
||||
@@ -37,7 +37,17 @@ Every member carries one or more **roles**, returned by [`get_members`](./commun
|
||||
|
||||
**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)`
|
||||
## Control node
|
||||
|
||||
The community's [control node](https://status.app/help/communities/about-the-control-node-in-status-communities) maintains your community's settings, configuration and functionality. **If the control node goes offline, your community functionality is affected.**
|
||||
|
||||
This matters for a bot, because a community created in Status App has its control node on the desktop application that created it, not on [`status-im/status-go`](https://github.com/status-im/status-go). The control node is the only computer that manages community members. You can use another computer or delegate tasks, but all actions go through the control node. If it's offline, new members can't be accepted, and join requests stay Pending until it comes back online.
|
||||
|
||||
The account behind the bot can hold the [`owner`](./community.md#roles) role and still not be the device that **owns** the key.
|
||||
|
||||
[`upload_control_node`](./community.md#upload_control_nodefolder) closes that gap - it replaces the account data Status Backend runs on with the `data` folder of the Status App installation that created the community, so the bot runs as that same installation.
|
||||
|
||||
## `Community(account, community_id=None, url=None, data_folder=None)`
|
||||
|
||||
Create a `Community` instance bound to a **logged-in** [`Account`](./account.md). Provide **either** `community_id` **or** `url`.
|
||||
|
||||
@@ -46,6 +56,8 @@ Create a `Community` instance bound to a **logged-in** [`Account`](./account.md)
|
||||
| `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). |
|
||||
| `data_folder` | `str` | No | The folder on **your machine** that [`launch_docker_container`](./utils.md#launch_docker_containercommitnone-wait_seconds5-platformlinuxamd64-data_foldernone) mounts into Status Backend. That is the only place the account data written by Status Backend lives, so a different folder cannot be reached. The path is resolved to its `data` subfolder, so `"status-backend-data"` and `"status-backend-data/data"` are equivalent. This property is only needed when the **same account is logged into Status App**, created a community there, and you want [`status-im/status-go`](https://github.com/status-im/status-go) (Status Backend) to take over as its [control node](./community.md#control-node) - it is where [`upload_control_node`](./community.md#upload_control_nodefolder) writes the uploaded account data. Leave it unset for every other use. |
|
||||
|
||||
|
||||
Wrap a community the account is already in:
|
||||
|
||||
@@ -160,6 +172,108 @@ print(members[["display_name", "roles"]].to_markdown(index=False))
|
||||
|
||||

|
||||
|
||||
### `get_collectibles()`
|
||||
|
||||
The community's **minted tokens** and who is holding them - one row per holder, per token, per chain.
|
||||
|
||||
A community can mint its own tokens, which are then used for [token gating](./community.md#is_token_gated) and rewards. This method takes every token the community has minted, looks up the holders of each of its contracts, and returns them as a `pd.DataFrame`. Tokens minted on more than one chain are looked up on each chain separately, so the same `symbol` can appear under several `chain_id` values.
|
||||
|
||||
Returns `pd.DataFrame`, one row per `owner` per contract. Rows are sorted by `symbol`, `name`, `chain_id`, `contract_address` and `owner`.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `symbol` | `str` | The token's symbol, as minted by the community. |
|
||||
| `name` | `str` | The token's name. |
|
||||
| `chain_id` | `int` | Chain ID the contract is deployed on. Matches values from [`chains`](./account.md#chains). |
|
||||
| `contract_address` | `str` | Address of the token contract on that chain. |
|
||||
| `owner` | `str` | Wallet address holding the token. |
|
||||
| `balance` | `int` | Number of tokens that wallet holds. |
|
||||
| `is_owner` | `bool` | `True` when `owner` is the logged-in account's own `wallet_address`, from [`info`](./account.md#info). |
|
||||
|
||||
This costs **one call per contract**, on top of the community fetch - so it is a reporting method rather than something to poll. Contracts the wallet service returns no holders for contribute no rows.
|
||||
|
||||
```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)
|
||||
|
||||
collectibles = community.get_collectibles()
|
||||
print(collectibles.to_markdown(index=False))
|
||||
```
|
||||
|
||||
What the account itself is holding:
|
||||
|
||||
```python
|
||||
collectibles = community.get_collectibles()
|
||||
|
||||
mine = collectibles.loc[collectibles["is_owner"]]
|
||||
for row in mine.itertuples():
|
||||
print(f"{row.symbol}\t{row.balance}")
|
||||
```
|
||||
|
||||
The biggest holders of a token, and how much of it is out there:
|
||||
|
||||
```python
|
||||
collectibles = community.get_collectibles()
|
||||
|
||||
snt_pump = collectibles.loc[collectibles["symbol"] == "PUMP"]
|
||||
print(f"{snt_pump['balance'].sum()} held across {len(snt_pump)} wallets")
|
||||
print(snt_pump.nlargest(5, "balance")[["owner", "balance"]].to_markdown(index=False))
|
||||
```
|
||||
|
||||
**Note**: the returned holders are **wallet addresses**, not the public keys used everywhere else in this class. They cannot be passed to [`kick`](./community.md#kickpublic_keys), [`ban`](./community.md#banpublic_keys-delete_messagesfalse) or [`get_public_key`](./account.md#get_public_keyvalue), and a holder does not have to be a member of the community.
|
||||
|
||||
**Note**: a community that has **not minted any tokens** does not return an empty `DataFrame` - there are no columns to group by, so pandas raises a `ValueError`. Wrap the call in a `try` / `except ValueError` when the community is not known to have tokens.
|
||||
|
||||
### `upload_control_node(folder)`
|
||||
|
||||
Hand Status Backend the account data of an existing Status App installation, so the bot runs as that installation and becomes the community's [control node](./community.md#control-node).
|
||||
|
||||
**This is destructive.** Everything inside the [`data_folder`](./community.md#communityaccount-community_idnone-urlnone-data_foldernone) given to the `Community` constructor is **deleted** and replaced with the contents of `folder`. Point `folder` at a copy of the account data, never at the only one you have.
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|-----|-----|-----|-------------|
|
||||
| `folder` | `str` | Yes | The account data folder to upload - `data` when it comes from Status App, or `data` when it comes from a [`status-im/status-go`](https://github.com/status-im/status-go) container. |
|
||||
|
||||
|
||||
```python
|
||||
from status_sdk import Account, Community, launch_docker_container
|
||||
|
||||
# The container and the Community must be pointed at the same folder
|
||||
data_folder = "status-backend-data"
|
||||
launch_docker_container(data_folder=data_folder)
|
||||
|
||||
account = Account()
|
||||
params = {
|
||||
"name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
# The account that created the community in Status App
|
||||
"mnemonic": "lens crater peanut ..."
|
||||
}
|
||||
account.login(**params)
|
||||
|
||||
community_id = account.communities[0]["id"]
|
||||
community = Community(account, community_id, data_folder=data_folder)
|
||||
|
||||
# A copy of the Status App `data` folder for that same account
|
||||
community.upload_control_node("status-app-copy/data")
|
||||
print(f"{community.name} is now controlled by this backend")
|
||||
```
|
||||
|
||||
**Note**: Status App does not show where it keeps its account data. Open the `logs` folder it writes to and go **one directory up** - `data` sits next to it:
|
||||
|
||||

|
||||
|
||||
That `data` folder is the one to pass as `folder`.
|
||||
|
||||
### `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.
|
||||
@@ -540,7 +654,7 @@ 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.
|
||||
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-data_foldernone) constructor to join or wrap the community.
|
||||
|
||||
Returns `str`, or `None` if the backend does not return one.
|
||||
|
||||
@@ -772,7 +886,7 @@ print(f"Joined on {joined:%Y-%m-%d}" if joined else "Not joined yet")
|
||||
|
||||
### `requested_timestamp`
|
||||
|
||||
When the account's request to join the community was sent - the request created by the [`Community`](./community.md#communityaccount-community_idnone-urlnone) constructor when the account is not yet a member.
|
||||
When the account's request to join the community was sent - the request created by the [`Community`](./community.md#communityaccount-community_idnone-urlnone-data_foldernone) constructor when the account is not yet a member.
|
||||
|
||||
Returns `datetime.datetime`, or `None` when no join request was ever sent - for example when the account created the community itself.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 250 KiB |
+5
-6
@@ -6,23 +6,24 @@ Helper functions for setting up the Status Backend environment, and package leve
|
||||
|
||||
## Methods
|
||||
|
||||
### `launch_docker_container(commit=None, wait_seconds=5, platform="linux/amd64")`
|
||||
### `launch_docker_container(commit=None, wait_seconds=5, platform="linux/amd64", data_folder=None)`
|
||||
|
||||
Launch Status Backend Docker container in the background using `docker-compose.yaml`. If `docker` is not installed, or if the container fails to start, an **exception will be raised** with the error message from Docker. The container is built from [`status-im/status-go`](https://github.com/status-im/status-go) at the git ref you choose:
|
||||
|
||||
```yaml
|
||||
context: https://github.com/status-im/status-go.git#${STATUS_GO_REF:-develop}
|
||||
context: https://github.com/status-im/status-go.git#${STATUS_GO_COMMIT:-develop}
|
||||
```
|
||||
|
||||
The image is always rebuilt (`docker compose up --build`) so a newly chosen `commit` is picked up instead of reusing a previously built image.
|
||||
|
||||
**Note**: The container mounts the SDK's `backups/` and `assets/` folders as Docker volumes. Make sure the repository has **read and write permissions**, otherwise the container will fail to start or [backups](./account.md#backups) and [profile pictures](./account.md#profile_picture) will not be saved. On Docker Desktop the repository must also be a **shared path** (see [Windows](./utils.md#windows) and [Mac](./utils.md#mac)).
|
||||
**Note**: The container mounts the SDK's `backups/`, `assets/` and `data/` folders as Docker volumes. Make sure the repository has **read and write permissions**, otherwise the container will fail to start or [backups](./account.md#backups) and [profile pictures](./account.md#profile_picture) will not be saved. On Docker Desktop the repository must also be a **shared path** (see [Windows](./utils.md#windows) and [Mac](./utils.md#mac)).
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|-----|-----|-----|-------------|
|
||||
| `commit` | `str` | No | The `status-im/status-go` git ref to build from - a commit SHA, branch, or tag. When omitted, the latest `develop` branch is built. |
|
||||
| `wait_seconds` | `int` | No | Number of seconds to pause after the `docker compose up` command returns, giving Status Backend enough time to finish booting before subsequent code runs. Defaults to `5`. This matters mainly when the container already exists and is being restarted, because `docker compose up` returns immediately while the backend is still warming up - instantiating [`Account`](./account.md#accountdomainlocalhost-port8080-is_securefalse-backup_foldernone) too quickly will fail to connect. On [Windows](./utils.md#windows) the same value is used to wait between retries after WSL has been restarted. |
|
||||
| `platform` | `str` | No | The platform the image is built for. Defaults to `linux/amd64`. Run `docker buildx ls` to see the platforms your Docker installation supports, and pass the matching value if the default does not build on your machine. |
|
||||
| `data_folder` | `str` | No | The folder on **your machine** where Status Backend keeps the accounts it creates. If you are a **[Community Control Node](./community.md#control-node)** you will need to create a Docker container with a volume folder, and pass that **same** folder to [`Community`](./community.md#communityaccount-community_idnone-urlnone-data_foldernone) so [`upload_control_node`](./community.md#upload_control_nodefolder) can reach it. |
|
||||
|
||||
Wait time after container has launched:
|
||||
```python
|
||||
@@ -107,7 +108,6 @@ The value is read from the installed package metadata at import time, so it alwa
|
||||
import status_sdk
|
||||
|
||||
print(status_sdk.__version__)
|
||||
# 1.1.0
|
||||
```
|
||||
|
||||
It can also be imported directly:
|
||||
@@ -116,10 +116,9 @@ It can also be imported directly:
|
||||
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:
|
||||
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-data_foldernone) - the two together describe the exact setup a bug happened on:
|
||||
|
||||
```python
|
||||
import status_sdk
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "status-sdk"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
description = "Private chat. Communities. Multi-chain wallet. Browser. dApps all in one app, powered by SNT."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -26,6 +26,8 @@ dependencies = [
|
||||
"pandas",
|
||||
"pillow",
|
||||
"eth-abi",
|
||||
"pyyaml",
|
||||
"pycryptodome"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+15
-5
@@ -3,6 +3,7 @@ import uuid as uuid_lib
|
||||
import requests, datetime, re, logging, os, json, ast, shutil, eth_abi, shutil
|
||||
import pandas as pd
|
||||
from . import exceptions
|
||||
from Crypto.Hash import keccak
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from PIL.JpegImagePlugin import JpegImageFile
|
||||
@@ -35,6 +36,7 @@ class Account:
|
||||
"transfer": "a9059cbb" # keccak256("transfer(address,uint256)")[:4]
|
||||
}
|
||||
__ETH_ADDRESS = "0x0000000000000000000000000000000000000000"
|
||||
__KECCAK256_ERROR = "failed to open database: failed to set `journal_mode` pragma: file is not a database"
|
||||
__status_types = {
|
||||
"auto": 1,
|
||||
"dnd": 2,
|
||||
@@ -58,7 +60,7 @@ class Account:
|
||||
self.__alchemy_token = None
|
||||
self.__transactions: Optional[pd.DataFrame] = None
|
||||
# Path of the account data in the Docker container for Status Backend
|
||||
self.__docker_data_folder = "./data-dir"
|
||||
self.__docker_data_folder = "./data"
|
||||
# Path of the backups in the Docker container for Status Backend
|
||||
self.__docker_backup_folder = "./root/.config/Status/backups"
|
||||
self.__backup_folder = backup_folder
|
||||
@@ -217,6 +219,14 @@ class Account:
|
||||
})
|
||||
response = requests.post(url, json=params)
|
||||
signal_event = self.__signal.get("node.login")
|
||||
# Password must be hashed if the `data` folder has been copied over from another Status instance (`status-im/status-go` or Status App)
|
||||
if signal_event["is_error"] and signal_event["error_message"] == self.__KECCAK256_ERROR:
|
||||
h = keccak.new(digest_bits=256)
|
||||
h.update(params["password"].encode())
|
||||
params["password"] = "0x" + h.hexdigest().lower()
|
||||
response = requests.post(url, json=params)
|
||||
signal_event = self.__signal.get("node.login")
|
||||
|
||||
if signal_event["is_error"]:
|
||||
raise exceptions.BackendError(f"There was an error with Status Backend...\n{signal_event['error_message']}")
|
||||
|
||||
@@ -250,7 +260,7 @@ class Account:
|
||||
self.logger.info("Updating remote display name")
|
||||
self.display_name = event["display-name"]
|
||||
self.logger.info("Successfully updated display name!")
|
||||
self.__load_backup()
|
||||
self._load_backup()
|
||||
|
||||
if self.__info["installation_id"]:
|
||||
self._call_rpc("messaging", "setInstallationName", [self.__info["installation_id"], self.__INSTALLATION_NAME])
|
||||
@@ -1471,10 +1481,10 @@ class Account:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __load_backup(self):
|
||||
def _load_backup(self):
|
||||
"""
|
||||
Try to load every file in the Docker volume
|
||||
when an account recover is done.
|
||||
Try to load a backup file in the Docker volume
|
||||
when an account recovery is completed.
|
||||
"""
|
||||
folder = self.__backup_folder if self.__backup_folder else self.__backup_sdk_folder
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from .. import exceptions
|
||||
from .channel import Channel
|
||||
from typing import Union, Optional, Generator
|
||||
import pandas as pd
|
||||
import datetime
|
||||
import datetime, copy, os, shutil
|
||||
|
||||
class Community:
|
||||
|
||||
@@ -21,7 +21,7 @@ class Community:
|
||||
4: "cancel"
|
||||
}
|
||||
|
||||
def __init__(self, account: Account, community_id: Optional[str] = None, url: Optional[str] = None):
|
||||
def __init__(self, account: Account, community_id: Optional[str] = None, url: Optional[str] = None, data_folder: Optional[str] = None):
|
||||
"""
|
||||
Work with Status App Communities
|
||||
|
||||
@@ -29,10 +29,14 @@ class Community:
|
||||
- `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`
|
||||
- `data_folder` - the local folder mounted into the Status Backend Docker container, holding the accounts and their community data. It must be the **same** folder that was passed to `launch_docker_container`, otherwise the community data written by Status Backend cannot be reached.
|
||||
"""
|
||||
# Verify that the user is logged in
|
||||
account.info
|
||||
self.__account = account
|
||||
self.__data_folder = data_folder
|
||||
if self.__data_folder and os.path.basename(self.__data_folder) != "data":
|
||||
self.__data_folder = os.path.join(self.__data_folder, "data")
|
||||
|
||||
if community_id:
|
||||
self.__id = community_id
|
||||
@@ -85,6 +89,7 @@ class 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`
|
||||
"""
|
||||
self.__verify_admin()
|
||||
public_keys = self.__normalise_public_keys(public_keys)
|
||||
for public_key in public_keys:
|
||||
params = [self.id, self.__account.get_public_key(public_key)]
|
||||
@@ -98,6 +103,7 @@ class Community:
|
||||
- `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
|
||||
"""
|
||||
self.__verify_admin()
|
||||
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}]
|
||||
@@ -110,6 +116,7 @@ class Community:
|
||||
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`
|
||||
"""
|
||||
self.__verify_admin()
|
||||
public_keys = self.__normalise_public_keys(public_keys)
|
||||
for public_key in public_keys:
|
||||
params = [{"communityId": self.id, "user": public_key}]
|
||||
@@ -143,6 +150,7 @@ class Community:
|
||||
- `pending_request_id` - the `request_id` of a member from `pending_members`
|
||||
- `mode` - either `accept` or `decline`, selecting which action to perform
|
||||
"""
|
||||
self.__verify_admin()
|
||||
mode_mapping = {
|
||||
"accept": "acceptRequestToJoinCommunity",
|
||||
"decline": "declineRequestToJoinCommunity"
|
||||
@@ -155,6 +163,94 @@ class Community:
|
||||
params = [{"id": pending_request_id}]
|
||||
self.__account._call_rpc("messaging", rpc_call, params)
|
||||
|
||||
def get_collectibles(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get all token collectibles from the community and the amount they are holding.
|
||||
|
||||
Output:
|
||||
- DataFrame - row per `owner` per contract.
|
||||
"""
|
||||
result: dict = self.__get_community_info()
|
||||
info = [
|
||||
{
|
||||
"symbol": nft_info["symbol"],
|
||||
"name": nft_info["name"],
|
||||
"chain_id": int(chain_id),
|
||||
"contract_address": contract_address,
|
||||
"owner": collectible["ownerAddress"],
|
||||
"balance": int(balance["balance"])
|
||||
}
|
||||
for nft_info in result["communityTokensMetadata"]
|
||||
for chain_id, contract_address in nft_info["contract_addresses"].items()
|
||||
for collectible in (self.__account._call_rpc("wallets", "getCollectibleOwnersByContractAddress", [int(chain_id), contract_address]).get("result", {}) or {}).get("owners") or []
|
||||
for balance in collectible["tokenBalances"]
|
||||
]
|
||||
info = pd.DataFrame(info)
|
||||
info = info.groupby(info.columns[:-1].to_list()).sum().reset_index()
|
||||
info["is_owner"] = info["owner"] == self.__account.info["wallet_address"]
|
||||
return info
|
||||
|
||||
def upload_control_node(self, folder: str):
|
||||
"""
|
||||
Upload a `data` (if using Status App) / `data` (if using `status-im/status-go`) folder.
|
||||
NOTE: This is a destructive action, so always make sure `folder` has valid account data. If
|
||||
the folder is
|
||||
|
||||
Parameters:
|
||||
- `folder` - the Status App `data` folder if using Status App or `data` if using `status-im/status-go` Docker image
|
||||
"""
|
||||
|
||||
if self.role != "owner":
|
||||
raise exceptions.CommunityPermissionError("Only community owners can perform this action...")
|
||||
self.__account.logger.info(f"Account is owner of Community {self.name} [{self.id}]")
|
||||
|
||||
if not isinstance(self.__data_folder, str):
|
||||
raise exceptions.CommunityDataFolderError()
|
||||
|
||||
for name, path in (("folder", folder), ("data_folder", self.__data_folder)):
|
||||
if not os.path.isdir(path):
|
||||
raise exceptions.CommunityDataFolderError(f"The `{name}` '{path}' does not exist / is not a folder...")
|
||||
if not os.listdir(path):
|
||||
raise exceptions.CommunityDataFolderError(f"The `{name}` '{path}' is empty...")
|
||||
|
||||
source = os.path.normcase(os.path.realpath(folder))
|
||||
destination = os.path.normcase(os.path.realpath(self.__data_folder))
|
||||
|
||||
if os.path.basename(source) != os.path.basename(destination):
|
||||
raise exceptions.CommunityControlNodeError(f"'{folder}' and '{self.__data_folder}' must end in the same folder name - Status Backend only reads the account data from a folder named '{os.path.basename(destination)}'...")
|
||||
|
||||
if source == destination or source.startswith(destination + os.sep) or destination.startswith(source + os.sep):
|
||||
raise exceptions.CommunityControlNodeError(f"'{folder}' and '{self.__data_folder}' must be two separate folders - the contents of the `data_folder` are deleted during the upload...")
|
||||
|
||||
account_info: dict = copy.deepcopy(self.__account.info)
|
||||
login_params = {
|
||||
"password": account_info["password"],
|
||||
"key_uid": account_info["key_uid"]
|
||||
}
|
||||
self.__account.backup()
|
||||
self.__account.logger.info(f"Backup (.bkp) file for {account_info['key_uid']} created!")
|
||||
|
||||
self.__account.logout()
|
||||
self.__account.logger.info("Account has been logged off successfully!")
|
||||
|
||||
# Replace the account data generated in `status-go`
|
||||
for entry in os.listdir(destination):
|
||||
entry_path = os.path.join(destination, entry)
|
||||
if os.path.isdir(entry_path):
|
||||
shutil.rmtree(entry_path)
|
||||
else:
|
||||
os.remove(entry_path)
|
||||
|
||||
self.__account.logger.warning(f"Deleted {entry_path}")
|
||||
|
||||
shutil.copytree(source, destination, dirs_exist_ok=True)
|
||||
self.__account.logger.info(f"Copied data from {source} to {destination}")
|
||||
# Error
|
||||
self.__account.login(**login_params)
|
||||
self.__account._load_backup()
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -169,6 +265,7 @@ class Community:
|
||||
Output:
|
||||
- the created `Channel`
|
||||
"""
|
||||
self.__verify_admin()
|
||||
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)
|
||||
|
||||
@@ -179,6 +276,7 @@ class Community:
|
||||
Parameters:
|
||||
- `channel_name` - the name of the channel to delete
|
||||
"""
|
||||
self.__verify_admin()
|
||||
channel = self.__getitem__(channel_name)
|
||||
params = [self.id, channel.id.replace(self.id, "")]
|
||||
self.__account._call_rpc("messaging", "deleteCommunityChat", params)
|
||||
@@ -223,6 +321,14 @@ class Community:
|
||||
}
|
||||
return mapping
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
"""
|
||||
The account's community role
|
||||
"""
|
||||
result = self.__get_community_info()
|
||||
return self.__role_mapping[result["memberRole"]]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""
|
||||
@@ -403,6 +509,7 @@ class Community:
|
||||
- a list of `{"public_key": ..., "request_id": ...}` for each request,
|
||||
or an empty list if there are none
|
||||
"""
|
||||
self.__verify_admin()
|
||||
mode_mapping = {
|
||||
"pending": "pendingRequestsToJoinForCommunity",
|
||||
"declined": "declinedRequestsToJoinForCommunity"
|
||||
@@ -527,3 +634,8 @@ class Community:
|
||||
"""
|
||||
result = self.__get_community_info()
|
||||
return datetime.datetime.fromtimestamp(result[key]) if result[key] != 0 else None
|
||||
|
||||
def __verify_admin(self):
|
||||
|
||||
if self.role not in list(self.__role_mapping.values())[1:]:
|
||||
raise exceptions.CommunityPermissionError()
|
||||
|
||||
@@ -37,6 +37,18 @@ 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 CommunityPermissionError(Exception):
|
||||
def __init__(self, msg: Optional[str] = None):
|
||||
super().__init__(msg or "Only the community's owner, admins and token masters can perform this action...")
|
||||
|
||||
class CommunityDataFolderError(Exception):
|
||||
def __init__(self, msg: Optional[str] = None):
|
||||
super().__init__(msg or "Please provide a local `data_folder` when creating the Community. Make sure the folder is the same one used in `launch_docker_container`...")
|
||||
|
||||
class CommunityControlNodeError(Exception):
|
||||
def __init__(self, msg: Optional[str] = None):
|
||||
super().__init__(msg or "The provided folder cannot be uploaded as the community's control node...")
|
||||
|
||||
class InvalidUserStatusError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
+41
-10
@@ -1,10 +1,10 @@
|
||||
import shutil, os, subprocess, sys, time
|
||||
import shutil, os, subprocess, sys, time, yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .logger import Logger
|
||||
from . import exceptions
|
||||
|
||||
def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, platform: str = "linux/amd64"):
|
||||
def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, platform: str = "linux/amd64", data_folder: Optional[str] = None):
|
||||
"""
|
||||
Launch the Status Backend Docker container using `docker-compose.yaml`
|
||||
|
||||
@@ -16,6 +16,7 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5,
|
||||
- `commit` - the commit SHA. If no commit is provided, the latest version is pulled
|
||||
- `wait_seconds` - number of seconds to wait before the code resumes. Sleep prevents calling `class Account` faster than launching the docker container. This only happens when the container already exists and it is must be turned on. On Windows the same value is used to wait between retries after WSL has been restarted.
|
||||
- `platform` - the platform the image is built for. Defaults to `linux/amd64`. Run `docker buildx ls` to see the platforms your Docker installation supports.
|
||||
- `data_folder` - the local folder holding the accounts created in Status Backend. Necessary for Community nodes
|
||||
"""
|
||||
logger = Logger()
|
||||
system = sys.platform
|
||||
@@ -23,20 +24,50 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5,
|
||||
if not shutil.which("docker"):
|
||||
raise exceptions.DockerError("Please install Docker.")
|
||||
|
||||
if is_windows and not shutil.which("wsl"):
|
||||
raise exceptions.DockerError("Please install wsl - https://learn.microsoft.com/en-us/windows/wsl/install.")
|
||||
|
||||
logger.info(f"Running Docker on {system}")
|
||||
ref = commit if commit else "develop"
|
||||
DOCKER_COMPOSE_PATH = os.path.join(os.path.dirname(__file__), "docker-compose.yaml")
|
||||
docker_path = DOCKER_COMPOSE_PATH
|
||||
if is_windows:
|
||||
p = Path(DOCKER_COMPOSE_PATH)
|
||||
drive = p.drive.rstrip(":").lower()
|
||||
docker_path = f"/mnt/{drive}/" + "/".join(p.parts[1:])
|
||||
# Docker is reached through WSL on Windows, so local paths are passed as `/mnt/<drive>/...`
|
||||
to_docker_path = lambda path: f"/mnt/{Path(path).drive.rstrip(':').lower()}/" + "/".join(Path(path).parts[1:]) if is_windows else path
|
||||
docker_path = to_docker_path(DOCKER_COMPOSE_PATH)
|
||||
|
||||
cmd = ["env", f"STATUS_GO_REF={ref}", f"STATUS_GO_PLATFORM={platform}", "docker", "compose", "-f", docker_path, "up", "-d", "--build"]
|
||||
env_params = {
|
||||
"STATUS_GO_COMMIT": ref,
|
||||
"PLATFORM": platform
|
||||
}
|
||||
|
||||
with open(DOCKER_COMPOSE_PATH, "r") as f:
|
||||
docker_yaml_data: dict = yaml.load(f, Loader=yaml.SafeLoader)
|
||||
|
||||
data_volume = '${DATA_DIR:-./data}:/data'
|
||||
current_volumes: list[str] = docker_yaml_data["services"]["backend"]["volumes"]
|
||||
if data_folder:
|
||||
# NOTE: A bare relative path is read as a named Docker volume rather than a bind mount
|
||||
data_folder = os.path.join(os.path.abspath(data_folder), "data")
|
||||
os.makedirs(data_folder, exist_ok=True)
|
||||
data_folder = to_docker_path(data_folder)
|
||||
env_params["DATA_DIR"] = data_folder
|
||||
|
||||
is_updated = False
|
||||
if data_folder and data_volume not in current_volumes:
|
||||
current_volumes.append(data_volume)
|
||||
is_updated = True
|
||||
|
||||
if not data_folder and data_volume in current_volumes:
|
||||
current_volumes.remove(data_volume)
|
||||
is_updated = True
|
||||
|
||||
if is_updated:
|
||||
compose_yaml = yaml.dump(docker_yaml_data, Dumper=yaml.SafeDumper, sort_keys=False, default_flow_style=False, indent=4)
|
||||
with open(DOCKER_COMPOSE_PATH, "w") as f:
|
||||
f.write(compose_yaml)
|
||||
|
||||
cmd = ["env"] + [f"{key}={value}" for key, value in env_params.items()] + ["docker", "compose", "-f", docker_path, "up", "-d", "--build"]
|
||||
|
||||
if is_windows:
|
||||
if not shutil.which("wsl"):
|
||||
raise exceptions.DockerError("Please install wsl - https://learn.microsoft.com/en-us/windows/wsl/install.")
|
||||
cmd.insert(0, "wsl")
|
||||
|
||||
logger.info(f"Running:\n{' '.join(cmd)}")
|
||||
|
||||
Reference in New Issue
Block a user