diff --git a/.gitignore b/.gitignore index f7a6069..d7caf06 100644 --- a/.gitignore +++ b/.gitignore @@ -211,8 +211,10 @@ __marimo__/ .vscode/ *.json *.ipynb -/data-dir /uploads *.DS_Store *.pkl *.dockerignore +# Docker volumes for Status +/data-dir +*.bkp diff --git a/bot/account.py b/bot/account.py index 7036d08..16bc8af 100644 --- a/bot/account.py +++ b/bot/account.py @@ -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 - diff --git a/bot/docs/account.md b/bot/docs/account.md index 217b153..67aaaf7 100644 --- a/bot/docs/account.md +++ b/bot/docs/account.md @@ -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.
**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 logged‑in 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") +``` diff --git a/bot/logger.py b/bot/logger.py new file mode 100644 index 0000000..850b254 --- /dev/null +++ b/bot/logger.py @@ -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 diff --git a/bot/signal.py b/bot/signal.py index 0e6d2f8..1ea75a8 100644 --- a/bot/signal.py +++ b/bot/signal.py @@ -1,5 +1,5 @@ from typing import Optional -import datetime, websocket, json, copy,queue, threading +import datetime, websocket, json, copy, queue, threading class Signal: """ diff --git a/docker-compose.yaml b/docker-compose.yaml index 31c5246..7503df3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: