diff --git a/docs/account.md b/docs/account.md index f444f53..6d7c08b 100644 --- a/docs/account.md +++ b/docs/account.md @@ -100,6 +100,20 @@ Wallet features are optional and can be omitted if not required for your use cas ![Status App Wallet](./images/account/wallet.png) +## Installation ID + +Currently installation IDs can be found in **Debug Mode** only. To turn **Debug Mode**: + +![Status App Debug Mode](./images/account/debug-mode.png) + +Once **Debug Mode** is turned on and Status App is restarted, you can go to **Syncing** tab. + +![Status App Sync 1](./images/account/syncing-1.png) + +The **Installation ID** should be used when calling [`sync`](./account.md#syncinstallation_id-namenone) and [`unsync`](./account.md#unsyncinstallation_id). + +![Status App Sync 2](./images/account/syncing-2.png) + ## `Account(domain="localhost", backend_port=8080, media_port=9000, is_secure=False, backup_folder=None, volume_folder=None)` Create a new `Account` instance ready to be logged in. The constructor wires the SDK to a running [Status Backend](https://github.com/status-im/status-go) at the given `domain` and `backend_port`, prepares the local `assets/` folder (used for image uploads, such as the [profile picture](./account.md#profile_picture)) and `backups/` folder (used for [backup uploads](./account.md#backups) and recovery). @@ -314,6 +328,60 @@ backup_path = account.backup() print(f"Backup created at: {backup_path}") ``` +### `sync(installation_id, name=None)` + +Pair another **device** with the account, so messages, contacts and settings are synced between them. This is the SDK equivalent of **Sync new device** in Status App - useful for running a remotely while keeping the same account on a phone or desktop. + +Each device that logs into an account is registered with the backend as an **installation**, identified by an `installation_id`. A device reports its own id under `installation_id` in [`info`](./account.md#info), so pairing means passing the **other** device's id to this method. Both devices must have logged in to the same Status account for the installation to be known to the backend. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `installation_id` | `str` | Yes | The id of the device to pair with. It is the value that device reports under `installation_id` in its own [`info`](./account.md#info). | +| `name` | `str` | No | The name of the paired device, so it is easier to recognise locally. When omitted, the device keeps whatever name it already has. | + +Returns `None`. Passing the logged-in account's **own** `installation_id` is a **no-op**, so a device can safely loop over a list of ids without filtering itself out first. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +# The id the other device reports under `installation_id` in its own `info` +account.sync("6a2f9c1e-...", "raspberry-pi") +``` + +**Note**: [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) **deletes** every installation that is not enabled. A device that was never synced, or that was [unsynced](./account.md#unsyncinstallation_id), is therefore removed on the next login and has to be re-registered by logging in from that device again. + +### `unsync(installation_id)` + +Stop syncing with a device that was paired with [`sync`](./account.md#syncinstallation_id-namenone). The device stops receiving the account's messages, contacts and settings. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `installation_id` | `str` | Yes | The id of the device to stop syncing with, in the same format accepted by [`sync`](./account.md#syncinstallation_id-namenone). | + +Returns `None`. As with [`sync`](./account.md#syncinstallation_id-namenone), passing the account's **own** `installation_id` is a **no-op** - an account cannot unsync itself. A custom exception is raised if the backend rejects the call. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +account.unsync("6a2f9c1e-...") +``` + +**Note**: unsyncing only **disables** the installation, so it can be paired again with [`sync`](./account.md#syncinstallation_id-namenone) within the same session. It does not survive a restart though - the next [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) deletes disabled installations, and the other device has to log in again before it can be synced. + ### Chat #### `send_message(chat_id, message, reply_to_message_id=None)` @@ -1172,6 +1240,7 @@ Provides information about the currently logged-in account. If `login()` has not | `password` | `str` | Password used to encrypt the account locally. | | `wallet_address` | `str` | Ethereum wallet address associated with the account. | | `ens` | `dict` | The account's [ENS](https://status.app/help/profile/transfer-your-ens-name-to-status) details. Contains `preferred_name` (`str` or `None`) - the ENS name the account has chosen to display - and `usernames` (`list[dict]`) - every ENS username registered to the account. Both are empty / `None` when no ENS name is set. | +| `installation_id` | `str` | Id of **this** device's installation. Pass it to another device's [`sync`](./account.md#syncinstallation_id-namenone) to pair the two. `None` if the backend did not return one. | | `logged_in_timestamp` | `datetime.datetime` | Timestamp when the account successfully logged in. | ```python diff --git a/docs/images/account/debug-mode.png b/docs/images/account/debug-mode.png new file mode 100644 index 0000000..0cdb184 Binary files /dev/null and b/docs/images/account/debug-mode.png differ diff --git a/docs/images/account/syncing-1.png b/docs/images/account/syncing-1.png new file mode 100644 index 0000000..b9e6471 Binary files /dev/null and b/docs/images/account/syncing-1.png differ diff --git a/docs/images/account/syncing-2.png b/docs/images/account/syncing-2.png new file mode 100644 index 0000000..e22b831 Binary files /dev/null and b/docs/images/account/syncing-2.png differ diff --git a/status_sdk/account.py b/status_sdk/account.py index e5b4d20..eba4df0 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -28,7 +28,8 @@ class Account: "urls": "sharedurls", "wallets": "wallet", "account": "accounts", - "identity": "multiaccounts" + "identity": "multiaccounts", + "settings": "settings" } __keccak256_selectors = { "transfer": "a9059cbb" # keccak256("transfer(address,uint256)")[:4] @@ -40,6 +41,7 @@ class Account: "on": 3, "off": 4 } + __INSTALLATION_NAME = "python-sdk" def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_port: int = 9000, is_secure: bool = False, backup_folder: Optional[str] = None, volume_folder: Optional[str] = None): """ Work with your own Status App account @@ -236,9 +238,12 @@ class Account: "preferred_name": event.get("preferred-name"), "usernames": ens_info }, + "installation_id": None, "logged_in_timestamp": datetime.datetime.now() } self.__info["url"] = self._call_rpc("urls", "shareUserURLWithData", [event["public-key"]]).get("result") + result = self._call_rpc("settings", "getSettings").get("result") or {} + self.__info["installation_id"] = result.get("installation-id") # Messenger can be activated only when logged in self.__start_messenger() if is_recovery: @@ -247,6 +252,14 @@ class Account: self.logger.info("Successfully updated display name!") self.__load_backup() + if self.__info["installation_id"]: + self._call_rpc("messaging", "setInstallationName", [self.__info["installation_id"], self.__INSTALLATION_NAME]) + + for sync_info in self._call_rpc("messaging", "getOurInstallations").get("result") or []: + + if not sync_info["enabled"]: + self._call_rpc("messaging", "deleteInstallation", [sync_info["id"]]) + return self def logout(self): @@ -1371,6 +1384,51 @@ class Account: self.__transactions = final.copy() return self.__transactions.copy() + def sync(self, installation_id: str, name: Optional[str] = None): + """ + Pair another device (installation) with the account, so accounts are synced. + Both devices must be logged in to the same Status account for the installation + to be known to the backend. + + Parameters: + - `installation_id` - the id of the device to pair with. + - `name` - the name of the paired device. + """ + if installation_id == self.info["installation_id"]: + return + + params = [{"installationId": installation_id}] + output = self._call_rpc("messaging", "enableInstallationAndPair", params) + error = (output.get("error") or {}).get("message", "") + if error: + raise exceptions.DeviceSyncError(f"Could not sync with installation '{installation_id}' - {error}") + + if not name: + return + + params = [installation_id, {"name": name}] + output = self._call_rpc("messaging", "setInstallationMetadata", params) + error = (output.get("error") or {}).get("message", "") + # The device is already paired at this point, so a failed rename is not worth failing the sync over + if error: + self.logger.warning(f"Synced with installation '{installation_id}' but could not name it - {error}") + + def unsync(self, installation_id: str): + """ + Stop syncing with a device (installation) that was paired with `sync`. + The device can be paired again with `sync`. + + Parameters: + - `installation_id` - the id of the device to stop syncing with. The account's own id is under `installation_id` in `info` + """ + if installation_id == self.info["installation_id"]: + return + + output = self._call_rpc("messaging", "disableInstallation", [installation_id]) + error = (output.get("error") or {}).get("message", "") + if error: + raise exceptions.DeviceSyncError(f"Could not unsync from installation '{installation_id}' - {error}") + def __start_messenger(self): """ Start the decentralized messaging service. diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py index 4db082e..ed2140d 100644 --- a/status_sdk/exceptions.py +++ b/status_sdk/exceptions.py @@ -92,6 +92,9 @@ class InvalidTokenError(Exception): class BackupError(Exception): pass +class DeviceSyncError(Exception): + pass + class ProfilePictureError(Exception): pass