account: backup

- Create `.bkp` files
- Import `.bkp` files
- Custom logger for current step visibility
- Add group chat monitoring
This commit is contained in:
Nick Ninov
2026-03-14 23:38:54 +00:00
committed by GitHub
6 changed files with 250 additions and 42 deletions
+3 -1
View File
@@ -211,8 +211,10 @@ __marimo__/
.vscode/
*.json
*.ipynb
/data-dir
/uploads
*.DS_Store
*.pkl
*.dockerignore
# Docker volumes for Status
/data-dir
*.bkp
+141 -36
View File
@@ -1,7 +1,7 @@
from typing import Optional, Union, Generator, Any
import requests, datetime, re
import requests, datetime, re, logging, os
from .signal import Signal
from .logger import Logger
class Account:
# Enum mappings from original wakuext.py
@@ -18,19 +18,25 @@ class Account:
"messaging": "wakuext",
"urls": "sharedurls"
}
def __init__(self, unix_folder: str = "./data-dir", domain: str = "localhost", port: int = 8080, is_secure: bool = False):
def __init__(self, domain: str = "localhost", port: int = 8080, is_secure: bool = False):
"""
Work with your own Status App account
Parameters:
- `unix_folder` - where Status Backend files will be initialized. **This folder is required in the Docker container**. Ideally it should be a volume so the account data is persistant if the image is deleted. Folder path is automatically created.
- `domain` - the domain name where Status Backend is running. If running locally it would be `localhost` and if it's running in a container it would be the image's name.
- `port` - the port to connect to Status Backend. Verify the port in the Docker files.
- `is_secure` - if `http` or `https` should be used
"""
# Path of the account data in the Docker container for Status Backend
self.__docker_data_folder = "./data-dir"
# Path of the backups in the Docker container for Status Backend
self.__docker_backup_folder = "./root/.config/Status/backups"
# As the docker-compose.yaml folder is at the moment
# NOTE: This might change for initial release
self.__backup_local_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), "backups")
self.__logger = Logger()
self.__timestamp_divisor = 1_000
self.__kd_iterations = 256000
self.__unix_folder = unix_folder
self.__is_messenger_launched = False
# Information for the logged in account
self.__info = {}
@@ -41,34 +47,23 @@ class Account:
"initialize": f"{self.http_base_url}InitializeApplication",
"login": f"{self.http_base_url}LoginAccount",
"create": f"{self.http_base_url}CreateAccountAndLogin",
"restore": f"{self.http_base_url}RestoreAccountAndLogin",
"logout": f"{self.http_base_url}Logout",
"rpc": f"{self.http_base_url}CallRPC"
"create_backup": f"{self.http_base_url}PerformLocalBackup",
"load_backup": f"{self.http_base_url}LoadLocalBackup",
"rpc": f"{self.http_base_url}CallRPC",
},
"socket": {
"signals": f"{self.ws_base_url}signals"
}
}
self.__signal = Signal(self.urls["socket"]["signals"])
response = requests.post(self.urls["http"]["initialize"], json={
"dataDir": self.__unix_folder
})
data: dict = response.json()
accounts: list[dict] = data.get("accounts", [])
if not isinstance(accounts, list):
accounts = []
self.__available_accounts = [
{
"display_name": account["name"],
"key_uid": account["key-uid"],
"created_at": datetime.datetime.fromtimestamp(account["timestamp"])
}
for account in accounts
]
# Initialize profile
self.available_accounts
# In case if there is a hanging logged in session
self.logout()
def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None):
def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None, mnemonic: Optional[str] = None):
"""
Login to the given account. If it does not exist,
it will be created and automatically logged in.
@@ -77,13 +72,15 @@ class Account:
- `password` - your Status password
- `key_uid` - your key unique identifier. If not provided `display_name` will be used to fetch it. This means that each `display_name` can be linked to one `key_uid`
- `display_name` - your Status display name. Use `display_name` and `password` parameter combination if you have a 1 to 1 mapping (each display name has a unique `key_uid`)
- `mnemonic` - the mnemonic when creating an account. Use this field with `password` and `display_name` to recover an account
"""
if not key_uid and not display_name:
raise ValueError("Please provide either a Key Unique Identifier (key_uid) or a Display Name (display_name)...")
available_accounts = self.available_accounts
# Login combination: display_name + password
if not key_uid:
for account in self.__available_accounts:
for account in available_accounts:
if account["display_name"] != display_name:
continue
@@ -91,22 +88,38 @@ class Account:
break
# Login combination: key_uid + password
else:
available_key_uids = [current["key_uid"] for current in self.__available_accounts]
available_key_uids = [current["key_uid"] for current in available_accounts]
if key_uid not in available_key_uids:
info = "\n".join([f"{current['key_uid']} - {current['display_name']}" for current in self.__available_accounts])
raise ValueError(f"Given Key Unique Identifier is invalid...\nAvailable Key Unique Identifiers:\n{info}")
is_new_account = isinstance(key_uid, type(None))
is_recovery = not isinstance(mnemonic, type(None)) and not key_uid
url_key = "login"
params = {
"keyUid": key_uid,
"password": password,
'kdfIterations': self.__kd_iterations
}
if is_new_account:
if is_recovery:
self.__validate_display_name(display_name)
params = {
"rootDataDir": self.__unix_folder,
"mnemonic": mnemonic,
"rootDataDir": self.__docker_data_folder,
"kdfIterations": self.__kd_iterations,
"displayName": display_name,
"password": password,
"customizationColor": "primary",
"wakuV2LightClient": False,
"thirdpartyServicesEnabled": True
}
url_key = "restore"
self.logger.info(f"Restoring account for given mnemonics")
elif is_new_account:
self.__validate_display_name(display_name)
params = {
"rootDataDir": self.__docker_data_folder,
"kdfIterations": self.__kd_iterations,
"displayName": display_name,
"password": password,
@@ -114,20 +127,26 @@ class Account:
"wakuV2LightClient": False,
"thirdpartyServicesEnabled": True,
}
url_key = "create"
self.logger.info(f"Creating account with display_name {display_name}")
else:
self.logger.info(f"Logging in with Key UID - {key_uid}")
self.logout()
url = self.urls["http"]["login" if not is_new_account else "create"]
url = self.urls["http"][url_key]
response = requests.post(url, json=params)
signal_event = self.__signal.get("node.login")
if signal_event["is_error"]:
raise Exception(f"There was an error with Status Backend...\n{signal_event['error_message']}")
self.logger.info("Successfully logged in!")
event: dict = signal_event["event"]["settings"]
self.__info = {
"public_key": event["public-key"],
"emojis": event["emojiHash"],
"key_uid": event["key-uid"],
"compressed_key": event["compressedKey"],
"mnemonic": event["mnemonic"],
"mnemonic": event.get("mnemonic", mnemonic),
"display_name": event["display-name"],
"bio": event.get("bio", ""),
"password": password,
@@ -136,6 +155,12 @@ class Account:
}
# Messenger can be activated only when logged in
self.__start_messenger()
if is_recovery:
self.logger.info("Updating remote display name")
self.display_name = event["display-name"]
self.logger.info("Successfully updated display name!")
self.__load_backup()
return self
def logout(self):
@@ -147,12 +172,32 @@ class Account:
self.__is_messenger_launched = False
return self
@property
def logger(self) -> logging.Logger:
return self.__logger
@property
def available_accounts(self) -> list[dict]:
"""
All locally available accounts
"""
return self.__available_accounts
response = requests.post(self.urls["http"]["initialize"], json={
"dataDir": self.__docker_data_folder
})
data: dict = response.json()
accounts: list[dict] = data.get("accounts", [])
if not isinstance(accounts, list):
accounts = []
current_available_accounts = [
{
"display_name": account["name"],
"key_uid": account["key-uid"],
"created_at": datetime.datetime.fromtimestamp(account["timestamp"])
}
for account in accounts
]
return current_available_accounts
@property
def info(self) -> dict:
@@ -330,7 +375,19 @@ class Account:
{"type": "contact", "id": contact["chat_id"], "name": contact["display_name"]}
for contact in self.contacts.values()
]
return contacts + communities
# Group chats in RPC endpoint are chat type 3
data = self.__call_rpc("messaging", "activeChats")
result: Optional[list[dict]] = data.get("result", [])
if not result:
result = []
group_chats = [
{"type": "group_chat", "id": active_chat["id"], "name": active_chat["name"]}
for active_chat in result
if active_chat["chatType"] == 3
]
return contacts + communities + group_chats
def send_message(self, chat_id: str, message: str):
"""
@@ -379,10 +436,17 @@ class Account:
while not finished:
data = self.__call_rpc("messaging", "chatMessages", list(params.values()))
result: dict[str, Union[str, list[dict]]] = data.get("result", {})
if result["messages"] and not timestamp_keys:
messages: Optional[list[dict]] = result.get("messages")
cursor: Optional[str] = result.get("cursor")
if not cursor:
cursor = ""
if not messages:
messages = []
if messages and not timestamp_keys:
timestamp_keys = [key for key in result["messages"][0].keys() if "timestamp" in key.lower()]
for message in result["messages"]:
for message in messages:
point = {
self.__camel_to_snake(key): value if key not in timestamp_keys else datetime.datetime.fromtimestamp(value / self.__timestamp_divisor)
for key, value in message.items()
@@ -397,8 +461,8 @@ class Account:
all_messages.append(point)
if len(result["cursor"]) > 0:
params["cursor"] = result["cursor"]
if len(cursor) > 0:
params["cursor"] = cursor
else:
finished = True
@@ -476,6 +540,23 @@ class Account:
data = self.__call_rpc("messaging", "requestToJoinCommunity", params)
return datetime.datetime.fromtimestamp(raw.get("requestedToJoinAt", datetime.datetime.now().timestamp()))
def backup(self) -> str:
"""
Create a `.bkp` (Backup) for the account. If the backup was not successful, a custom exception will be raised.
Output:
- the Docker backup path (linked to a volume). The file name is unique per account.
"""
self.info
response = requests.post(self.urls["http"]["create_backup"])
result: dict = response.json()
file_path = result.get("filePath")
if not file_path or (isinstance(file_path, str) and len(file_path) == 0):
raise Exception(f"There was an error with creating a backup for {self.info['display_name']}")
return file_path
def __start_messenger(self):
"""
Start the decentralized messaging service.
@@ -483,8 +564,11 @@ class Account:
"""
if self.__is_messenger_launched:
return
self.logger.info("Starting messaging")
self.__call_rpc("messaging", "startMessenger")
self.__signal.get("wakuv2.peerstats")
self.__is_messenger_launched = True
self.logger.info("Messaging launched")
def __del__(self):
"""
@@ -500,6 +584,25 @@ class Account:
"""
return self.__call_rpc(prefix, method_name, params)
def __load_backup(self):
"""
Try to load every file in the Docker volume
when an account recover is done.
"""
for file_name in os.listdir(self.__backup_local_folder):
params = {
"filePath": os.path.join(self.__docker_backup_folder, file_name)
}
self.logger.info(f"Trying to load {file_name}")
response = requests.post(self.urls["http"]["load_backup"], json=params)
error: str = response.json().get("error", "")
if len(error) == 0:
self.__signal.get("history.request.completed")
self.logger.info(f"Successfully loaded file!")
break
self.logger.warning(error)
def __call_rpc(self, prefix: str, method_name: str, params: Optional[Union[list, dict]] = None) -> dict:
"""
Make RPC calls to Status Backend
@@ -570,8 +673,10 @@ class Account:
if len(name) < 5:
raise ValueError("Display name must be at least 5 characters long.")
if len(name) > 24:
raise ValueError("Display name cannot be more than 24 characters long.")
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
raise ValueError("Display name can contain only A-Z, 0-9, hyphens (-), and underscores (_).")
return True
+80 -3
View File
@@ -13,6 +13,7 @@ Display names must follow strict validation rules enforced by the library and ex
- It may contain **hyphens (`-`)**
- It may contain **underscores (`_`)**
- It must be **at least 5 characters long**
- It **cannot be more than 24 characters long**
- It **cannot start or end with a space**
Characters such as spaces, punctuation, emojis, or other symbols are **not allowed**.
@@ -39,21 +40,33 @@ node-42
If a display name does not follow these rules, a **`ValueError`** will be raised by the account validation logic.
## Backups
Backup files (`.bkp`) can be both created in [Status App](https://our.status.im/status-desktop-v2-35-local-backups-new-home-page-performance-boosts-and-more/) and the [Python SDK](./account.md#backup). Status Backend backup folder is exposed in a Docker volume so users can:
- **Upload backup** - by dropping`.bkp` files in the `backups` folder locally (linked to Status Backend Docker container). Backups are automatically uploaded if a [`mnemonic` is provided during `login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone).
- **Create backup** - by using [`backup()`](./account.md#backup) or creating one in [Status App](https://our.status.im/status-desktop-v2-35-local-backups-new-home-page-performance-boosts-and-more/).
**Note**: Status App will not automatically backup messages. This has to be manually overridden on the app. When using the Python SDK, the messages are automatically stored in the `.bkp` files.
## Methods
### `login(password, key_uid=None, display_name=None)`
### `login(password, key_uid=None, display_name=None, mnemonic=None)`
Login to an existing Status account. If the account does not exist in the initialized data directory, a new account will be created and automatically logged in. After a successful login, the decentralized messenger service is automatically started so the account can send and receive messages.
An account can also be recovered if the `mnemonic` is passed.
| Name | Type | Required | Description |
|-----|-----|-----|-------------|
| `password` | `str` | Yes | Password used to encrypt the account |
| `key_uid` | `str` | Yes* | Unique key identifier of the account. If provided, the account will be logged in directly using this identifier. If not provided, then you must use `display_name` and `password` to login. |
| `display_name` | `str` | Yes* | Display name of the account. Used to resolve the `key_uid` if it is not provided, or to create a new account if one does not already exist. |
| `display_name` | `str` | Yes* | Display name of the account. Used to resolve the `key_uid` if it is not provided, or to create a new account if one does not already exist. This field is required if an account needs to be recovered with `mnemonic`. |
| `mnemonic` | `str` | No | The mnemonic from [`info`](./account.md#info). Use this field with `password` and `display_name` to recover the account. If you have [`.bkp`](./account.md#backup) files, in the backup Docker volume they will be automatically picked up and loaded.<br>**Note**: You can pass a different `display_name` but that will be internal only. When an account is recovered setting [`display_name`](./account.md#display_name) can be buggy. Ideally when recovering the account, use the original `display_name` of the account. |
Returns the current `Account` instance, allowing method chaining.
Login with `display_name`:
```python
from bot import Account
@@ -65,6 +78,9 @@ params = {
account.login(**params)
```
**Note**: This assumes that `display_name` and is unique for every `key_uid`. If there are duplicated `display_names` then the first found match will be used. You can log in with `key_uid` if you have `display_name` duplicates.
Login with `key_uid`:
```python
from bot import Account
@@ -76,6 +92,24 @@ params = {
account.login(**params)
```
Recover account:
```python
from bot import Account
account = Account()
params = {
"display_name": "status-app-bot",
"password": "SNTPUMP",
"mnemonic" : "phrase_1 phrase_2 phrase_3 phrase_4 phrase_5 phrase_6 phrase_7 phrase_8 phrase_9 phrase_10 phrase_11 phrase_12"
}
account.login(**params)
```
**Note**: When in recovery mode, the display name is updated on Status App as well so it is consistent locally and to other users.
### `logout()`
Logout from the currently logged-in Status account. This method also clears the internal account state and stops the active messenger session. This function is also supported in `del` and when the script automatically finishes.
@@ -296,7 +330,25 @@ account.send_request_community(
"https://status.app/c/community-invite-link"
)
```
### `backup()`
Create a **local backup file** (`.bkp`) for the currently loggedin account. The backup is generated by the Status Backend and stored inside the configured Docker backup volume. Each file is uniquely associated with an account. If the backup creation fails, an **exception will be raised**.
Returns `str` representing the **Docker path** of the generated backup file. The returned path refers to the **Docker container path** where the backup was created. If the backup directory is mounted as a Docker volume, the file will also appear on the host machine in the mapped folder.
```python
from bot import Account
account = Account()
params = {
"display_name": "status-app-bot",
"password": "SNTPUMP"
}
account.login(**params)
backup_path = account.backup()
print(f"Backup created at: {backup_path}")
```
## Properties
@@ -470,12 +522,13 @@ for community in account.communities:
Get all chats that the account can **send messages to**. This includes:
- [`contacts`](./account.md#contacts) — direct messages with users
- [`communities`](./account.md#communities) — community channels where the account has **posting permission**
- Group chats that the account is in
Returns `list[dict]` where each `dict` represents a chat that can be used with [`send_message`](./account.md#send_messagechat_id-message) and [`get_messages`](./account.md#get_messageschat_id-start_timestampnone-end_timestampnone).
| Key | Type | Description |
|----|----|-------------|
| `type` | `str` | Type of chat (`contact` or `channel`). |
| `type` | `str` | Type of chat (`contact`, `channel` or `group_chat`). |
| `id` | `str` | Chat identifier used when sending messages. |
| `name` | `str` | Either the display name of the user or the community channel name. |
@@ -603,3 +656,27 @@ account.login(**params)
# account.bio = None
del account.bio
```
### `logger`
Provides access to the internal **Python logger** for monitoring the lifecycle of the account and backend operations such as login, account creation, messenger startup, and recovery.
Returns `logging.Logger`.
Default logger configuration:
- **Name**: `status-bot`
- **Level**: `INFO`
- **Output**: standard output (terminal)
Example:
```python
from bot import Account
account = Account()
account.logger.info("Starting Status bot")
account.logger.warning("This is a warning")
account.logger.error("Something went wrong")
```
+24
View File
@@ -0,0 +1,24 @@
from typing import Optional
import logging
class Logger:
instance: Optional[logging.Logger] = None
def __new__(cls) -> logging.Logger:
if cls.instance:
return cls.instance
cls.instance = logging.getLogger("status-bot")
cls.instance.setLevel(logging.INFO)
cls.instance.propagate = False
handler = logging.StreamHandler()
formatter = logging.Formatter(
f"[%(asctime)s] [%(levelname)s]\t%(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
handler.setFormatter(formatter)
cls.instance.addHandler(handler)
return cls.instance
+1 -1
View File
@@ -1,5 +1,5 @@
from typing import Optional
import datetime, websocket, json, copy,queue, threading
import datetime, websocket, json, copy, queue, threading
class Signal:
"""
+1 -1
View File
@@ -10,7 +10,7 @@ services:
entrypoint: 'status-backend'
command: '-address 0.0.0.0:8080'
volumes:
- ./data-dir:/data-dir
- ./backups:/root/.config/Status/backups
networks:
- status-bridge
profiles: