diff --git a/.gitignore b/.gitignore index 845728e..16d6f54 100644 --- a/.gitignore +++ b/.gitignore @@ -215,5 +215,5 @@ __marimo__/ *.DS_Store *.pkl # Docker volumes for Status -/data-dir +data/ *.bkp diff --git a/README.md b/README.md index 74187ee..488d194 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/account.md b/docs/account.md index dae4254..34fe038 100644 --- a/docs/account.md +++ b/docs/account.md @@ -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 @@ -386,7 +386,7 @@ account.unsync("6a2f9c1e-...") #### `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**. 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. +Send a text message to a specific chat. 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. @@ -439,6 +439,82 @@ account.send_message( ) ``` +#### `send_image(chat_id, file_path, message=None, reply_to_message_id=None)` + +Send an image to a specific chat, with an optional text message. The image renders inline in Status App, the same as attaching an image in the app. Like [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone), it can be sent as a **reply** to an existing message. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `chat_id` | `str` | Yes | Identifier of the chat where the image will be sent. All available chat IDs can be obtained from the [`chats`](./account.md#chats) property. | +| `file_path` | `str` | Yes | Local full path to the image file. | +| `message` | `str` | No | Caption sent together with the image. Cannot be longer than **2000 characters**. When omitted (default), the image is sent without any text. | +| `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 image is sent as a standalone message. | + +Returns `str` - the `id` of the message that was just sent, exactly as [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) does, 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. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# This is under the assumption you already have a contact / joined a community +chat = account.chats[0] +message_id = account.send_image(chat["id"], "/full/file-path/meme-67.png") +print(f"Sent image: {message_id}") +``` + +Send an image with a caption: + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +chat = account.chats[0] + +account.send_image( + chat_id=chat["id"], + file_path="/full/file-path/meme-67.png", + message="Du bist gut genug" +) +``` + +Reply to a message with an image: + +```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_image( + chat_id=chat["id"], + file_path="/full/file-path/meme-67.png", + message="Du bist gut genug", + reply_to_message_id=latest["id"] +) +``` + #### `get_messages(chat_id, start_timestamp=None, end_timestamp=None)` Retrieve messages from the specified chat within an optional time range. Messages are returned in **descending order** (newest to oldest). The method automatically paginates through the backend until all messages in the specified range are collected. This method is ideal for backfilling, [batch processing](https://aws.amazon.com/what-is/batch-processing/) or [micro batch processing](https://www.dremio.com/wiki/micro-batch-processing/). @@ -519,7 +595,6 @@ Listen for new incoming messages **in real time**. This method yields raw messag ```python from status_sdk import Account -import datetime # For terminal readability only from rich import print as rprint from rich.pretty import Pretty @@ -537,6 +612,27 @@ for msg in account.listen_messages(): **Note**: If you receive multiple messages at once, `contacts` and `chats` will grow. +#### `listen_contact_requests()` + +Listen for incoming contact requests **in real time**. + +```python +from status_sdk import Account +# For terminal readability only +from rich import print as rprint +from rich.pretty import Pretty + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +for request in account.listen_contact_requests(): + rprint(Pretty(request)) +``` + #### `add_contact(public_key, display_name=None)` Send a contact request or approve an existing contact request. The mode depends on how the contact shows up in [`contacts`](./account.md#contacts). Best practice would be to look at the the following [`contacts`](./account.md#contacts) keys: diff --git a/docs/community.md b/docs/community.md index b3af1b0..9a1a6a9 100644 --- a/docs/community.md +++ b/docs/community.md @@ -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. @@ -1095,6 +1209,36 @@ message_id = channel.send_message("Hello from my Status bot!") print(f"Sent message: {message_id}") ``` +### `send_image(file_path, message=None, reply_to_message_id=None)` + +Send an image to the channel, with an optional text **caption**. The image renders inline in Status App, the same as attaching an image in the app. Like [`send_message`](./community.md#send_messagemessage-reply_to_message_idnone), it can be sent as a **reply** to an existing message in the channel. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `file_path` | `str` | Yes | Local full path to the image file. | +| `message` | `str` | No | Caption sent together with the image. | +| `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 image is sent as a standalone message. | + +Returns `str` - the `id` of the message that was just sent, delegated from [`send_image`](./account.md#send_imagechat_id-file_path-messagenone-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. + +```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_image("./meme-67.png", "Daily random meme") +print(f"Sent image: {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). diff --git a/docs/group-chat.md b/docs/group-chat.md index ea34b23..e717785 100644 --- a/docs/group-chat.md +++ b/docs/group-chat.md @@ -223,6 +223,35 @@ latest = messages[0] group_chat.send_message("Thanks for the update!", latest["id"]) ``` +### `send_image(file_path, message=None, reply_to_message_id=None)` + +Send an image to the group chat, with an optional text **caption**. The image renders inline in Status App, the same as attaching an image in the app. Like [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone), it can be sent as a **reply** to an existing message in the chat. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `file_path` | `str` | Yes | Local full path to the image file. | +| `message` | `str` | No | Caption sent together with the image. | +| `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 image is sent as a standalone message. | + +Returns `str` - the `id` of the message that was just sent, delegated from [`send_image`](./account.md#send_imagechat_id-file_path-messagenone-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. + +```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_image("./meme-67.png", "Daily random meme") +print(f"Sent image: {message_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. diff --git a/docs/images/community/data-folder.png b/docs/images/community/data-folder.png new file mode 100644 index 0000000..d949dac Binary files /dev/null and b/docs/images/community/data-folder.png differ diff --git a/docs/images/community/overview.png b/docs/images/community/overview.png new file mode 100644 index 0000000..45069e2 Binary files /dev/null and b/docs/images/community/overview.png differ diff --git a/docs/images/community/overview.webp b/docs/images/community/overview.webp deleted file mode 100644 index 81eb493..0000000 Binary files a/docs/images/community/overview.webp and /dev/null differ diff --git a/docs/utils.md b/docs/utils.md index b5ab807..c15eca3 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 4f979ab..2cc6d3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/status_sdk/account.py b/status_sdk/account.py index eba4df0..7658686 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -3,6 +3,7 @@ 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 @@ def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_po 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 @@ def login(self, password: str, key_uid: Optional[str] = None, name: Optional[str }) 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 @@ def login(self, password: str, key_uid: Optional[str] = None, name: Optional[str 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]) @@ -406,13 +416,13 @@ def profile_picture(self, file_path: str): self.logger.info("File is already in asset path") img = Image.open(asset_file_path) - params = [ - self.info["key_uid"], - docker_file_path, - 0, - 0, - *img.size - ] + width, height = img.size + side = min(width, height) + ax = (width - side) // 2 # left edge, centered horizontally + ay = (height - side) // 2 # top edge, centered vertically + bx = ax + side # right edge + by = ay + side # bottom edg + params = [self.info["key_uid"], docker_file_path, ax, ay, bx, by] self.logger.info(f"Setting {file_path} as profile picture") self._call_rpc("identity", "storeIdentityImage", params) self.logger.info(f"Profile picture has been updated!") @@ -649,32 +659,119 @@ def __getitem__(self, key: str) -> pd.DataFrame: return balance.copy() + def send_image(self, chat_id: str, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: + """ + Send an image to the given chat. + + Parameters: + - `chat_id` - the chat ID can be found in `self.chats` + - `file_path` - the file path of the image + - `message` - the message that will be sent + - `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.__send_content(chat_id, message, reply_to_message_id, image_path = file_path) + 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 + - `message` - the message that will be sent - `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.__send_content(chat_id, message, reply_to_message_id) + + def __send_content(self, chat_id: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_path: Optional[str] = None) -> str: + """ + Send a message with optional media attached to the given chat. + + Parameters: + - `chat_id` - the chat ID can be found in `self.chats` + - `message` - the text that will be sent. Optional when media is attached, so an image can be sent on its own + - `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. + - `image_path` - local path to the image to attach. + + Output: + - The message ID + """ + + def validate_path(path: str): + """ + Validate the passed in path + """ + if not isinstance(path, str): + raise exceptions.InvalidPathError(f"Path '{path}' must be a string (got {type(path).__name__})...") + + if not os.path.exists(path): + raise exceptions.InvalidPathError(f"Path '{path}' does not exist...") + + return path + self.info + if not message: + message = "" + if len(message) > 2_000: raise exceptions.MessageTooLongError(f"Message cannot be longer than 2000 characters (got {len(message)})...") - params = [{ + msg_params = { "chatId": chat_id, "text": message, - "contentType": 1, # Send message only. Future versions can have different message types (audio, image, etc.) + "contentType": 1, # Normal message "responseTo": reply_to_message_id if reply_to_message_id else "" - }] + } + # Content status-go key name for RPC request + content_key = None + # Non `status-im/status-go` path + file_path = None + # File path in `status-im/status-go` + docker_file_path = [self.__docker_asset_folder] + # subfolder name in ./assets/ (if necessary) + asset_subfolder = None + + if image_path: + image_path = validate_path(image_path) + file_path = image_path + msg_params["contentType"] = 7 + content_key = "imagePath" + asset_subfolder = "images" + + if asset_subfolder: + docker_file_path.append(asset_subfolder) + + asset_file_path = None + if file_path: + docker_file_path.append(os.path.basename(file_path)) + asset_file_path = os.path.join(self.__assets_local_folder, asset_subfolder, os.path.basename(file_path)) + os.makedirs(os.path.dirname(asset_file_path), exist_ok=True) + if os.path.exists(asset_file_path): + os.remove(asset_file_path) + + shutil.copy(file_path, asset_file_path) + + if content_key: + msg_params.update({ + content_key: "/".join(docker_file_path) + }) + + if msg_params["contentType"] == 1 and len(msg_params["text"]) == 0: + raise exceptions.SendContentError("Cannot send empty text messages") + + params = [msg_params] response = self._call_rpc("messaging", "sendChatMessage", params) - error = response.get("error", {}) + if asset_file_path and os.path.exists(asset_file_path): + os.remove(asset_file_path) + + error = response.get("error", {}) or {} if error: - raise exceptions.InvalidContactError(error["message"]) + raise exceptions.SendContentError(error.get("message")) return response["result"]["messages"][0]["id"] @@ -695,6 +792,16 @@ def delete_message(self, id: str) -> bool: self.logger.warning(f"Could not delete Message {id}... {error.get('message')}") return not bool(error) + def listen_contact_requests(self) -> Generator: + """ + Listen for new contact requests. Can be used for real time processing. + """ + for message in self.signal.listen("local-notifications"): + event: dict = message.get("event", {}) + category = event.get("category") + if category == "contactRequest": + yield message + def listen_messages(self) -> Generator: """ Listen for new **RAW** messages continuously. Can be used for real time processing. @@ -1461,10 +1568,10 @@ def __del__(self): 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 diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py index 48765b2..1705533 100644 --- a/status_sdk/community/base.py +++ b/status_sdk/community/base.py @@ -3,7 +3,7 @@ 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 @@ def __init__(self, account: Account, community_id: Optional[str] = None, url: Op - `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 @@ def kick(self, public_keys: Union[str, list[str]]): 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 @@ def ban(self, public_keys: Union[str, list[str]], delete_messages: bool = False) - `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 @@ def unban(self, public_keys: Union[str, list[str]]): 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 @@ def __accept_or_decline(self, pending_request_id: str, mode: str): - `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 @@ def __accept_or_decline(self, pending_request_id: str, mode: str): 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 @@ def create_channel(self, name: str, description: str, emoji: Optional[str] = Non 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 @@ def delete_channel(self, channel_name: str): 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 @@ def categories(self) -> dict[str, str]: } 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 @@ def __pending_declined_members(self, mode: str) -> list[dict[str, str]]: - 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 @@ def __to_datetime(self, key: str) -> Optional[datetime.datetime]: """ 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() diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py index 54fb493..28757ff 100644 --- a/status_sdk/community/channel.py +++ b/status_sdk/community/channel.py @@ -199,6 +199,20 @@ def send_message(self, message: str, reply_to_message_id: Optional[str] = None): """ return self.__account.send_message(self.id, message, reply_to_message_id) + def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: + """ + Send a image to the group chat. + + Parameters: + - `file_path` - the file path of the image + - `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_image(self.id, file_path, message, reply_to_message_id) + def get_messages(self, start_timestamp: Optional[datetime.datetime] = None, end_timestamp: Optional[datetime.datetime] = None) -> list[dict]: """ diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py index ed2140d..4e12bc8 100644 --- a/status_sdk/exceptions.py +++ b/status_sdk/exceptions.py @@ -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 @@ -83,6 +95,9 @@ class MessageTooLongError(ValueError): def __init__(self, msg: Optional[str] = None): super().__init__(msg or "Message cannot be longer than 2000 characters...") +class SendContentError(Exception): + pass + class InvalidCurrencyError(Exception): pass @@ -103,3 +118,6 @@ class DockerError(Exception): class SignalError(Exception): pass + +class InvalidPathError(Exception): + pass diff --git a/status_sdk/group_chat.py b/status_sdk/group_chat.py index 014a687..3e8583a 100644 --- a/status_sdk/group_chat.py +++ b/status_sdk/group_chat.py @@ -69,6 +69,20 @@ def send_message(self, message: str, reply_to_message_id: Optional[str] = None) """ return self.__account.send_message(self.id, message, reply_to_message_id) + def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: + """ + Send a image to the group chat. + + Parameters: + - `file_path` - the file path of the image + - `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_image(self.id, file_path, message, reply_to_message_id) + def get_messages(self, start_timestamp: Optional[datetime.datetime] = None, end_timestamp: Optional[datetime.datetime] = None) -> list[dict]: """ diff --git a/status_sdk/utils.py b/status_sdk/utils.py index 040e9a7..73a9f3c 100644 --- a/status_sdk/utils.py +++ b/status_sdk/utils.py @@ -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//...` + 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) + + 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"STATUS_GO_REF={ref}", f"STATUS_GO_PLATFORM={platform}", "docker", "compose", "-f", docker_path, "up", "-d", "--build"] + 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)}")