diff --git a/machine/clients/slack.py b/machine/clients/slack.py index 8b3ff214..9537136b 100644 --- a/machine/clients/slack.py +++ b/machine/clients/slack.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime -from typing import Any, AsyncGenerator, Awaitable, Callable +from typing import Any, AsyncGenerator, Awaitable, Callable, Sequence from slack_sdk.errors import SlackApiError from slack_sdk.models.views import View @@ -13,15 +13,20 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse from structlog.stdlib import get_logger +from zoneinfo import ZoneInfo from machine.models import Channel, User from machine.utils.datetime import calculate_epoch -from zoneinfo import ZoneInfo - logger = get_logger(__name__) +def is_sequence(obj: Any): + if isinstance(obj, str): + return False + return isinstance(obj, Sequence) + + def id_for_user(user: User | str) -> str: if isinstance(user, User): return user.id @@ -290,9 +295,9 @@ async def react(self, channel: Channel | str, ts: str, emoji: str) -> AsyncSlack channel_id = id_for_channel(channel) return await self._client.web_client.reactions_add(name=emoji, channel=channel_id, timestamp=ts) - async def open_im(self, user: User | str) -> str: - user_id = id_for_user(user) - response = await self._client.web_client.conversations_open(users=user_id) + async def open_im(self, users: User | str | Sequence[User | str]) -> str: + user_ids = [id_for_user(user) for user in users] if is_sequence(users) else id_for_user(users) + response = await self._client.web_client.conversations_open(users=user_ids) return response["channel"]["id"] async def send_dm(self, user: User | str, text: str | None, **kwargs: Any) -> AsyncSlackResponse: diff --git a/machine/core.py b/machine/core.py index 822edff7..eb36f688 100644 --- a/machine/core.py +++ b/machine/core.py @@ -12,6 +12,7 @@ from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient from structlog.stdlib import get_logger +from zoneinfo import ZoneInfo from machine.clients.slack import SlackClient from machine.handlers import ( @@ -40,11 +41,6 @@ from machine.utils.logging import configure_logging from machine.utils.module_loading import import_string -if sys.version_info >= (3, 9): - from zoneinfo import ZoneInfo # pragma: no cover -else: - from backports.zoneinfo import ZoneInfo # pragma: no cover - logger = get_logger(__name__) diff --git a/machine/plugins/base.py b/machine/plugins/base.py index d116d531..b3d882f3 100644 --- a/machine/plugins/base.py +++ b/machine/plugins/base.py @@ -353,7 +353,7 @@ async def send_dm( :param text: message text :param attachments: optional attachments (see `attachments`_) :param blocks: optional blocks (see `blocks`_) - :return: Dictionary deserialized from `chat.postMessage`_ request. + :return: Dictionary deserialized from `chat.postMessage`_ response. .. _chat.postMessage: https://api.slack.com/methods/chat.postMessage """ @@ -471,7 +471,7 @@ async def update_modal( """Update a modal dialog Update a modal dialog that was previously opened. You can update the view by providing the view_id or the - external_id of the modal. external_id has precedence over vie_id, but at least one needs to be provided. + external_id of the modal. external_id has precedence over view_id, but at least one needs to be provided. You can also provide a hash of the view that you want to update to prevent race conditions. :param view: view definition for the modal dialog diff --git a/machine/plugins/block_action.py b/machine/plugins/block_action.py index 59db9244..eab4bdec 100644 --- a/machine/plugins/block_action.py +++ b/machine/plugins/block_action.py @@ -4,6 +4,8 @@ from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.webhook import WebhookResponse from slack_sdk.webhook.async_client import AsyncWebhookClient from structlog.stdlib import get_logger @@ -30,7 +32,7 @@ class BlockAction: def __init__(self, client: SlackClient, payload: BlockActionsPayload, triggered_action: Action): self._client = client - self.payload = payload #: blablab + self.payload = payload """The payload that was received by the bot when the action was triggered that this plugin method listens for""" self.triggered_action = triggered_action """The action that triggered this plugin method""" @@ -74,7 +76,7 @@ def response_url(self) -> Optional[str]: def trigger_id(self) -> str: """The trigger id associated with the action - The trigger id can be user ot open a modal + The trigger id can be used to open a modal :return: the trigger id for the action """ @@ -136,3 +138,50 @@ async def say( delete_original=delete_original, **kwargs, ) + + async def send_dm( + self, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Send a DM to the user that triggered the block action + + Send a Direct Message to the user that triggered the block action by opening a DM channel and + sending a message to it. Allows for rich formatting using `blocks`_ and/or `attachments`_. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + Any extra kwargs you provide, will be passed on directly to the `chat.postMessage` request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :return: Dictionary deserialized from [chat.postMessage] response. + + [chat.postMessage]: https://api.slack.com/methods/chat.postMessage + """ + return await self._client.send_dm(self.user.id, text, attachments=attachments, blocks=blocks, **kwargs) + + async def open_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open a modal in response to the block action + + Open a modal in response to the block action, using the trigger_id that was returned when the block action was + triggered. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the block action payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) diff --git a/machine/plugins/command.py b/machine/plugins/command.py index 31ef61c8..55be366b 100644 --- a/machine/plugins/command.py +++ b/machine/plugins/command.py @@ -4,6 +4,8 @@ from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.views import View +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.webhook import WebhookResponse from slack_sdk.webhook.async_client import AsyncWebhookClient @@ -116,10 +118,26 @@ async def say( :param ephemeral: `True/False` wether to send the message as an ephemeral message, only visible to the sender of the original message :return: Dictionary deserialized from `AsyncWebhookClient.send()` - """ response_type = "ephemeral" if ephemeral else "in_channel" return await self._webhook_client.send( text=text, attachments=attachments, blocks=blocks, response_type=response_type, **kwargs ) + + async def open_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open a modal in response to the command + + Open a modal in response to the command, using the trigger_id that was returned when the command was invoked. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the command payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) diff --git a/machine/plugins/modals.py b/machine/plugins/modals.py index e3c88258..a2d67a36 100644 --- a/machine/plugins/modals.py +++ b/machine/plugins/modals.py @@ -1,5 +1,14 @@ +from __future__ import annotations + +from typing import Any, Sequence + +from slack_sdk.models.attachments import Attachment +from slack_sdk.models.blocks import Block +from slack_sdk.web.async_slack_response import AsyncSlackResponse + from machine.clients.slack import SlackClient -from machine.models.interactive import ViewClosedPayload, ViewSubmissionPayload +from machine.models import User +from machine.models.interactive import View, ViewClosedPayload, ViewSubmissionPayload class ModalSubmission: @@ -9,6 +18,115 @@ def __init__(self, client: SlackClient, payload: ViewSubmissionPayload): self._client = client self.payload = payload + @property + def user(self) -> User: + """The user that submitted the modal + + :return: the user that submitted the modal + """ + return self._client.users[self.payload.user.id] + + @property + def view(self) -> View: + """The view that was submitted including the state of all the elements in the view + + :return: the view that was submitted + """ + return self.payload.view + + @property + def trigger_id(self) -> str: + """The trigger id associated with the submitted modal + + The trigger id can be user ot open another modal + + :return: the trigger id for the modal + """ + return self.payload.trigger_id + + async def open_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Open another modal in response to the modal submission + + Open another modal in response to modal submission, using the trigger_id that was returned when the modal was + submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_open()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to open + :return: Dictionary deserialized from `AsyncWebClient.views_open()` + """ + return await self._client.open_modal(self.trigger_id, view, **kwargs) + + async def push_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Push a new modal view in response to the modal submission + + Push a new modal view on top of the view stack in response to modal submission, using the trigger_id that was + returned when the modal was submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_push()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to push + :return: Dictionary deserialized from `AsyncWebClient.views_push()` + """ + return await self._client.push_modal(self.trigger_id, view, **kwargs) + + async def update_modal( + self, + view: dict | View, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Update the modal view in response to the modal submission + + Update the modal view in response to modal submission, using the trigger_id that was returned when the modal was + submitted. + Any extra kwargs you provide, will be passed on directly to `AsyncWebClient.views_update()` + + Note: you have to call this method within 3 seconds of receiving the modal submission payload. + + :param view: the view to update + :return: Dictionary deserialized from `AsyncWebClient.views_update()` + """ + return await self._client.update_modal(view, self.payload.view.id, self.payload.view.external_id, **kwargs) + + async def send_dm( + self, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Send a DM to the user that submitted the modal + + Send a Direct Message to the user that submitted the modal by opening a DM channel and + sending a message to it. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + Any extra kwargs you provide, will be passed on directly to the `chat.postMessage` request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :return: Dictionary deserialized from [chat.postMessage] response. + + [chat.postMessage]: https://api.slack.com/methods/chat.postMessage + """ + return await self._client.send_dm(self.user.id, text, attachments=attachments, blocks=blocks, **kwargs) + class ModalClosure: payload: ViewClosedPayload