diff --git a/bot/account.py b/bot/account.py index 50f7da2..7036d08 100644 --- a/bot/account.py +++ b/bot/account.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Generator +from typing import Optional, Union, Generator, Any import requests, datetime, re from .signal import Signal @@ -57,26 +57,45 @@ class Account: if not isinstance(accounts, list): accounts = [] - self.__available_accounts = { - account["name"]: { + self.__available_accounts = [ + { + "display_name": account["name"], "key_uid": account["key-uid"], "created_at": datetime.datetime.fromtimestamp(account["timestamp"]) } for account in accounts - } + ] # In case if there is a hanging logged in session self.logout() - def login(self, username: str, password: str): + def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None): """ Login to the given account. If it does not exist, it will be created and automatically logged in. Parameters: - - `username` - your Status display name - `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`) """ - key_uid = self.__available_accounts.get(username, {}).get("key_uid") + 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)...") + + # Login combination: display_name + password + if not key_uid: + for account in self.__available_accounts: + if account["display_name"] != display_name: + continue + + key_uid = account["key_uid"] + break + # Login combination: key_uid + password + else: + available_key_uids = [current["key_uid"] for current in self.__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)) params = { @@ -85,10 +104,11 @@ class Account: 'kdfIterations': self.__kd_iterations } if is_new_account: + self.__validate_display_name(display_name) params = { "rootDataDir": self.__unix_folder, "kdfIterations": self.__kd_iterations, - "displayName": username, + "displayName": display_name, "password": password, "customizationColor": "primary", "wakuV2LightClient": False, @@ -106,8 +126,10 @@ class Account: "public_key": event["public-key"], "emojis": event["emojiHash"], "key_uid": event["key-uid"], + "compressed_key": event["compressedKey"], "mnemonic": event["mnemonic"], - "name": event["display-name"], + "display_name": event["display-name"], + "bio": event.get("bio", ""), "password": password, "wallet_address": event["address"], "logged_in_timestamp": datetime.datetime.now() @@ -125,6 +147,13 @@ class Account: self.__is_messenger_launched = False return self + @property + def available_accounts(self) -> list[dict]: + """ + All locally available accounts + """ + return self.__available_accounts + @property def info(self) -> dict: """ @@ -135,6 +164,52 @@ class Account: raise Exception("Make sure you are logged in to your Status account with login() first...") return self.__info + @property + def display_name(self) -> str: + """ + Get the current display name + """ + return self.info["display_name"] + + @display_name.setter + def display_name(self, name: str): + self.__validate_display_name(name) + output = self.__call_rpc("messaging", "setDisplayName", [name]) + # It seems that if a valid name is given, it will be instantly updated + # However after tracing the signals, an `envelope.sent` is sent a bit + # after the name has been changed. + self.signal.get("envelope.sent") + self.__info["display_name"] = name + + @property + def bio(self) -> str: + """ + Get the current bio + """ + return self.info["bio"] + + @bio.setter + def bio(self, bio: Any): + if isinstance(bio, type(None)): + bio = "" + + bio = str(bio).strip() + # Limit based from Status App + CHARACTERS = 240 + if len(bio) > CHARACTERS: + raise ValueError(f"Bio cannot be longer than {CHARACTERS} characters...") + + self.__call_rpc("messaging", "setBio", [bio]) + # It seems that if a valid bio is given, it will be instantly updated + # However after tracing the signals, an `envelope.sent` is sent a bit + # after the bio has been updated. + self.signal.get("envelope.sent") + self.__info["bio"] = bio + + @bio.deleter + def bio(self): + self.bio = "" + @property def contacts(self) -> dict[str, dict]: """ @@ -347,7 +422,7 @@ class Account: break if not display_name: - raise Exception(f"Cannot add contact {public_key}...\nPlease make sure you add display_name for contacts that you are sending friend requests to and have never interacted with before!") + raise ValueError(f"Cannot add contact {public_key}...\nPlease make sure you add display_name for contacts that you are sending friend requests to and have never interacted with before!") params = [{"id": public_key, "nickname": "", "displayName": display_name, "ensName": ""}] self.__call_rpc("messaging", "addContact", params) @@ -442,7 +517,7 @@ class Account: self.info name = self.__prefix_mapping.get(prefix) if not name: - raise Exception(f"Name {name} does not exist... Available options: {list(self.__prefix_mapping.keys())}") + raise ValueError(f"Name {name} does not exist... Available options: {list(self.__prefix_mapping.keys())}") data = { 'jsonrpc': '2.0', @@ -471,3 +546,32 @@ class Account: s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name) s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1) return s2.lower() + + def __validate_display_name(self, name: str) -> bool: + """ + Validate the display name based on Status App rules. + Validation most probably is dealt with on the GUI side + of the application instead of the backend. + + Status App validation rules: + - Use A-Z and 0-9, hyphens and underscores only + - Display name must be at least 5 characters long + - Display name can't start or end with a space + + Parameters: + - `name` - the name that the user wants to use to login / create account / change + + Output: + - `True` if the name was successfully changed. A + """ + if name != name.strip(): + raise ValueError("Display name cannot start or end with a space.") + + if len(name) < 5: + raise ValueError("Display name must be at least 5 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 21e8600..217b153 100644 --- a/bot/docs/account.md +++ b/bot/docs/account.md @@ -2,16 +2,55 @@ The account class allows you to easily work with a Status account. +## Display name + +The **display name** is the human‑readable identifier for a Status account. It is used when creating an account, resolving an existing account during [`login`](./account.md#loginpassword-key_uidnone-display_namenone), and when updating the account name through the [`display_name`](./account.md#display_name) property. + +Display names must follow strict validation rules enforced by the library and expected by the Status application. A valid display name must satisfy all of the following conditions: + +- It may contain **uppercase letters (`A–Z`)** +- It may contain **numbers (`0–9`)** +- It may contain **hyphens (`-`)** +- It may contain **underscores (`_`)** +- It must be **at least 5 characters long** +- It **cannot start or end with a space** + +Characters such as spaces, punctuation, emojis, or other symbols are **not allowed**. + +### Valid examples + +``` +alpha_01 +STATUS-01 +bot_user_5 +HELLO123 +node-42 +``` + +### Invalid examples + +| Example | Reason | +|-------|--------| +| `bot` | Too short (minimum length is 5) | +| ` mybot` | Leading space | +| `mybot ` | Trailing space | +| `bot!123` | Contains invalid character `!` | +| `bot user` | Spaces are not allowed | + +If a display name does not follow these rules, a **`ValueError`** will be raised by the account validation logic. + + ## Methods -### `login(username, password)` +### `login(password, key_uid=None, display_name=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. | Name | Type | Required | Description | |-----|-----|-----|-------------| -| `username` | `str` | Yes | Display name of the Status account | | `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. | Returns the current `Account` instance, allowing method chaining. @@ -19,7 +58,22 @@ Returns the current `Account` instance, allowing method chaining. from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) +``` + +```python +from bot import Account + +account = Account() +params = { + "key_uid": "0xff2c3...", + "password": "SNTPUMP" +} +account.login(**params) ``` ### `logout()` @@ -30,7 +84,11 @@ Logout from the currently logged-in Status account. This method also clears the from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) # Optional - even if not specified __del__ will log you out account.logout() @@ -54,7 +112,11 @@ Send a text message to a specific chat. This method currently supports **text me from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_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] @@ -82,7 +144,11 @@ from bot import Account import datetime account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) chat = account.chats[0] @@ -109,7 +175,11 @@ from rich import print as rprint from rich.pretty import Pretty account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) for msg in account.listen_messages(): rprint(Pretty(msg)) @@ -143,7 +213,11 @@ Returns the current `Account` instance, allowing method chaining. from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) # Send a contact request account.add_contact( @@ -182,7 +256,11 @@ Returns `bool`. from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) # NOTE: contacts are returned as a dict for # internal class checks and scalability @@ -208,7 +286,11 @@ Returns `datetime.datetime` representing when the join request was submitted. from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) account.send_request_community( "https://status.app/c/community-invite-link" @@ -218,6 +300,30 @@ account.send_request_community( ## Properties +### `available_accounts` + +Returns all Status accounts that are **locally available** in the initialized data directory. These accounts are detected when the `Account` class is initialized. + +This property is useful when you want to: +- inspect which accounts exist locally +- retrieve a `key_uid` for login +- display metadata about stored accounts + +**You will have to know the passwords for the given `key_uid`.** + +Returns `list[dict]`. + +```python +from bot import Account +# For terminal readability only +from rich import print as rprint +from rich.pretty import Pretty + +account = Account() + +rprint(Pretty(account.available_accounts)) +``` + ### `info` Provides information about the currently logged-in account. If `login()` has not been called, accessing this property will raise an exception. Returns `dict` containing account metadata. @@ -227,8 +333,9 @@ Provides information about the currently logged-in account. If `login()` has not | `public_key` | `str` | Public key that uniquely identifies the account. | | `emojis` | `str` | Emoji hash associated with the account identity. | | `key_uid` | `str` | Internal Status key identifier for the account. | +| `compressed_key` | `str` | The chat key as it is in Status App. | | `mnemonic` | `str` | Mnemonic phrase used to generate the account keys. | -| `name` | `str` | Display name of the account. | +| `display_name` | `str` | Display name of the account. | | `password` | `str` | Password used to encrypt the account locally. | | `wallet_address` | `str` | Ethereum wallet address associated with the account. | | `logged_in_timestamp` | `datetime.datetime` | Timestamp when the account successfully logged in. | @@ -237,7 +344,11 @@ Provides information about the currently logged-in account. If `login()` has not from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) print(account.info) ``` @@ -279,7 +390,11 @@ Returns `dict[str, dict]` where the key is the contact's **public key**. This ma from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) contacts = account.contacts @@ -368,7 +483,11 @@ Returns `list[dict]` where each `dict` represents a chat that can be used with [ from bot import Account account = Account() -account.login("status-app-bot", "SNTPUMP") +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) # This is under the assumption you already have a contact / joined a community for chat in account.chats: @@ -390,3 +509,97 @@ The property exposes two primary methods: - `signal.get()` — fetch a single event. If the event is not found, you may end up in an infinite loop. - `signal.listen()` — stream events continuously. Example usage of this is found in [`listen_messages()`](./account.md#listen_messages) + +### `display_name` + +Get or update the current display name of the logged‑in account. + +Returns `str` when reading the property. + +```python +from bot import Account + +account = Account() +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# Get the current display name +print(account.display_name) +``` + +You can update the display name by assigning a new value: + +```python +from bot import Account + +account = Account() +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# Change the display name +account.name = "status_bot_42" +print(account.display_name) +``` + +**Note**: Next time you login with the changed display name, you will have to put in the new display name, instead of the initial one. + +### `bio` + +Get or update the **bio** of the currently logged‑in account. The length of the bio (as in Status App) is 240 characters. + +Returns `str` when reading the property. + +```python +from bot import Account + +account = Account() +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# Read the current bio +print(account.bio) +``` + +The value assigned to `bio` will automatically be converted to a string before being sent to the backend. You can update the bio by assigning a new value: + +```python +from bot import Account + +account = Account() +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# Update the bio +account.bio = "Monitoring Status communities and chats" +print(account.bio) +``` + +You can also **clear the bio** by deleting the property: + +```python +from bot import Account + +account = Account() +params = { + "display_name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# Clears the bio - same as: +# account.bio = "" +# account.bio = None +del account.bio +```