diff --git a/README.md b/README.md index f43b7f7..8e78a63 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ The compose file reads three variables from the environment. All of them have a | Variable | Default | Description | |-----|-----|-------------| | `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. | -| `STATUS_GO_PLATFORM` | `linux/amd64` | The platform the image is built for. | +| `PLATFORM` | `linux/amd64` | The platform the image is built for. | ``` STATUS_GO_COMMIT=2bee8b6a38cdc8f92d74e2dbb8c4e77fbbeea149 PLATFORM=linux/amd64 docker compose -f status_sdk/docker-compose.yaml up -d diff --git a/docs/account.md b/docs/account.md index f529188..d7aa203 100644 --- a/docs/account.md +++ b/docs/account.md @@ -515,6 +515,69 @@ account.send_image( ) ``` +#### `send_bridged_message(chat_id, message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into a Status chat. Status App renders it as a **bridged message** - the original author's name and avatar are shown, with the platform it came from, rather than the message appearing to come from the bot account itself. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `chat_id` | `str` | Yes | Identifier of the chat where the message will be sent. All available chat IDs come from the [`chats`](./account.md#chats) property. | +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id. Unlike in [`send_message`](./account.md#send_messagechat_id-message-reply_to_message_idnone) and [`send_image`](./account.md#send_imagechat_id-file_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. Passing a Status id will not thread. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, which is a different value from the `message_id` you passed in. It can be used with [`delete_message`](./account.md#delete_messageid) like any other sent message. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +chat = account.chats[0] + +status_id = account.send_bridged_message( + chat_id=chat["id"], + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + +Threading a reply that happened on the other platform: + +```python +account.send_bridged_message( + chat_id=chat["id"], + message="Yes, works on my machine", + name="Discord", + username="alex", + user_id="481920374651829301", + message_id="1180937812345678901", + # The Discord id of the message being replied to, relayed earlier + reply_to_message_id="1180937465829183498" +) +``` + +**Note**: `user_id` and `message_id` both fall back to a **freshly generated UUID** when omitted. + +1. Without `user_id`, every relayed message looks like it came from a different author even when the same person sent them all; +2. without `message_id`, nothing can ever reply to that message, because the id it would need to reference is discarded. A real bridge should pass both. + +The same method is available on [`GroupChat`](./group-chat.md#send_bridged_messagemessage-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) and on a community [`Channel`](./community.md#send_bridged_messagemessage-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) without the `chat_id` argument, since those already know their own chat. + #### `send_emoji_reaction(message_id, emoji_shortname, chat_id=None)` React to a message with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. @@ -661,12 +724,13 @@ for msg in account.listen_messages(): #### `listen_contact_requests()` -Listen for contact requests **in real time**. Both **incoming** contact requests sent to the account and contact requests sent by the account that were **accepted** by the other user are yielded. Every yielded event carries a `request_type` key that tells the two apart: +Listen for contact requests **in real time**. . Every yielded event carries a `request_type` key that tells them apart: | `request_type` | Meaning | |-----|-----| -| `incoming` | Another user sent a contact request to the account. Approve it with [`add_contact`](./account.md#add_contactpublic_key-display_namenone). | +| `incoming` | Another user sent a contact request to the account. Approve it with [`add_contact`](./account.md#add_contactpublic_key-display_namenone). Use `id` and `public_key` properties from `ContactRequest` | | `accepted` | Another user accepted a contact request that the account had sent. The contact is now mutual. | +| `removed` | When a user has removed the account from their contacts. | ```python from status_sdk import Account @@ -695,10 +759,12 @@ params = { account.login(**params) for request in account.listen_contact_requests(): - if request["request_type"] == "incoming": + if request.incoming: print("New contact request received") - elif request["request_type"] == "accepted": + elif request.accepted: print("Contact request was accepted") + elif request.removed: + print(f"{request.public_key} has removed you") ``` #### `listen_message_mentions()` @@ -719,20 +785,14 @@ for mention in account.listen_message_mentions(): print(mention) ``` -#### `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: +#### `add_contact(public_key, request_id=None, display_name=None)` -- `has_added_us` - `bool` value to check if the other user has added the account as a friend -- `added` - `bool` value to check if the account has added the other user as a friend -- `mutual` - `bool` value to check if the account and other user are in contacts -- `contact_state` - `str` value to see the account's current state -- `external_contact_state` - `str` value to see the other user's state as it is in your node +Send a contact request or approve an existing contact request. Modes: -- **Approve mode** - `has_added_us` is `True` and `added` is `False` -- **Add mode** - `has_added_us` is `False` +- **Approve mode** - use `public_key` and `request_id` +- **Add mode** - `public_key` and `display_name`. Display name can be omitted if the account shows up in [`contacts`](./account.md#contacts) The contact can be identified in three different ways, so you can pass whichever value you have at hand - the public key, the chat key as shown in Status App, or the profile link a user shares with you: @@ -747,6 +807,7 @@ When an account URL is passed, the public key is resolved from it automatically | Name | Type | Required | Description | |-----|-----|-----|-------------| | `public_key` | `str` | Yes | The contact's Status **public key** (`0x...`), **chat key** (`zQ...`) or **account URL** (`https://...`). | +| `request_id` | `str` | No | The `id` of an incoming contact request, from the `ContactRequest` yielded by [`listen_contact_requests`](./account.md#listen_contact_requests). Passing it switches the method into **approve mode** - the pending request is accepted instead of a new one being sent, and `display_name` is not used at all. Omit it to send a **new** contact request. | | `display_name` | `str` | Yes / No | Display name for the contact. If the contact already exists in [`contacts`](./account.md#contacts), the `display_name` parameter is optional and the existing name will be reused. If the contact has **never interacted with the bot before**, `display_name` must be provided so the contact can be created locally. | Returns the current `Account` instance, allowing method chaining. @@ -1908,6 +1969,33 @@ for chat in account.chats: print(f"{chat['type']}\t{chat['name']}\t{chat['id']}") ``` +#### `message_length` + +The maximum number of characters a single message may contain, as enforced by Status App. + +Returns `int`. + +```python +from status_sdk import Account + +account = Account() +params = { + "name": "status-app-bot", + "password": "SNTPUMP" +} +account.login(**params) + +print(account.message_length) + +# This is under the assumption you already have a contact / joined a community +chat = account.chats[0] +report = "A very long report..." + +# Split a long text into messages the app accepts +for index in range(0, len(report), account.message_length): + account.send_message(chat["id"], report[index:index + account.message_length]) +``` + ### Wallet #### `chains` diff --git a/docs/community.md b/docs/community.md index 68b30de..39858d8 100644 --- a/docs/community.md +++ b/docs/community.md @@ -946,7 +946,8 @@ Returns `list[dict]`, one entry per channel. |----|----|-------------| | `id` | `str` | The channel id (within the community). | | `name` | `str` | The channel name. Use this with [subscript access](./community.md#fetching-a-channel) and [`delete_channel`](./community.md#delete_channelchannel_name). | -| `category` | `str`
`None` | The id of the category the channel belongs to, or `None` if uncategorised. | +| `category_id` | `str`
`None` | The id of the category the channel belongs to, or `None` if uncategorised. | +| `category_name` | `str`
`None` | The name of the category the channel belongs to, or `None` if uncategorised. | ```python from status_sdk import Account, Community @@ -1178,6 +1179,42 @@ A valid channel emoji must satisfy all of the following: | `🇬🇧` | Flag (multi-codepoint) | | `👨‍👩‍👧` | ZWJ sequence | +## Channel permissions + +A **permission** is a rule that decides who may do something in the community. Every permission has a **scope** - what it grants - and optional **token criteria** - what a member must hold to be granted it. A permission with no token criteria applies to everyone; a permission with token criteria is what makes a channel [token gated](./community.md#is_token_gated). + +Permissions are [added](./community.md#add_permissionpermission-tokensnone), [listed](./community.md#permissions-1) and [deleted](./community.md#delete_permissionid) through the channel. + +### Permission scopes + +The `permission` argument is a **case-insensitive** string. Any other value raises a custom exception listing the valid ones. + +| Scope | Description | +|-----|-----|-------------| +| `view` | Members who meet the criteria can **read** the channel. | +| `view_post` | Members who meet the criteria can **read and post** in the channel. | +| `admin` | Members who meet the criteria become **administrators**. | +| `member` | Members who meet the criteria can **join** the community. | +| `token_master` | Members who meet the criteria become **token masters**. | +| `token_owner` | Members who meet the criteria become **token owners**. | + +`view` and `view_post` are the channel-level scopes and are the ones you normally want here. The remaining four are **community-wide** roles - they are accepted by the SDK, but the backend decides whether it will attach them to a single channel, and a rejection is raised as a custom exception. + +### Token criteria + +Token criteria are described with the `models.TokenPermission` **dataclass**: + +| Attribute | Type | Required | Description | +|----|----|----|-------------| +| `symbol` | `str` | Yes | The token symbol, as it appears in [`get_tokens`](./account.md#get_tokens) on `Account`. | +| `amount` | `float` | Yes | The minimum amount a member must hold. Converted to wei using the token's own `decimals`, so it is written in human units - `10` means 10 tokens, not 10 wei. | +| `chain_id` | `int` | No | The chain the token lives on. Defaults to `1` (Ethereum mainnet). | +| `address` | `str` | No | The token's contract address. Needed only when the same `symbol` exists more than once on `chain_id`. | + +Passing several `TokenPermission` objects requires a member to satisfy **all** of them. + +**Note**: Currently only **ERC-20** tokens are supported. Collectibles (ERC-721) and ENS criteria are configured in Status App. + ## Methods ### `send_message(message, reply_to_message_id=None)` @@ -1239,6 +1276,48 @@ message_id = channel.send_image("./meme-67.png", "Daily random meme") print(f"Sent image: {message_id}") ``` +### `send_bridged_message(message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into the channel. Status App renders it as a **bridged message**: the original author's name and avatar are shown, along with the platform it came from, instead of the message appearing to come from the bot account. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id, unlike in [`send_message`](./community.md#send_messagemessage-reply_to_message_idnone) and [`send_image`](./community.md#send_imagefile_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, delegated from [`send_bridged_message`](./account.md#send_bridged_messagechat_id-message-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) on `Account`. This is a different value from the `message_id` you passed in, and it can be used with [`delete_message`](./community.md#delete_messageid) like any other sent message. Returns `None` when [`can_post`](./community.md#can_post) is `False` - nothing is relayed and no error is raised. + +```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"] + +status_id = channel.send_bridged_message( + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + ### `send_emoji_reaction(message_id, emoji_shortname)` React to a message in the channel with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. @@ -1339,6 +1418,66 @@ deleted = channel.delete_message(messages[0]["id"]) print(f"Deleted: {deleted}") ``` +### `add_permission(permission, tokens=None)` + +Add a permission to the channel. See [Channel permissions](./community.md#channel-permissions) for what the scopes mean and how token criteria are described. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `permission` | `str` | Yes | The [scope](./community.md#permission-scopes) to grant - `view`, `view_post`, `admin`, `member`, `token_master` or `token_owner`. Case-insensitive. | +| `tokens` | `models.TokenPermission`
`list[models.TokenPermission]` | No | The [token criteria](./community.md#token-criteria) a member must meet. A single object or a list of them. When omitted, the permission applies to **every** member. | + +```python +from status_sdk import Account, Community, models + +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"] + +# Open to every member - anyone can read and post +channel.add_permission("view") + +# Token gated - only members holding at least 10 SNT on mainnet can read +channel.add_permission("view", models.TokenPermission(symbol="SNT", amount=10)) +``` + +### `delete_permission(id)` + +Delete a permission from the channel by its id. Current permissions and their ids come from the [`permissions` property](./community.md#permissions-1). + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `id` | `str` | Yes | The permission's `id`, from the [`permissions` property](./community.md#permissions-1). | + + +```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"] + +# Remove every token gate on the channel, leaving it open to all members +for permission_id in channel.permissions["id"].unique(): + channel.delete_permission(permission_id) +``` + ## Properties ### `id` @@ -1498,6 +1637,62 @@ print(channel.emoji) ![Community Edit Emoji](./images/community/edit-channel-emoji.png) +### `category` + +Get the channel's category, or move the channel to another one. It can also be set by a **category name**, as described in [`categories`](./community.md#categories), and the channel is placed at the bottom of that category. Unknown category names are ignored and the channel is moved out of its current category, next to the other uncategorised channels. + +Returns `str` when reading, or `None` if the channel is not in a category. + +```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"] + +# Read +print(channel.category) + +# Update +channel.category = "Development" +``` + +### `position` + +Get or update the channel's position inside its own [`category`](./community.md#category). Positions start at `0` at the top of the category, so assigning `0` moves the channel to the very top. Out of range values are clamped - anything below `0` becomes `0`, and anything past the last channel moves it to the bottom of the category. + +Returns `int` when reading. + +```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"] + +# Read +print(channel.position) + +# Update - move the channel to the top of its category +channel.position = 0 +``` + ### Permissions Four properties describe what the logged-in account may do in the channel - [`can_post`](./community.md#can_post), [`can_view`](./community.md#can_view), [`can_react`](./community.md#can_react) and [`is_token_gated`](./community.md#is_token_gated). They are the per-channel counterpart of the `permissions` keys exposed by [`communities`](./account.md#channels) on `Account`, and every one of them returns a `bool`. @@ -1509,6 +1704,8 @@ Four properties describe what the logged-in account may do in the channel - [`ca | [`can_react`](./community.md#can_react) | `reactions` | The account can post emoji reactions. | | [`is_token_gated`](./community.md#is_token_gated) | `token_gated` | Access to the channel is gated behind a token. | +A fifth property, [`permissions`](./community.md#permissions-1), works the other way round - instead of what **this account** may do, it lists the [permission rules](./community.md#channel-permissions) configured on the channel. + #### `can_post` Whether the logged-in account is allowed to post in the channel. @@ -1610,4 +1807,43 @@ if channel.is_token_gated and not channel.can_post: print("The account does not hold the token this channel requires") ``` -**Note**: token gating is configured in Status App. The SDK reports it, and always [creates channels](./community.md#create_channelname-description-emojinone-colournone-category_namenone) that are open to every member. +Channels are always [created](./community.md#create_channelname-description-emojinone-colournone-category_namenone) open to every member. A channel becomes token gated once a permission carrying [token criteria](./community.md#token-criteria) is [added](./community.md#add_permissionpermission-tokensnone) to it, and stops being gated when that permission is [deleted](./community.md#delete_permissionid). + +#### `permissions` + +The permissions currently configured **on this channel**. Unlike the four properties above, this does not describe what the logged-in account may do - it lists the rules themselves, and is where [`delete_permission`](./community.md#delete_permissionid) gets its ids. + +Returns a `pd.DataFrame` with one row per **chat id per token criterion**, so a permission covering several channels or requiring several tokens spans several rows. An **empty** `DataFrame` is returned when the community has no permissions at all. + +| Column | Type | Description | +|-------|------|-------------| +| `id` | `str` | The permission's id. Pass this to [`delete_permission`](./community.md#delete_permissionid). | +| `type` | `str` | The [scope](./community.md#permission-scopes), mapped back to its name - `view`, `view_post`, `admin`, `member`, `token_master` or `token_owner`. | +| `chat_id` | `str` | The channel the permission applies to. | +| `is_private` | `bool` | Whether the permission is hidden from members who do not meet it. | +| `symbol` | `str` | Symbol of the required token. | +| `amount_in_wei` | `str` | The required amount, in **wei**. Divide by `10 ** decimals` for human units. | +| `decimals` | `int` | The token's decimals. | +| `chain_id` | `int` | The chain the required token lives on. | +| `contract_address` | `str` | The required token's contract address. | +| `token_type` | `int` | The criterion type - `1` for ERC-20. | + +```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"] + +permissions = channel.permissions +if len(permissions) > 0: + print(permissions[["id", "type", "symbol", "amount_in_wei", "chain_id"]]) +``` diff --git a/docs/group-chat.md b/docs/group-chat.md index 58e8b76..ec70f4e 100644 --- a/docs/group-chat.md +++ b/docs/group-chat.md @@ -252,6 +252,46 @@ message_id = group_chat.send_image("./meme-67.png", "Daily random meme") print(f"Sent image: {message_id}") ``` +### `send_bridged_message(message, name=None, username=None, user_id=None, message_id=None, reply_to_message_id=None, image_url=None)` + +Relay a message that came from **another messaging platform** - Discord, Telegram, Slack, IRC - into the group chat. Status App renders it as a **bridged message**: the original author's name and avatar are shown, along with the platform it came from, instead of the message appearing to come from the bot account. Use [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone) when the bot is speaking as itself. + +| Name | Type | Required | Description | +|-----|-----|-----|-------------| +| `message` | `str` | Yes | The text of the original message. | +| `name` | `str` | No | The platform the message came from, shown as the bridge label in Status App - for example `Discord`. Defaults to `Unknown`. | +| `username` | `str` | No | The author's username **on the other platform**, shown as the sender. Defaults to `Anon`. | +| `user_id` | `str` | No | The author's id on the other platform. Status uses it to tell one bridged author from another, so **pass the real id** - see the note below. | +| `message_id` | `str` | No | The original message's id on the other platform. Pass it if you want later messages to be able to reply to this one. | +| `reply_to_message_id` | `str` | No | The **other platform's** id of the message being replied to - *not* a Status message id, unlike in [`send_message`](./group-chat.md#send_messagemessage-reply_to_message_idnone) and [`send_image`](./group-chat.md#send_imagefile_path-messagenone-reply_to_message_idnone). It threads correctly only when the message it points at was itself relayed with that same value as its `message_id`. | +| `image_url` | `str` | No | URL of the author's avatar on the other platform. Defaults to no avatar. | + +Returns `str` - the `id` of the message **in Status App**, delegated from [`send_bridged_message`](./account.md#send_bridged_messagechat_id-message-namenone-usernamenone-user_idnone-message_idnone-reply_to_message_idnone-image_urlnone) on `Account`. This is a different value from the `message_id` you passed in, and it can be used with [`delete_message`](./group-chat.md#delete_messageid) like any other sent 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"]) + +status_id = group_chat.send_bridged_message( + message="Has anyone tried the new build?", + name="Discord", + username="thedatabro", + user_id="356712449896382465", + message_id="1180937465829183498", + image_url="https://cdn.discordapp.com/avatars/356712449896382465/a1b2c3.png" +) +print(f"Relayed into Status as {status_id}") +``` + ### `send_emoji_reaction(message_id, emoji_shortname)` React to a message in the group chat with an emoji, the same as reacting to a message in Status App. The reaction is a **toggle** - calling the method again with the same emoji on the same message removes it, so the same call both sets and unsets the reaction. diff --git a/examples/community-greet/main.py b/examples/community-greet/main.py index 758a038..8038136 100644 --- a/examples/community-greet/main.py +++ b/examples/community-greet/main.py @@ -113,15 +113,15 @@ def main(channel_name: str, approve: bool): for request in community.listen_requests(): member_public_key: str = request.public_key - if request.state == "pending" and member_public_key not in pending_requests: + if request.pending and member_public_key not in pending_requests: pending_requests.append(member_public_key) - if approve and request.state == "pending": + if approve and request.pending: community.accept(request.id) account.logger.info(f"Accepted {member_public_key}") continue - if request.state != "accept" or member_public_key not in pending_requests: + if not request.accept or member_public_key not in pending_requests: continue message = generate_message(member_public_key) diff --git a/pyproject.toml b/pyproject.toml index 4228477..4f530d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "status-sdk" -version = "1.1.3" +version = "1.1.4" description = "Private chat. Communities. Multi-chain wallet. Browser. dApps all in one app, powered by SNT." readme = "README.md" requires-python = ">=3.11" @@ -54,7 +54,7 @@ Issues = "https://github.com/status-im/status-python-sdk/issues" "Status Backend" = "https://github.com/status-im/status-go" [tool.setuptools] -packages = ["status_sdk", "status_sdk.community"] +packages = ["status_sdk", "status_sdk.community", "status_sdk.utils"] [tool.setuptools.package-data] status_sdk = ["docker-compose.yaml"] diff --git a/status_sdk/__init__.py b/status_sdk/__init__.py index f3c258f..fecd7b3 100644 --- a/status_sdk/__init__.py +++ b/status_sdk/__init__.py @@ -2,7 +2,7 @@ from .account import Account from .group_chat import GroupChat -from .community.base import Community +from .community.base import Community, Channel from .utils import launch_docker_container from . import exceptions, models diff --git a/status_sdk/account.py b/status_sdk/account.py index 604518a..f4cffa8 100644 --- a/status_sdk/account.py +++ b/status_sdk/account.py @@ -10,10 +10,8 @@ from PIL.PngImagePlugin import PngImageFile from . import constants, models from .signal import Signal -from .logger import Logger class Account: - # Enum mappings from original wakuext.py __mappings = { "contact_request": { @@ -76,8 +74,7 @@ def __init__(self, domain: str = "localhost", backend_port: int = 8080, media_po # NOTE: This might change for initial release self.__assets_local_folder = os.path.join(sdk_folder, "assets") os.makedirs(self.__assets_local_folder, exist_ok=True) - - self.__logger = Logger() + self.__logger = logging.getLogger(__name__) self.__timestamp_divisor = 1_000 self.__kd_iterations = 256000 self.__is_messenger_launched = False @@ -594,6 +591,10 @@ def balance(self) -> pd.DataFrame: balance.insert(0, "timestamp", datetime.datetime.now()) return balance.copy() + @property + def message_length(self) -> int: + return 2_000 + @property def status(self) -> str: """ @@ -674,6 +675,33 @@ def send_message(self, chat_id: str, message: str, reply_to_message_id: Optional """ return self.__send_content(chat_id, message, reply_to_message_id) + def send_bridged_message(self, chat_id: str, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> str: + """ + Forward a message from another messaging platform to the given chat. + + Parameters: + - `chat_id` - the chat ID can be found in `self.chats` + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + content = models.BridgedContent( + message=message, + name=name, + username=username, + user_id=user_id, + reply_to_message_id=reply_to_message_id, + message_id=message_id, + image_url=image_url + ) + return self.__send_content(chat_id, bridged_content=content) def send_emoji_reaction(self, message_id: str, emoji_shortname: str, chat_id: Optional[str] = None): """ @@ -708,7 +736,7 @@ def send_emoji_reaction(self, message_id: str, emoji_shortname: str, chat_id: Op if error: raise exceptions.ChatNotFoundError(error.get("message")) - def __send_content(self, chat_id: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_path: Optional[str] = None) -> str: + def __send_content(self, chat_id: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_path: Optional[str] = None, bridged_content: Optional[models.BridgedContent] = None) -> str: """ Send a message with optional media attached to the given chat. @@ -738,7 +766,7 @@ def validate_path(path: str): if not message: message = "" - if len(message) > 2_000: + if len(message) > self.message_length: raise exceptions.MessageTooLongError(f"Message cannot be longer than 2000 characters (got {len(message)})...") msg_params = { @@ -763,6 +791,11 @@ def validate_path(path: str): content_key = "imagePath" asset_subfolder = "images" + if bridged_content: + msg_params["text"] = "" + msg_params["contentType"] = 18 + msg_params["bridgeMessage"] = bridged_content.status_go_params + if asset_subfolder: docker_file_path.append(asset_subfolder) @@ -793,7 +826,11 @@ def validate_path(path: str): if error: raise exceptions.SendContentError(error.get("message")) - return response["result"]["messages"][0]["id"] + messages = response["result"]["messages"] + if reply_to_message_id and len(messages) > 1: + messages = [m for m in messages if m["id"] != reply_to_message_id] or messages + + return max(messages, key=lambda m: m.get("clock", 0))["id"] def delete_message(self, id: str) -> bool: """ @@ -832,22 +869,36 @@ def listen_contact_requests(self) -> Generator[models.ContactRequest, None, None """ Listen for incoming contact requests and for contact requests that were accepted. Can be used for real time processing. """ - accepted_contact_request = re.compile(r"@(0x04[0-9a-fA-F]{128}) accepted your contact request") + key_mapping = { + 16: "accepted", + 17: "removed" + } for message in self.signal.listen(["local-notifications", "messages.new"]): event: dict = message.get("event", {}) if message["type"] == "local-notifications": category = event.get("category") if category == "contactRequest": - yield models.ContactRequest(message["event"]["body"]["message"]["from"], incoming=True) - - if message["type"] == "messages.new" and accepted_contact_request.search(str(message)): - for public_key in set(accepted_contact_request.findall(str(message))): - - if self.info["public_key"] == public_key: + yield models.ContactRequest( + message["event"]["body"]["message"]["id"], + message["event"]["body"]["message"]["from"], + incoming=True + ) + + if message["type"] == "messages.new" and "messages" in message["event"]: + messages: list[dict] = message["event"]["messages"] + for message in messages: + public_key: str = message["from"] + + if self.info["public_key"] == public_key or message["contentType"] not in key_mapping: continue - yield models.ContactRequest(public_key, accepted=True) + params = { + "id": message["id"], + "public_key": public_key, + key_mapping[message["contentType"]]: True + } + yield models.ContactRequest(**params) def listen_message_mentions(self) -> Generator[models.Message, None, None]: """ @@ -870,10 +921,20 @@ def listen_messages(self) -> Generator[models.Message, None, None]: """ Listen for new **RAW** messages continuously. Can be used for real time processing. """ + ALLOWED_CONTENT_TYPES = [ + 1, # Text + 2, # Sticker + 4, # Emojis + 7, # Image + 18, # Bridged Message + ] + for message in self.signal.listen("messages.new"): event: dict = message.get("event", {}) if "chats" in event or "messages" in event: for raw in event["messages"]: + if raw["contentType"] not in ALLOWED_CONTENT_TYPES: + continue yield models.Message.from_raw(raw) def get_messages(self, chat_id: str, start_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None, end_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None) -> list[dict]: @@ -940,12 +1001,13 @@ def get_messages(self, chat_id: str, start_timestamp: Optional[Union[str, dateti return all_messages - def add_contact(self, public_key: str, display_name: Optional[str] = None): + def add_contact(self, public_key: str, request_id: Optional[str] = None, display_name: Optional[str] = None): """ Send a contact request / approve a contact. Parameters: - `public_key` - the contact's public key / chat key / URL + - `request_id` - the `id` from `ContactRequest` when using `listen_contact_request`. If not provided the user will be sent a friend request. If provided the user's request will be accepted. - `display_name` - this field is required if the `public_key` does not appear in your contacts. This will set their display name (can be different from the one the other user has chosen) """ public_key = self.get_public_key(public_key) @@ -953,6 +1015,14 @@ def add_contact(self, public_key: str, display_name: Optional[str] = None): if public_key == self.info["public_key"]: return self + if request_id: + params = [{"id": request_id, "contactID": public_key}] + response = self._call_rpc("messaging", "acceptContactRequest", params) + if response.get("error"): + raise exceptions.InvalidContactError(response["error"]["message"]) + + return self + if display_name: self.__validate_display_name(display_name) diff --git a/status_sdk/community/base.py b/status_sdk/community/base.py index 7547fd0..0763320 100644 --- a/status_sdk/community/base.py +++ b/status_sdk/community/base.py @@ -2,6 +2,7 @@ from .. import exceptions, models from .channel import Channel from typing import Union, Optional, Generator +from ..utils import community as utils import pandas as pd import datetime, copy, os, shutil @@ -48,7 +49,7 @@ def __init__(self, account: Account, community_id: Optional[str] = None, url: Op raise exceptions.CommunityNotFoundError(error["message"]) self.__id = response["result"]["community"]["communityId"] - result: dict = self.__get_community_info() + result: dict = utils.get_community_info(self.__account, self.id) # Account is a member -> actions can be used if result["joined"]: return @@ -170,7 +171,7 @@ def get_collectibles(self) -> pd.DataFrame: Output: - DataFrame - row per `owner` per contract. """ - result: dict = self.__get_community_info() + result: dict = utils.get_community_info(self.__account, self.id) info = [ { "symbol": nft_info["symbol"], @@ -300,7 +301,12 @@ def listen_requests(self) -> Generator[models.CommunityRequest, None, None]: if not state: continue - yield models.CommunityRequest(request["id"], state, request["publicKey"]) + params = { + "id": request["id"], + "public_key": request["publicKey"], + state: True + } + yield models.CommunityRequest(**params) @property def categories(self) -> dict[str, str]: @@ -313,7 +319,7 @@ def categories(self) -> dict[str, str]: "id": community_id, "position": info["position"] } - for community_id, info in self.__get_community_info().get("categories", {}).items() + for community_id, info in utils.get_community_info(self.__account, self.id).get("categories", {}).items() } return mapping @@ -322,7 +328,7 @@ def role(self) -> str: """ The account's community role """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return self.__role_mapping[result["memberRole"]] @property @@ -330,7 +336,7 @@ def name(self) -> str: """ The community's name """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["name"] @property @@ -338,7 +344,7 @@ def description(self) -> str: """ The community's description """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["description"] @property @@ -346,7 +352,7 @@ def is_member(self) -> str: """ If the account is a member of the community """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["isMember"] @property @@ -354,7 +360,7 @@ def is_encrypted(self) -> str: """ If the account is a member of the community """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["encrypted"] @property @@ -362,7 +368,7 @@ def tags(self) -> str: """ The community's tags """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["tags"] @property @@ -370,7 +376,7 @@ def has_joined(self) -> str: """ The community's tags """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["joined"] @property @@ -392,7 +398,7 @@ def introduction(self) -> str: """ The community's introduction message when new users join """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["introMessage"] @property @@ -400,7 +406,7 @@ def leave_message(self) -> str: """ The community's leave message when a member leaves. """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return result["outroMessage"] def get_members(self, dataframe: bool = False) -> Union[dict[str, dict], pd.DataFrame]: @@ -414,7 +420,7 @@ def get_members(self, dataframe: bool = False) -> Union[dict[str, dict], pd.Data Output: - `dict` or `DataFrame` of the current community members """ - raw_data: dict[str, dict] = self.__get_community_info().get("members", {}) + raw_data: dict[str, dict] = utils.get_community_info(self.__account, self.id).get("members", {}) if not dataframe: return raw_data @@ -454,12 +460,17 @@ def channels(self) -> list[dict]: """ High level information for all community channels """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) + category_mapping = { + category_id: info["name"] + for category_id, info in result.get("categories", {}).items() + } available_chats = [ { "id": current["id"], "name": current["name"], - "category": current["categoryID"] if len(current["categoryID"]) > 0 else None + "category_id": current["categoryID"] if len(current["categoryID"]) > 0 else None, + "category_name": category_mapping.get(current["categoryID"]) } for current in result["chats"].values() ] @@ -470,7 +481,7 @@ def banned_members(self) -> list[str]: """ Currently banned public keys """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) banned_states = [0, 4] # Banned, BanWithAllmessagesDeleted public_keys = [ public_key @@ -540,7 +551,7 @@ def __getitem__(self, channel_name: str) -> Channel: Fetch a community chat by its name using subscript access, e.g. `community[channel_name]`. Available chat names can be found in the `chats` property. """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) category_mapping = { category_id: info["name"] for category_id, info in result.get("categories", {}).items() @@ -567,28 +578,6 @@ def __len__(self) -> int: """ return len(self.get_members()) - def __get_community_info(self) -> dict: - """ - Get up to date information for the community - - Output: - - up to date community data - """ - params = { - "communityKey": self.id, - "waitForResponse": True, - "tryDatabase": True - } - response = self.__account._call_rpc("messaging", "fetchCommunity", [params]) - error: dict = response.get("error", {}) - if error: - raise exceptions.InvalidCommunityKeyError(error["message"]) - - if not response["result"]: - raise exceptions.CommunityNotFoundError(f"Community '{self.id}' was not found...") - - return response["result"] - def __normalise_public_keys(self, public_keys: Union[str, list[str]]) -> list[str]: """ Verify if the given public keys exist in the community @@ -628,7 +617,7 @@ def __to_datetime(self, key: str) -> Optional[datetime.datetime]: Output: - the `datetime.datetime` of the `key` """ - result = self.__get_community_info() + result = utils.get_community_info(self.__account, self.id) return datetime.datetime.fromtimestamp(result[key]) if result[key] != 0 else None def __verify_admin(self): diff --git a/status_sdk/community/channel.py b/status_sdk/community/channel.py index b46ed6b..0d7e573 100644 --- a/status_sdk/community/channel.py +++ b/status_sdk/community/channel.py @@ -1,17 +1,20 @@ from ..account import Account -from .. import exceptions +from .. import exceptions, models +from ..utils import community as utils from typing import Union, Optional import pandas as pd -import re, datetime, random, unicodedata +import re, datetime, random class Channel: - __perimission_mapping = { - 0: "unknown", - 1: "auto_accept", - 2: "manual_accept" + __permission_mapping = { + 1: "admin", + 2: "member", + 3: "view", + 4: "view_post", + 5: "token_master", + 6: "token_owner" } - __STATUS_COLOURS = [ "#FF7D46", # Orange "#F6B03C", # Yellow @@ -99,6 +102,13 @@ def id(self) -> str: raise exceptions.CommunityChannelNotFoundError() return self.__id + @property + def permissions(self) -> pd.DataFrame: + data = utils.get_channel_permissions(self.__account, self.__community_id, self.id) + if len(data) > 0: + data["type"] = data["type"].map(self.__permission_mapping) + return data + @property def url(self) -> str: """ @@ -135,6 +145,65 @@ def can_post(self) -> bool: """ return self.__get_channel_info()["canPost"] + @property + def category(self) -> Optional[str]: + """ + The category's name + """ + return self.__get_channel_info()["categoryName"] + + @category.setter + def category(self, value: str): + community_info = utils.get_community_info(self.__account, self.__community_id) + category_mapping = { + info["name"]: category_id + for category_id, info in community_info.get("categories", {}).items() + } + + new_category = category_mapping.get(value) + + position_mapping = self.__get_category_positions() + params = [{ + "communityId": self.__community_id, + "categoryId": '' if not new_category else new_category, + "chatId": self.id, + "position": position_mapping.get(new_category, 0) + 1 + }] + self.__account._call_rpc("messaging", "reorderCommunityChat", params) + + @property + def position(self) -> Optional[str]: + """ + The position of the chat in the current `category` + """ + return self.__get_channel_info()["position"] + + @position.setter + def position(self, value: int): + if not isinstance(value, int): + raise exceptions.InvalidCommunityChannelPositionError("Community channel position must be an integer.") + + category_id = self.__get_channel_info()["categoryID"] + if len(category_id) == 0: + category_id = None + + position_mapping = self.__get_category_positions() + largest_position = position_mapping[category_id] + new_largest_position = largest_position + 1 + if value < 0: + value = 0 + + if value > new_largest_position: + value = new_largest_position + + params = [{ + "communityId": self.__community_id, + "categoryId": '' if not category_id else category_id, + "chatId": self.id, + "position": value + }] + self.__account._call_rpc("messaging", "reorderCommunityChat", params) + @property def description(self) -> str: """ @@ -187,7 +256,7 @@ def emoji(self, value: str): self.__validate_emoji(value) self.__edit_channel(emoji=value) - def send_message(self, message: str, reply_to_message_id: Optional[str] = None): + def send_message(self, message: str, reply_to_message_id: Optional[str] = None) -> Optional[str]: """ Send a message to the Community chat. @@ -198,9 +267,9 @@ def send_message(self, message: str, reply_to_message_id: Optional[str] = None): Output: - The message ID """ - return self.__account.send_message(self.id, message, reply_to_message_id) + return self.__account.send_message(self.id, message, reply_to_message_id) if self.can_post else None - def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> str: + def send_image(self, file_path: str, message: Optional[str] = None, reply_to_message_id: Optional[str] = None) -> Optional[str]: """ Send a image to the group chat. @@ -212,7 +281,25 @@ def send_image(self, file_path: str, message: Optional[str] = None, reply_to_mes Output: - The message ID """ - return self.__account.send_image(self.id, file_path, message, reply_to_message_id) + return self.__account.send_image(self.id, file_path, message, reply_to_message_id) if self.can_post else None + + def send_bridged_message(self, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> Optional[str]: + """ + Forward a message from another messaging platform to the Community chat. + + Parameters: + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + return self.__account.send_bridged_message(self.id, message, name, username, user_id, message_id, reply_to_message_id, image_url) if self.can_post else None def send_emoji_reaction(self, message_id: str, emoji_shortname: str): """ @@ -222,6 +309,8 @@ def send_emoji_reaction(self, message_id: str, emoji_shortname: str): - `message_id` - the `id` of the message, as it appears in `self.get_messages()` - `emoji_shortname` - the emoji shortname as in Status App, with or without the surrounding colons """ + if not self.can_react: + return self.__account.send_emoji_reaction(message_id, emoji_shortname, self.id) def get_messages(self, start_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None, end_timestamp: Optional[Union[str, datetime.datetime, datetime.date, pd.Timestamp]] = None) -> list[dict]: @@ -237,7 +326,7 @@ def get_messages(self, start_timestamp: Optional[Union[str, datetime.datetime, d Output: - All messages within the given range """ - return self.__account.get_messages(self.id, start_timestamp, end_timestamp) + return self.__account.get_messages(self.id, start_timestamp, end_timestamp) if self.can_view else [] def delete_message(self, id: str) -> bool: """ @@ -253,6 +342,70 @@ def delete_message(self, id: str) -> bool: self.name return self.__account.delete_message(id) + def add_permission(self, permission: str, tokens: Optional[Union[list[models.TokenPermission], models.TokenPermission]] = None): + """ + Add a new permission for the channel + + Parameters: + - `permission` - the scope of the permission + + """ + if not isinstance(permission, str): + raise exceptions.InvalidCommunityChannelPermissionError("Community channel permission must be a string.") + + reversed_mapping = {name: number for number, name in self.__permission_mapping.items()} + permission = permission.lower() + if permission not in reversed_mapping: + raise exceptions.InvalidCommunityChannelPermissionError(f"'{permission}' is not a valid community channel permission. It must be one of: {', '.join(reversed_mapping)}.") + + token_criteria = [] + if tokens: + if isinstance(tokens, models.TokenPermission): + tokens = [tokens] + + available_tokens = self.__account.get_tokens() + for token_permission in tokens: + query = (available_tokens["symbol"] == token_permission.symbol) & (available_tokens["chain_id"] == token_permission.chain_id) + selected_tokens = available_tokens.loc[query, ["address", "decimals"]].drop_duplicates().reset_index(drop=True).copy() + if len(selected_tokens) > 1 and not token_permission.address: + continue + + token_info = selected_tokens.to_dict("records")[0] + token_criteria.append({ + "type": 1, # ERC20 + "contract_addresses": {str(token_permission.chain_id): token_info["address"]}, + "symbol": token_permission.symbol, + "name": token_permission.symbol, + "amountInWei": str(token_permission.amount * (10 ** token_info["decimals"])) + }) + + params = { + "communityId": self.__community_id, + "type": reversed_mapping[permission], + "tokenCriteria": token_criteria, + "chat_ids": [self.id] + } + response = self.__account._call_rpc("messaging", "createCommunityTokenPermission", [params]) + if response.get("error"): + raise exceptions.InvalidCommunityChannelPermissionError(response["error"]["message"]) + + def delete_permission(self, id: str): + """ + Delete a permission. Channel permissions can be found property `permissions`. + + Parameters: + - `id` - the permission's ID as it is in property `permissions` + """ + params = [ + { + "communityId": self.__community_id, + "permissionId": id, + } + ] + response = self.__account._call_rpc("messaging", "deleteCommunityTokenPermission", params) + if response.get("error"): + raise exceptions.InvalidCommunityChannelPermissionError(response["error"]["message"]) + def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None, colour: Optional[str] = None, description: Optional[str] = None): """ Modify chat related properties. @@ -261,6 +414,7 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None left out of `chat_setup` is cleared. That is why every current value is filled in first, and only the provided arguments override it. """ + info = self.__get_channel_info() channel_setup = { "identity": { "display_name": self.name, @@ -268,8 +422,8 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None "color": self.colour, "description": self.description }, - "category_id": self.__get_channel_info()["categoryID"], - "position": self.__get_channel_info()["position"] + "category_id": info["categoryID"], + "position": info["position"] } if name: @@ -287,28 +441,38 @@ def __edit_channel(self, name: Optional[str] = None, emoji: Optional[str] = None params = [self.__community_id, self.id, channel_setup] self.__account._call_rpc("messaging", "editCommunityChat", params) + def __get_category_positions(self) -> dict: + """ + Get the largest position for each category + """ + largest = {} + for chat in (utils.get_community_info(self.__account, self.__community_id).get("chats") or {}).values(): + category_id: str = chat["categoryID"] + current_position = chat["position"] + if len(category_id) == 0: + category_id = None + + if category_id not in largest: + largest[category_id] = current_position + + if current_position > largest[category_id]: + largest[category_id] = current_position + + return largest + def __get_channel_info(self) -> dict: """ Get information for the current channel. """ - params = { - "communityKey": self.__community_id, - "waitForResponse": True, - "tryDatabase": True - } - response = self.__account._call_rpc("messaging", "fetchCommunity", [params]) - error: dict = response.get("error", {}) - if error: - raise exceptions.InvalidCommunityKeyError(error["message"]) - - chats: dict[str, dict] = response["result"]["chats"] + response = utils.get_community_info(self.__account, self.__community_id) + chats: dict[str, dict] = response["chats"] selected_chat: dict = chats.get(self.id.replace(self.__community_id, ""), {}) if not selected_chat: raise exceptions.CommunityChannelNotFoundError() category_mapping = { category_id: info["name"] - for category_id, info in response["result"].get("categories", {}).items() + for category_id, info in response.get("categories", {}).items() } selected_chat["categoryName"] = category_mapping.get(selected_chat["categoryID"]) return selected_chat diff --git a/status_sdk/docker-compose.yaml b/status_sdk/docker-compose.yaml index 449f9fe..79b6b21 100644 --- a/status_sdk/docker-compose.yaml +++ b/status_sdk/docker-compose.yaml @@ -1,8 +1,8 @@ services: backend: build: - context: https://github.com/status-im/status-go.git#${STATUS_GO_REF:-develop} - platform: ${STATUS_GO_PLATFORM:-linux/amd64} + context: https://github.com/status-im/status-go.git#${STATUS_GO_COMMIT:-develop} + platform: ${PLATFORM:-linux/amd64} container_name: status-backend ports: - 8080:8080 # backend_port diff --git a/status_sdk/exceptions.py b/status_sdk/exceptions.py index 7a6f550..aaa68f0 100644 --- a/status_sdk/exceptions.py +++ b/status_sdk/exceptions.py @@ -74,6 +74,12 @@ class InvalidCommunityChannelColourError(ValueError): class InvalidCommunityChannelEmojiError(ValueError): pass +class InvalidCommunityChannelPositionError(ValueError): + pass + +class InvalidCommunityChannelPermissionError(ValueError): + pass + class ChatNotFoundError(Exception): pass diff --git a/status_sdk/group_chat.py b/status_sdk/group_chat.py index ee65b6b..2fd1267 100644 --- a/status_sdk/group_chat.py +++ b/status_sdk/group_chat.py @@ -84,6 +84,24 @@ def send_image(self, file_path: str, message: Optional[str] = None, reply_to_mes """ return self.__account.send_image(self.id, file_path, message, reply_to_message_id) + def send_bridged_message(self, message: str, name: Optional[str] = None, username: Optional[str] = None, user_id: Optional[str] = None, message_id: Optional[str] = None, reply_to_message_id: Optional[str] = None, image_url: Optional[str] = None) -> str: + """ + Forward a message from another messaging platform to the group chat. + + Parameters: + - `message` - the message that will be sent + - `name` - the name of the other platform + - `username` - the username as it is in the other platform + - `user_id` - the ID of the `username` as it is in the other platform + - `message_id` - the message ID as it is in the other platform + - `reply_to_message_id` - the ID of the message as it is in the other platform + - `image_url` - URL of the user's image + + Output: + - The message ID in Status App + """ + return self.__account.send_bridged_message(self.id, message, name, username, user_id, message_id, reply_to_message_id, image_url) + def send_emoji_reaction(self, message_id: str, emoji_shortname: str): """ Set / unset emoji reaction for a message in the group chat. diff --git a/status_sdk/logger.py b/status_sdk/logger.py deleted file mode 100644 index 5ebc7d0..0000000 --- a/status_sdk/logger.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Optional -import logging - -class Logger: - instance: Optional[logging.Logger] = None - - def __new__(cls, name: str = "status-bot") -> logging.Logger: - - if cls.instance: - return logging.getLogger(name) - - cls.instance = logging.getLogger(name) - 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/status_sdk/models.py b/status_sdk/models.py index 61cefb2..7bd1af3 100644 --- a/status_sdk/models.py +++ b/status_sdk/models.py @@ -1,16 +1,18 @@ from dataclasses import dataclass, field -from typing import Self, Optional -import datetime +from typing import Self, Optional, Union +import datetime, uuid @dataclass class ContactRequest: + id: str public_key: str incoming: bool = False accepted: bool = False + removed: bool = False def __post_init__(self): - if not (self.incoming or self.accepted): - raise ValueError("A ContactRequest must be `incoming` or `accepted`") + if not (self.incoming or self.accepted or self.removed): + raise ValueError(f"A {self.__class__.__name__} must be `incoming`, `accepted` or `removed`") @dataclass class PaymentRequest: @@ -32,6 +34,36 @@ def from_raw(cls, raw: dict) -> Self: } return cls(**params) +@dataclass +class BridgedContent: + message: str + name: Optional[str] = None + username: Optional[str] = None + user_id: Optional[Union[str, int]] = None + reply_to_message_id: Optional[Union[str, int]] = None + message_id: Optional[Union[str, int]] = None + image_url: Optional[str] = None + + def __post_init__(self): + to_string = lambda value: str(value) if isinstance(value, int) else value + + self.user_id = to_string(self.user_id) + self.reply_to_message_id = to_string(self.reply_to_message_id) + self.message_id = to_string(self.message_id) + + @property + def status_go_params(self) -> dict: + content = { + "bridgeName": self.name or "Unknown", + "userName": self.username or "Anon", + "userAvatar": self.image_url or "", + "userID": self.user_id or str(uuid.uuid4()), + "content": self.message, + "messageID": self.message_id or str(uuid.uuid4()), + "parentMessageID": self.reply_to_message_id or "", + } + return content + @dataclass class Message: id: str @@ -41,7 +73,9 @@ class Message: from_public_key: str timestamp: datetime.datetime chat_type: str + source: str reply_id: Optional[str] = None + bridge_id: Optional[str] = None payment_requests: list[PaymentRequest] = field(default_factory=list) @classmethod @@ -52,7 +86,8 @@ def from_raw(cls, raw: dict) -> Self: "id": raw["id"], "chat_id": raw["chatId"], "from_public_key": raw["from"], - "timestamp": datetime.datetime.fromtimestamp(raw["whisperTimestamp"] / 1_000) + "timestamp": datetime.datetime.fromtimestamp(raw["whisperTimestamp"] / 1_000), + "source": raw.get("bridgeMessage", {}).get("bridgeName", "status") } if len(raw["responseTo"]) > 0: @@ -82,6 +117,15 @@ def from_raw(cls, raw: dict) -> Self: caption = f"{text}\n\n" if len(text) > 0 else "" params["content"] = f"{caption}{img_path}" params["content_type"] = "image" + # Bridged Message + elif content_type == 18: + params["content"] = raw["bridgeMessage"]["content"] + params["content_type"] = "text" + params["bridge_id"] = raw["bridgeMessage"]["messageID"] + reply_id = raw["bridgeMessage"].get("parentMessageID") + if isinstance(reply_id, str) and len(reply_id) == 0: + reply_id = None + params["reply_id"] = reply_id payments = raw.get("paymentRequests", []) if payments: @@ -95,5 +139,15 @@ def from_raw(cls, raw: dict) -> Self: @dataclass class CommunityRequest: id: str - state: str public_key: str + pending: bool = False + reject: bool = False + accept: bool = False + cancel: bool = False + +@dataclass +class TokenPermission: + symbol: str + amount: float + chain_id: int = 1 + address: Optional[str] = None diff --git a/status_sdk/utils/__init__.py b/status_sdk/utils/__init__.py new file mode 100644 index 0000000..17a6a7e --- /dev/null +++ b/status_sdk/utils/__init__.py @@ -0,0 +1 @@ +from .external import launch_docker_container diff --git a/status_sdk/utils/community.py b/status_sdk/utils/community.py new file mode 100644 index 0000000..7d81ad0 --- /dev/null +++ b/status_sdk/utils/community.py @@ -0,0 +1,97 @@ +from ..account import Account +from .. import exceptions +from typing import Optional +import pandas as pd + +def get_community_info(account: Account, community_id: str) -> dict: + """ + Get up to date information for the community. + Reused in `class Community` and `class Channel` + + Parameters: + - `account` - already logged in account + - `community_id` - community's ID + + Output: + - up to date community data + """ + params = { + "communityKey": community_id, + "waitForResponse": True, + "tryDatabase": True + } + response = account._call_rpc("messaging", "fetchCommunity", [params]) + error: dict = response.get("error", {}) + if error: + raise exceptions.InvalidCommunityKeyError(error["message"]) + + if not response["result"]: + raise exceptions.CommunityNotFoundError(f"Community '{community_id}' was not found...") + + return response["result"] + + +def get_channel_permissions(account: Account, community_id: str, channel_id: Optional[str] = None) -> pd.DataFrame: + """ + Get the current permissions for the selected Community Channel + + Parameters: + - `account` - already logged in account + - `community_id` - community's ID + - `channel_id` - channel's ID from `community_id` + + Output: + - up to date community permissions + """ + community_info = get_community_info(account, community_id) + if not community_info["tokenPermissions"]: + return pd.DataFrame() + df = pd.DataFrame(community_info["tokenPermissions"].values()).explode("chat_ids") + + # Fill is_private and expand token_criteria + df = df.assign( + is_private=df["is_private"].fillna(False) + ).explode("token_criteria")\ + .reset_index(drop=True) + + # Normalize token_criteria dictionaries into columns + token_cols = pd.json_normalize(df["token_criteria"]) + if "decimals" not in token_cols: + token_cols["decimals"] = 0 + + token_cols = token_cols.assign( + df_index = df.index, + decimals = token_cols["decimals"].fillna(0).astype(int) + ).rename(columns={"amountInWei": "amount_in_wei", "type": "token_type"}) + contract_cols = [col for col in token_cols.columns if col.startswith("contract_addresses.")] + + # Convert the contract address columns into rows + token_cols = token_cols.melt( + id_vars=[ + col for col in token_cols.columns if col not in contract_cols + ], + value_vars=contract_cols, + var_name="chain_id", + value_name="contract_address" + ) + + # Extract chain ID from: + # contract_addresses.1 -> 1 + # contract_addresses.42161 -> 42161 + token_cols["chain_id"] = token_cols["chain_id"].str.removeprefix("contract_addresses.").astype(int) + + # Remove rows where there is no contract address + token_cols = token_cols.dropna(subset=["contract_address"]) + + # Join the expanded token data back onto the original dataframe + df = df.drop(columns=["token_criteria"])\ + .reset_index()\ + .rename(columns={"index": "df_index", "chat_ids": "chat_id"})\ + .merge(token_cols, on="df_index", how="left")\ + .drop(columns=["df_index"])\ + .reset_index(drop=True) + + if channel_id: + df = df.loc[df["chat_id"] == channel_id].reset_index(drop=True) + + return df.copy() diff --git a/status_sdk/utils.py b/status_sdk/utils/external.py similarity index 94% rename from status_sdk/utils.py rename to status_sdk/utils/external.py index 73a9f3c..41aacbf 100644 --- a/status_sdk/utils.py +++ b/status_sdk/utils/external.py @@ -1,8 +1,10 @@ -import shutil, os, subprocess, sys, time, yaml +""" +Used outside of `status_sdk` +""" +import shutil, os, subprocess, sys, time, yaml, logging from pathlib import Path from typing import Optional -from .logger import Logger -from . import exceptions +from .. import exceptions def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, platform: str = "linux/amd64", data_folder: Optional[str] = None): """ @@ -18,7 +20,7 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, - `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() + logger = logging.getLogger(__name__) system = sys.platform is_windows = system == "win32" if not shutil.which("docker"): @@ -29,7 +31,7 @@ def launch_docker_container(commit: Optional[str] = None, wait_seconds: int = 5, 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_COMPOSE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docker-compose.yaml") # 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)