bot: Control Node

This commit is contained in:
Nick Ninov
2026-08-11 08:38:16 +03:00
parent 46bb0b20ae
commit 7b5986243f
13 changed files with 318 additions and 37 deletions
+1 -1
View File
@@ -215,5 +215,5 @@ __marimo__/
*.DS_Store
*.pkl
# Docker volumes for Status
/data-dir
data/
*.bkp
+6 -5
View File
@@ -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
View File
@@ -92,7 +92,7 @@ Where a list is accepted, the formats can even be **mixed within the same list**
![Community Settings](./images/account/public-keys.png)
**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
View File
@@ -1,10 +1,10 @@
# Community
![Community header image](./images/community/overview.webp)
![Community header image](./images/community/overview.png)
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))
![Community Members](./images/community/members.png)
### `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:
![Status App data folder](./images/community/data-folder.png)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+114 -2
View File
@@ -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()
+12
View File
@@ -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
View File
@@ -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)}")