diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4879f783..5a52ce7c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,7 +49,7 @@ jobs:
         uses: actions/setup-python@v5
         with:
           python-version: "3.13"
-      - uses: chartboost/ruff-action@v1
-      - uses: chartboost/ruff-action@v1
+      - uses: astral-sh/ruff-action@v1
+      - uses: astral-sh/ruff-action@v1
         with:
           args: "format --check"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 884ce6bd..6abdd23c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,7 +10,7 @@ repos:
       - id: check-added-large-files
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: v0.7.4
+    rev: v0.8.0
     hooks:
       # Run the linter.
       - id: ruff
diff --git a/README.md b/README.md
index 5c036739..8d063eb7 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin):
 **Dropped support for Python 3.8** (v0.38.0)
 
 As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been
-dropped. Python 3.7 has reached end-of-life on 2024-10-07.
+dropped. Python 3.8 has reached end-of-life on 2024-10-07.
 
 ## Features
 
@@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
 - Support for [blocks](https://api.slack.com/reference/block-kit/blocks)
 - Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚]
 - Support for [interactive elements](https://api.slack.com/block-kit)
+- Support for [modals](https://api.slack.com/surfaces/modals)
 - Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API
 - Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite and in-memory storage are
   supported)
@@ -68,7 +69,6 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
 
 ### Coming Soon
 
-- Support for modals
 - Support for shortcuts
 - ... and much more
 
diff --git a/docs/api.md b/docs/api.md
index 286fe646..3fa989ed 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -18,6 +18,10 @@ The following classes form the basis for Plugin development.
 
 ### ::: machine.plugins.block_action.BlockAction
 
+### ::: machine.plugins.modals.ModalSubmission
+
+### ::: machine.plugins.modals.ModalClosure
+
 
 ## Decorators
 
@@ -34,6 +38,8 @@ These classes represent base objects from the Slack API
 
 ### ::: machine.models.channel.Channel
 
+### ::: machine.models.interactive.View
+
 ## Storage
 
 Storage is exposed to plugins through the `self.storage` field. The following class implements the interface plugins
diff --git a/docs/index.md b/docs/index.md
index 01291a72..812f0341 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin):
 **Dropped support for Python 3.8** (v0.38.0)
 
 As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been
-dropped. Python 3.7 has reached end-of-life on 2024-10-07.
+dropped. Python 3.8 has reached end-of-life on 2024-10-07.
 
 ## Features
 
@@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
 - Support for [blocks](https://api.slack.com/reference/block-kit/blocks)
 - Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚]
 - Support for [interactive elements](https://api.slack.com/block-kit)
+- Support for [modals](https://api.slack.com/surfaces/modals)
 - Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API
 - Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite, and in-memory storage
   are supported)
@@ -68,6 +69,5 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
 
 ### Coming Soon
 
-- Support for modals
 - Support for shortcuts
 - ... and much more
diff --git a/docs/plugins/block-kit-actions.md b/docs/plugins/block-kit-actions.md
index a9bc26e5..18378657 100644
--- a/docs/plugins/block-kit-actions.md
+++ b/docs/plugins/block-kit-actions.md
@@ -8,8 +8,9 @@ Slack Machine makes it easy to listen to _actions_ triggered by these interactiv
 
 ## Defining actions
 
-When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces, each of
-these blocks can be given a `block_id`. Within certain blocks, you can place
+When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces - either by
+providing a [dict][] or by leveraging the [models of the Slack SDK for Python](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/blocks/index.html)
+- each of these blocks can be given a `block_id`. Within certain blocks, you can place
 [block elements](https://api.slack.com/reference/block-kit/block-elements) that are interactive. These interactive
 elements can be given an `action_id`. Given that one block can contain multiple action elements, each `block_id` can
 be linked to multiple `action_id`s.
@@ -19,7 +20,7 @@ Whenever the user interacts with these elements, an event is sent to Slack Machi
 
 ## Listening to actions
 
-With the [`action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be
+With the [`@action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be
 called when a certain action is triggered. The decorator takes 2 arguments: the `block_id` and the `action_id` that
 you want to listen to. Both arguments are optional, but **one of them always needs to be set**. Both arguments accept a
 [`str`][str] or [`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match,
@@ -34,7 +35,7 @@ Your block action handler will be called with a [`BlockAction`][machine.plugins.
 contains useful information about the action that was triggered and the message or other surface in which the action
 was triggered.
 
-You can optionally pass the `logger` argument to get a
+You can optionally add the `logger` argument to your handler get a
 [logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)
 
 The [`BlockAction`][machine.plugins.block_action.BlockAction] contains various useful fields and properties about
diff --git a/docs/plugins/interacting.md b/docs/plugins/interacting.md
index 6690560d..d7c8b673 100644
--- a/docs/plugins/interacting.md
+++ b/docs/plugins/interacting.md
@@ -219,7 +219,7 @@ You can read [the events section][slack-machine-events] to see how your plugin c
 ## Using the Slack Web API in other ways
 
 Sometimes you want to use [Slack Web API](https://api.slack.com/web) in ways that are not directly exposed by
-[`MachineBaserPlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use
+[`MachineBasePlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use
 [`self.web_client`][machine.plugins.base.MachineBasePlugin.web_client]. `self.web_client` references the
 [`AsyncWebClient`](https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/async_client.html#slack_sdk.web.async_client.AsyncWebClient)
 object of the underlying Slack Python SDK. You should be able to call any
diff --git a/docs/plugins/modals.md b/docs/plugins/modals.md
new file mode 100644
index 00000000..1cbdf4dd
--- /dev/null
+++ b/docs/plugins/modals.md
@@ -0,0 +1,104 @@
+# Modals
+
+In Slack, [modals](https://api.slack.com/surfaces/modals) are a way to ask users for input or display information in a
+dialog/popup-like form. Modals are a great way to collect information from users, display information, or confirm an
+action.
+
+Modals can only be triggered by [actions the user takes](https://api.slack.com/interactivity#user). The most
+common types of user actions that can trigger a modal are:
+
+- Shortcuts
+- [Slash Commands][slash-commands]
+- [Block Kit interative components][block-kit-actions]
+
+For each of these actions, Slack provides a `trigger_id` which can be used to open a modal. **This needs to be done
+within 3 seconds of receiving the `trigger_id`**. Slack Machine abstracts most of this away for you and lets you
+open modals from Slash Commands and Block Kit actions without having to worry about the `trigger_id`.
+
+## Defining and opening modals
+
+When you want to open a modal, you first need to define the
+[_view_](https://api.slack.com/surfaces/modals#composing_views) with the content you want to show. This view has
+some additional properties that define how the modal should behave. One important property is the `callback_id` which
+is used to identify the modal when it is submitted or closed.
+
+You can define a modal view in 2 ways:
+
+- As a [dict][] that conforms to the [View schema](https://api.slack.com/reference/surfaces/views#modal)
+- By constructing a [View](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/views/index.html#slack_sdk.models.views.View)
+  object from the Slack SDK for Python
+
+When you have defined the view, you can open the modal by calling the `open_modal` method on the
+[`Command`][machine.plugins.command.Command] object that is passed to your Slash Command handler or on the
+[`BlockAction`][machine.plugins.block_action.BlockAction] object that is passed to your Block Kit action handler.
+
+## Listening for modal interactions
+
+Once a modal is opened, the user can interact with the block kit elements within the modal, such as buttons, input
+fields, datepickers etc. When the user interacts with these elements, a [block kit action](block-kit-actions.md) can be
+triggered which lets you deal with input.
+
+Additionally, when the user _submits_ the modal, this triggers a `view_submission` event that you can listen to with
+the [`@modal`][machine.plugins.decorators.modal] decorator. This decorator takes a `callback_id` as
+an argument, which is used to identify the modal that was submitted. The `callback_id` can be a [`str`][str] or a
+[`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match, whereas with a regex
+pattern, you can have the handler fired for multiple matching `callback_id`s. This is convenient when you want one
+handler to process multiple modals, for example.
+
+Unless you want to listen for changes to specific input fields - for example to update the modal in-place - it's
+probably easiest to use the `@modal` decorator and process the entire input upon modal submission.
+
+### The modal handler function
+
+The handler function will be called with a
+[`ModalSubmission`][machine.plugins.modals.ModalSubmission] object that contains useful information about the
+modal that was submitted and the user that submitted it. The `ModalSubmission` object has a property
+[`view`][machine.plugins.modals.ModalSubmission.view] that contains the complete view that was submitted, including the
+state of the input fields of the modal. The object also has a [`user`][machine.plugins.modals.ModalSubmission.user]
+property that corresponds to the user that submitted the modal.
+
+You can optionally add the `logger` argument to your handler get a
+[logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)
+
+The `ModalSubmission` contains methods for
+[updating the current modal view][machine.plugins.modals.ModalSubmission.update_modal],
+[pushing a new view on top of the current one][machine.plugins.modals.ModalSubmission.push_modal] or even
+[opening a completely new modal][machine.plugins.modals.ModalSubmission.open_modal].
+
+You can also send a message to the user that submitted the modal with the
+[`send_dm`][machine.plugins.modals.ModalSubmission.send_dm] method.
+
+The modal handler function can be defined as a regular `async` function or a generator. When you define it as a
+generator, you can use the `yield` statement to:
+
+- [Update the modal view in-place](https://api.slack.com/surfaces/modals#updating_response)
+- [Push a new view on top of the current one](https://api.slack.com/surfaces/modals#add_response)
+- [Close the current view](https://api.slack.com/surfaces/modals#close_current_view) (which is the default behavior when
+  nothing is yielded)
+- [Close all the views on the stack](https://api.slack.com/surfaces/modals#close_all_views)
+- [Display errors in the modal](https://api.slack.com/surfaces/modals#displaying_errors), which is useful when you want
+  to show validation errors to the user.
+
+!!! warning
+
+    You must yield a response to Slack within 3 seconds of receiving the `view_submission` event. If you don't, Slack
+    will show an error to the user.
+
+
+## Listening for modal closures
+
+Sometimes you want to know when a user closes a modal without submitting it. This can be useful to clean up
+resources or store the state of the modal for later continuation. You can listen for modal closures with the
+[`@modal_closed`][machine.plugins.decorators.modal_closed] decorator. This decorator takes a `callback_id` as parameter
+and works the same way as the `@modal` decorator.
+
+The handler function will be called with a
+[`ModalClosure`][machine.plugins.modals.ModalClosure] object that contains information about the modal that was
+closed. Just like the `ModalSubmission` object, the `ModalClosure` object has a
+[`view`][machine.plugins.modals.ModalSubmission.view] property that contains the complete view of the modal that was
+closed, including the state of the input fields. The object also has a
+[`user`][machine.plugins.modals.ModalClosure.user] property that corresponds to the user that submitted the
+modal.
+
+You can send a message to the user that submitted the modal with the
+[`send_dm`][machine.plugins.modals.ModalClosure.send_dm] method.
diff --git a/docs/plugins/slash-commands.md b/docs/plugins/slash-commands.md
index 2c3dfb20..696963f8 100644
--- a/docs/plugins/slash-commands.md
+++ b/docs/plugins/slash-commands.md
@@ -34,7 +34,7 @@ features:
 
 ## Defining your Slash Command in code
 
-The next step is to use the [`command`][machine.plugins.decorators.command] decorator on the function that should be
+The next step is to use the [`@command`][machine.plugins.decorators.command] decorator on the function that should be
 triggered when the user uses the Slash Command you defined. The decorator takes only 1 parameter: the slash command
 that should trigger the decorated function. It should be the same as the Slash Command you just defined in the App
 dashboard.
@@ -55,7 +55,7 @@ information about the slash command invocation. The most important property is p
 [`text`][machine.plugins.command.Command.text], which contains any additional text that was passed when the slash
 command was used.
 
-You can optionally pass the `logger` argument to get a
+You can optionally add the `logger` argument to your handler get a
 [logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)
 
 ### Responding to a command
@@ -96,14 +96,10 @@ async def hello_again(self, command):
     await command.say("This will be sent after the initial acknowledgement")
 ```
 
-## Other types of responses
+## Opening modals
 
 The [`Command`][machine.plugins.command.Command] object that your handler receives, contains an extra piece of
 information you can use to trigger more varied reponses: the [`trigger_id`][machine.plugins.command.Command.trigger_id]
-The `trigger_id` can used specifically to trigger
-[_modal responses_](https://api.slack.com/interactivity/handling#modal_responses). For now, creating a modal is
-something you have to take care of yourself. More information on this can be found
-[here](https://api.slack.com/surfaces/modals/using).
-
-In future releases, Slack Machine will make working with modals much easier by allowing modals to be opened directly
-through the provided command object, and responding to interactions happening in modals through new decorators.
+The `trigger_id` can used specifically to trigger [_modal responses_][modals]. You don't have to worry about the
+`trigger_id` and instead can just call the [`open_modal`][machine.plugins.command.Command.open_modal] method on the
+`Command` object to open a modal.
diff --git a/machine/clients/slack.py b/machine/clients/slack.py
index 474c1002..9e1220a7 100644
--- a/machine/clients/slack.py
+++ b/machine/clients/slack.py
@@ -1,11 +1,13 @@
 from __future__ import annotations
 
 import asyncio
-import sys
+from collections.abc import AsyncGenerator, Awaitable
 from datetime import datetime
-from typing import Any, AsyncGenerator, Awaitable, Callable
+from typing import Any, Callable
+from zoneinfo import ZoneInfo
 
 from slack_sdk.errors import SlackApiError
+from slack_sdk.models.views import View
 from slack_sdk.socket_mode.aiohttp import SocketModeClient
 from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
 from slack_sdk.socket_mode.request import SocketModeRequest
@@ -17,11 +19,6 @@
 from machine.models import Channel, User
 from machine.utils.datetime import calculate_epoch
 
-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__)
 
 
@@ -293,24 +290,22 @@ 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 | list[User | str]) -> str:
+        user_ids = [id_for_user(user) for user in users] if isinstance(users, list) 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:
         user_id = id_for_user(user)
-        dm_channel_id = await self.open_im(user_id)
 
-        return await self._client.web_client.chat_postMessage(channel=dm_channel_id, text=text, as_user=True, **kwargs)
+        return await self._client.web_client.chat_postMessage(channel=user_id, text=text, as_user=True, **kwargs)
 
     async def send_dm_scheduled(self, when: datetime, user: User | str, text: str, **kwargs: Any) -> AsyncSlackResponse:
         user_id = id_for_user(user)
-        dm_channel_id = await self.open_im(user_id)
         scheduled_ts = calculate_epoch(when, self._tz)
 
         return await self._client.web_client.chat_scheduleMessage(
-            channel=dm_channel_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs
+            channel=user_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs
         )
 
     async def pin_message(self, channel: Channel | str, ts: str) -> AsyncSlackResponse:
@@ -324,3 +319,27 @@ async def unpin_message(self, channel: Channel | str, ts: str) -> AsyncSlackResp
     async def set_topic(self, channel: Channel | str, topic: str, **kwargs: Any) -> AsyncSlackResponse:
         channel_id = id_for_channel(channel)
         return await self._client.web_client.conversations_setTopic(channel=channel_id, topic=topic, **kwargs)
+
+    async def open_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
+        return await self._client.web_client.views_open(trigger_id=trigger_id, view=view, **kwargs)
+
+    async def push_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
+        return await self._client.web_client.views_push(trigger_id=trigger_id, view=view, **kwargs)
+
+    async def update_modal(
+        self,
+        view: dict | View,
+        view_id: str | None = None,
+        external_id: str | None = None,
+        hash: str | None = None,
+        **kwargs: Any,
+    ) -> AsyncSlackResponse:
+        return await self._client.web_client.views_update(
+            view=view, view_id=view_id, external_id=external_id, hash=hash, **kwargs
+        )
+
+    async def publish_home_tab(
+        self, user: User | str, view: dict | View, hash: str | None = None, **kwargs: Any
+    ) -> AsyncSlackResponse:
+        user_id = id_for_user(user)
+        return await self._client.web_client.views_publish(user_id=user_id, view=view, hash=hash, **kwargs)
diff --git a/machine/core.py b/machine/core.py
index 17a96532..c9da1493 100644
--- a/machine/core.py
+++ b/machine/core.py
@@ -4,8 +4,10 @@
 import inspect
 import os
 import sys
+from collections.abc import Awaitable
 from inspect import Signature
-from typing import Awaitable, Callable, Literal, cast
+from typing import Callable, Literal, cast
+from zoneinfo import ZoneInfo
 
 import dill
 from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -27,22 +29,19 @@
     HumanHelp,
     Manual,
     MessageHandler,
+    ModalHandler,
     RegisteredActions,
-    action_block_id_to_str,
+    matcher_to_str,
 )
 from machine.plugins.base import MachineBasePlugin
-from machine.plugins.decorators import ActionConfig, CommandConfig, DecoratedPluginFunc, MatcherConfig, Metadata
+from machine.plugins.decorators import DecoratedPluginFunc
+from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, Metadata, ModalConfig
 from machine.settings import import_settings
 from machine.storage import MachineBaseStorage, PluginStorage
 from machine.utils.collections import CaseInsensitiveDict
 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__)
 
 
@@ -236,6 +235,26 @@ def _register_plugin_actions(
                 block_action_config=block_action_config,
                 class_help=class_help,
             )
+        for modal_config in metadata.plugin_actions.modal_submissions:
+            self._register_modal_handler(
+                type_="modal",
+                class_=cls_instance,
+                class_name=plugin_class_name,
+                fq_fn_name=fq_fn_name,
+                function=fn,
+                modal_config=modal_config,
+                class_help=class_help,
+            )
+        for modal_config in metadata.plugin_actions.modal_closures:
+            self._register_modal_handler(
+                type_="modal_closed",
+                class_=cls_instance,
+                class_name=plugin_class_name,
+                fq_fn_name=fq_fn_name,
+                function=fn,
+                modal_config=modal_config,
+                class_help=class_help,
+            )
 
         if metadata.plugin_actions.schedule is not None:
             self._scheduler.add_job(
@@ -314,11 +333,34 @@ def _register_block_action_handler(
             action_id_matcher=block_action_config.action_id,
             block_id_matcher=block_action_config.block_id,
         )
-        action_id = action_block_id_to_str(block_action_config.action_id)
-        block_id = action_block_id_to_str(block_action_config.block_id)
+        action_id = matcher_to_str(block_action_config.action_id)
+        block_id = matcher_to_str(block_action_config.block_id)
         key = f"{fq_fn_name}-{action_id}-{block_id}"
         self._registered_actions.block_actions[key] = handler
 
+    def _register_modal_handler(
+        self,
+        type_: Literal["modal", "modal_closed"],
+        class_: MachineBasePlugin,
+        class_name: str,
+        fq_fn_name: str,
+        function: Callable[..., Awaitable[None]],
+        modal_config: ModalConfig,
+        class_help: str,
+    ) -> None:
+        signature = Signature.from_callable(function)
+        logger.debug("signature of modal handler", signature=signature, function=fq_fn_name)
+        handler = ModalHandler(
+            class_=class_,
+            class_name=class_name,
+            function=function,
+            function_signature=signature,
+            callback_id_matcher=modal_config.callback_id,
+            is_generator=modal_config.is_generator,
+        )
+        key = f"{fq_fn_name}-{matcher_to_str(modal_config.callback_id)}"
+        getattr(self._registered_actions, type_)[key] = handler
+
     @staticmethod
     def _parse_human_help(doc: str) -> HumanHelp:
         summary = doc.splitlines()[0].split(":")
diff --git a/machine/handlers/command_handler.py b/machine/handlers/command_handler.py
index f6ca86a9..8e7a10d7 100644
--- a/machine/handlers/command_handler.py
+++ b/machine/handlers/command_handler.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
 import contextlib
-from typing import Any, AsyncGenerator, Awaitable, Callable, Union, cast
+from collections.abc import AsyncGenerator, Awaitable
+from typing import Any, Callable, Union, cast
 
 from slack_sdk.models import JsonObject
 from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
diff --git a/machine/handlers/event_handler.py b/machine/handlers/event_handler.py
index 1f125670..b3015ff4 100644
--- a/machine/handlers/event_handler.py
+++ b/machine/handlers/event_handler.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
 import asyncio
-from typing import Any, Awaitable, Callable
+from collections.abc import Awaitable
+from typing import Any, Callable
 
 from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
 from slack_sdk.socket_mode.request import SocketModeRequest
diff --git a/machine/handlers/interactive_handler.py b/machine/handlers/interactive_handler.py
index bfd8f4f9..26c1b26a 100644
--- a/machine/handlers/interactive_handler.py
+++ b/machine/handlers/interactive_handler.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
 import asyncio
+import contextlib
 import re
-from typing import Awaitable, Callable, Union
+from collections.abc import AsyncGenerator, Awaitable
+from typing import Callable, Union, cast
 
+from slack_sdk.models.views import View
 from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
 from slack_sdk.socket_mode.request import SocketModeRequest
 from slack_sdk.socket_mode.response import SocketModeResponse
@@ -12,8 +15,15 @@
 from machine.clients.slack import SlackClient
 from machine.handlers.logging import create_scoped_logger
 from machine.models.core import RegisteredActions
-from machine.models.interactive import Action, BlockActionsPayload, InteractivePayload
+from machine.models.interactive import (
+    Action,
+    BlockActionsPayload,
+    InteractivePayload,
+    ViewClosedPayload,
+    ViewSubmissionPayload,
+)
 from machine.plugins.block_action import BlockAction
+from machine.plugins.modals import ModalClosure, ModalSubmission
 
 logger = get_logger(__name__)
 
@@ -25,13 +35,19 @@ def create_interactive_handler(
     async def handle_interactive_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None:
         if request.type == "interactive":
             logger.debug("interactive trigger received", payload=request.payload)
-            # Acknowledge the request anyway
-            response = SocketModeResponse(envelope_id=request.envelope_id)
-            # Don't forget having await for method calls
-            await client.send_socket_mode_response(response)
             parsed_payload = InteractivePayload.validate_python(request.payload)
             if parsed_payload.type == "block_actions":
+                # Acknowledge the request
+                response = SocketModeResponse(envelope_id=request.envelope_id)
+                await client.send_socket_mode_response(response)
                 await handle_block_actions(parsed_payload, plugin_actions, slack_client)
+            if parsed_payload.type == "view_submission":
+                await handle_view_submission(parsed_payload, request.envelope_id, client, plugin_actions, slack_client)
+            if parsed_payload.type == "view_closed":
+                # Acknowledge the request
+                response = SocketModeResponse(envelope_id=request.envelope_id)
+                await client.send_socket_mode_response(response)
+                await handle_view_closed(parsed_payload, plugin_actions, slack_client)
 
     return handle_interactive_request
 
@@ -64,6 +80,70 @@ async def handle_block_actions(
     await asyncio.gather(*handler_funcs)
 
 
+async def handle_view_submission(
+    payload: ViewSubmissionPayload,
+    envelope_id: str,
+    socket_mode_client: AsyncBaseSocketModeClient,
+    plugin_actions: RegisteredActions,
+    slack_client: SlackClient,
+) -> None:
+    handler_funcs = []
+    modal_submission_obj = _gen_modal_submission(payload, slack_client)
+    for handler in plugin_actions.modal.values():
+        if _matches(handler.callback_id_matcher, payload.view.callback_id):
+            if "logger" in handler.function_signature.parameters:
+                view_submission_logger = create_scoped_logger(
+                    handler.class_name,
+                    handler.function.__name__,
+                    user_id=payload.user.id,
+                    user_name=payload.user.name,
+                )
+                extra_args = {"logger": view_submission_logger}
+            else:
+                extra_args = {}
+            # Check if the handler is a generator. In this case we have an immediate response we can send back
+            if handler.is_generator:
+                gen_fn = cast(Callable[..., AsyncGenerator[Union[dict, View], None]], handler.function)
+                logger.debug("Modal submission handler is generator, returning immediate ack")
+                gen = gen_fn(modal_submission_obj, **extra_args)
+                # return immediate reponse
+                response = await gen.__anext__()
+                ack_response = SocketModeResponse(envelope_id=envelope_id, payload=response)
+                await socket_mode_client.send_socket_mode_response(ack_response)
+                # Now run the rest of the function
+                with contextlib.suppress(StopAsyncIteration):
+                    await gen.__anext__()
+            else:
+                logger.debug("Modal submission is regular async function")
+                ack_response = SocketModeResponse(envelope_id=envelope_id)
+                await socket_mode_client.send_socket_mode_response(ack_response)
+                handler_funcs.append(handler.function(modal_submission_obj, **extra_args))
+    await asyncio.gather(*handler_funcs)
+
+
+async def handle_view_closed(
+    payload: ViewClosedPayload,
+    plugin_actions: RegisteredActions,
+    slack_client: SlackClient,
+) -> None:
+    handler_funcs = []
+    modal_submission_obj = _gen_modal_closure(payload, slack_client)
+    for handler in plugin_actions.modal_closed.values():
+        if _matches(handler.callback_id_matcher, payload.view.callback_id):
+            if "logger" in handler.function_signature.parameters:
+                view_closure_logger = create_scoped_logger(
+                    handler.class_name,
+                    handler.function.__name__,
+                    user_id=payload.user.id,
+                    user_name=payload.user.name,
+                )
+                extra_args = {"logger": view_closure_logger}
+            else:
+                extra_args = {}
+            handler_funcs.append(handler.function(modal_submission_obj, **extra_args))
+    await asyncio.gather(*handler_funcs)
+
+
 def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool:
     if matcher is None:
         return True
@@ -74,3 +154,11 @@ def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool:
 
 def _gen_block_action(payload: BlockActionsPayload, triggered_action: Action, slack_client: SlackClient) -> BlockAction:
     return BlockAction(slack_client, payload, triggered_action)
+
+
+def _gen_modal_submission(payload: ViewSubmissionPayload, slack_client: SlackClient) -> ModalSubmission:
+    return ModalSubmission(slack_client, payload)
+
+
+def _gen_modal_closure(payload: ViewClosedPayload, slack_client: SlackClient) -> ModalClosure:
+    return ModalClosure(slack_client, payload)
diff --git a/machine/handlers/logging.py b/machine/handlers/logging.py
index 23d3e290..fbad603f 100644
--- a/machine/handlers/logging.py
+++ b/machine/handlers/logging.py
@@ -8,7 +8,12 @@
 
 
 async def log_request(_: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None:
-    logger.debug("Request received", type=request.type, request=request.to_dict())
+    logger.debug(
+        "Request received",
+        type=request.type,
+        request=request.to_dict(),
+        accepts_response_payload=request.accepts_response_payload,
+    )
 
 
 def create_scoped_logger(class_name: str, function_name: str, **kwargs: Any) -> BoundLogger:
diff --git a/machine/handlers/message_handler.py b/machine/handlers/message_handler.py
index de1535ae..f5e6aff7 100644
--- a/machine/handlers/message_handler.py
+++ b/machine/handlers/message_handler.py
@@ -2,7 +2,8 @@
 
 import asyncio
 import re
-from typing import Any, Awaitable, Callable, Mapping
+from collections.abc import Awaitable, Mapping
+from typing import Any, Callable
 
 from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
 from slack_sdk.socket_mode.request import SocketModeRequest
diff --git a/machine/models/channel.py b/machine/models/channel.py
index 57f5b697..87207c68 100644
--- a/machine/models/channel.py
+++ b/machine/models/channel.py
@@ -1,4 +1,4 @@
-from typing import List, Optional
+from typing import Optional
 
 from pydantic import BaseModel, ConfigDict
 
@@ -36,7 +36,7 @@ class Channel(BaseModel):
     user: Optional[str] = None
     topic: Optional[PurposeTopic] = None
     purpose: Optional[PurposeTopic] = None
-    previous_names: Optional[List[str]] = None
+    previous_names: Optional[list[str]] = None
 
     @property
     def identifier(self) -> str:
diff --git a/machine/models/core.py b/machine/models/core.py
index 7793a78a..4b59ad35 100644
--- a/machine/models/core.py
+++ b/machine/models/core.py
@@ -1,9 +1,10 @@
 from __future__ import annotations
 
 import re
+from collections.abc import AsyncGenerator, Awaitable
 from dataclasses import dataclass, field
 from inspect import Signature
-from typing import Any, AsyncGenerator, Awaitable, Callable, Union
+from typing import Any, Callable, Union
 
 from slack_sdk.models import JsonObject
 
@@ -52,6 +53,16 @@ class BlockActionHandler:
     block_id_matcher: Union[re.Pattern[str], str, None]
 
 
+@dataclass
+class ModalHandler:
+    class_: MachineBasePlugin
+    class_name: str
+    function: Callable[..., Awaitable[None]]
+    function_signature: Signature
+    callback_id_matcher: Union[re.Pattern[str], str]
+    is_generator: bool
+
+
 @dataclass
 class RegisteredActions:
     listen_to: dict[str, MessageHandler] = field(default_factory=dict)
@@ -59,9 +70,11 @@ class RegisteredActions:
     process: dict[str, dict[str, Callable[[dict[str, Any]], Awaitable[None]]]] = field(default_factory=dict)
     command: dict[str, CommandHandler] = field(default_factory=dict)
     block_actions: dict[str, BlockActionHandler] = field(default_factory=dict)
+    modal: dict[str, ModalHandler] = field(default_factory=dict)
+    modal_closed: dict[str, ModalHandler] = field(default_factory=dict)
 
 
-def action_block_id_to_str(id_: Union[str, re.Pattern[str], None]) -> str:
+def matcher_to_str(id_: Union[str, re.Pattern[str], None]) -> str:
     if id_ is None:
         return "*"
     elif isinstance(id_, str):
diff --git a/machine/models/interactive.py b/machine/models/interactive.py
index 4ad3994b..0c910b37 100644
--- a/machine/models/interactive.py
+++ b/machine/models/interactive.py
@@ -1,13 +1,16 @@
+# mypy: ignore-errors
+# MyPy is disabled because Pydantic doesn't support __replace__ that was introduced in Python 3.13 yet, which causes
+# MyPy to throw an error. This will be fixed once Pydantic supports __replace__ in v2.10
+
 from __future__ import annotations
 
 from datetime import date, time
-from typing import Any, Dict, List, Literal, Optional, Union
+from typing import Annotated, Any, Literal, Optional, Union
 
 from pydantic import BaseModel, Field, TypeAdapter
 from pydantic.functional_validators import PlainValidator, model_validator
 from pydantic_core.core_schema import ValidationInfo
 from slack_sdk.models.blocks import Block as SlackSDKBlock
-from typing_extensions import Annotated
 
 
 class TypedModel(BaseModel):
@@ -77,7 +80,7 @@ class Option(BaseModel):
 
 class CheckboxValues(TypedModel):
     type: Literal["checkboxes"]
-    selected_options: List[Option]
+    selected_options: list[Option]
 
 
 class DatepickerValue(TypedModel):
@@ -117,27 +120,27 @@ class ExternalSelectValue(TypedModel):
 
 class MultiStaticSelectValues(TypedModel):
     type: Literal["multi_static_select"]
-    selected_options: List[Option]
+    selected_options: list[Option]
 
 
 class MultiChannelSelectValues(TypedModel):
     type: Literal["multi_channels_select"]
-    selected_channels: List[str]
+    selected_channels: list[str]
 
 
 class MultiConversationSelectValues(TypedModel):
     type: Literal["multi_conversations_select"]
-    selected_conversations: List[str]
+    selected_conversations: list[str]
 
 
 class MultiUserSelectValues(TypedModel):
     type: Literal["multi_users_select"]
-    selected_users: List[str]
+    selected_users: list[str]
 
 
 class MultiExternalSelectValues(TypedModel):
     type: Literal["multi_external_select"]
-    selected_options: List[str]
+    selected_options: list[str]
 
 
 class NumberValue(TypedModel):
@@ -197,7 +200,7 @@ class UrlValue(TypedModel):
 
 
 class State(BaseModel):
-    values: Dict[str, Dict[str, Values]]
+    values: dict[str, dict[str, Values]]
 
 
 class BaseAction(TypedModel):
@@ -221,7 +224,7 @@ class ButtonAction(BaseAction):
 
 class CheckboxAction(BaseAction):
     type: Literal["checkboxes"]
-    selected_options: List[Option]
+    selected_options: list[Option]
 
 
 class DatepickerAction(BaseAction):
@@ -256,27 +259,27 @@ class ExternalSelectAction(BaseAction):
 
 class MultiStaticSelectAction(BaseAction):
     type: Literal["multi_static_select"]
-    selected_options: List[Option]
+    selected_options: list[Option]
 
 
 class MultiChannelSelectAction(BaseAction):
     type: Literal["multi_channels_select"]
-    selected_channels: List[str]
+    selected_channels: list[str]
 
 
 class MultiConversationSelectAction(BaseAction):
     type: Literal["multi_conversations_select"]
-    selected_conversations: List[str]
+    selected_conversations: list[str]
 
 
 class MultiUserSelectAction(BaseAction):
     type: Literal["multi_users_select"]
-    selected_users: List[str]
+    selected_users: list[str]
 
 
 class MultiExternalSelectAction(BaseAction):
     type: Literal["multi_external_select"]
-    selected_options: List[str]
+    selected_options: list[str]
 
 
 class TimepickerAction(BaseAction):
@@ -348,14 +351,14 @@ class Message(BaseModel):
     app_id: str
     text: str
     team: str
-    blocks: List[Block]
+    blocks: list[Block]
 
 
 class View(BaseModel):
     id: str
     team_id: str
     type: Literal["modal", "home"]
-    blocks: List[Block]
+    blocks: list[Block]
     private_metadata: str
     callback_id: str
     state: State
@@ -395,7 +398,7 @@ class BlockActionsPayload(TypedModel):
     view: Optional[View] = None
     state: Optional[State] = None
     response_url: Optional[str] = None
-    actions: List[Action]
+    actions: list[Action]
 
     @model_validator(mode="after")
     def validate_view_or_message(self) -> BlockActionsPayload:
@@ -420,10 +423,22 @@ class ViewSubmissionPayload(TypedModel):
     api_app_id: str
     token: str
     trigger_id: str
-    response_urls: List[ResponseUrlForView]
+    response_urls: list[ResponseUrlForView]
+    is_enterprise_install: bool
+
+
+class ViewClosedPayload(TypedModel):
+    type: Literal["view_closed"]
+    team: Team
+    user: User
+    view: View
+    enterprise: Optional[str]
+    api_app_id: str
+    token: str
+    is_cleared: bool
     is_enterprise_install: bool
 
 
-InteractivePayload: TypeAdapter[Union[BlockActionsPayload, ViewSubmissionPayload]] = TypeAdapter(
-    Annotated[Union[BlockActionsPayload, ViewSubmissionPayload], Field(discriminator="type")]
+InteractivePayload: TypeAdapter[Union[BlockActionsPayload, ViewSubmissionPayload, ViewClosedPayload]] = TypeAdapter(
+    Annotated[Union[BlockActionsPayload, ViewSubmissionPayload, ViewClosedPayload], Field(discriminator="type")]
 )
diff --git a/machine/plugins/base.py b/machine/plugins/base.py
index 5c67c06c..e338545e 100644
--- a/machine/plugins/base.py
+++ b/machine/plugins/base.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
+from collections.abc import Sequence
 from datetime import datetime
-from typing import Any, Sequence
+from typing import Any
 
 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_client import AsyncWebClient
 from slack_sdk.web.async_slack_response import AsyncSlackResponse
 
@@ -268,7 +270,6 @@ async def update(
         :param text: message text
         :param attachments: optional attachments (see [attachments])
         :param blocks: optional blocks (see [blocks])
-        :param thread_ts: optional timestamp of thread, to send a message in that thread
         :param ephemeral_user: optional user name or id if the message needs to visible
             to a specific user only
         :return: Dictionary deserialized from [`chat.update`](https://api.slack.com/methods/chat.update) request
@@ -352,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
         """
@@ -387,6 +388,18 @@ async def send_dm_scheduled(
             when, user, text=text, attachments=attachments, blocks=blocks, **kwargs
         )
 
+    async def open_im(self, users: User | str | list[User | str]) -> str:
+        """Open a DM channel with one or more users
+
+        Open a DM channel with one or more users. If the DM channel already exists, the existing channel id
+        will be returned. If the DM channel does not exist, a new channel will be created and the
+        id of the new channel will be returned.
+
+        :param users: :py:class:`~machine.models.user.User` object or id of user to open DM with.
+        :return: id of the DM channel
+        """
+        return await self._client.open_im(users)
+
     def emit(self, event: str, **kwargs: Any) -> None:
         """Emit an event
 
@@ -431,3 +444,71 @@ async def set_topic(self, channel: Channel | str, topic: str, **kwargs: Any) ->
         :return: response from the Slack Web API
         """
         return await self._client.set_topic(channel, topic, **kwargs)
+
+    async def open_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
+        """Open a modal dialog
+
+        Open a modal dialog in response to a user action. The modal dialog can be used to collect
+        information from the user, or to display information to the user.
+
+        :param trigger_id: trigger id is provided by Slack when a user action is performed, such as a slash command
+            or a button click
+        :param view: view definition for the modal dialog
+        :return: response from the Slack Web API
+        """
+        return await self._client.web_client.views_open(trigger_id=trigger_id, view=view, **kwargs)
+
+    async def push_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
+        """Push a new view onto the stack of a modal that was already opened
+
+        Push a new view onto the stack of a modal that was already opened by a open_modal call. At most 3 views can be
+        active in a modal at the same time. For more information on the lifecycle of modals, refer to the
+        [relevant Slack documentation](https://api.slack.com/surfaces/modals)
+
+        :param trigger_id: trigger id is provided by Slack when a user action is performed, such as a slash command
+            or a button click
+        :param view: view definition for the modal dialog
+        :return: response from the Slack Web API
+        """
+        return await self._client.push_modal(trigger_id=trigger_id, view=view, **kwargs)
+
+    async def update_modal(
+        self,
+        view: dict | View,
+        view_id: str | None = None,
+        external_id: str | None = None,
+        hash: str | None = None,
+        **kwargs: Any,
+    ) -> AsyncSlackResponse:
+        """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 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
+        :param view_id: id of the view to update
+        :param external_id: external id of the view to update
+        :param hash: hash of the view to update
+        :return: response from the Slack Web API
+        """
+        return await self._client.update_modal(view=view, view_id=view_id, external_id=external_id, hash=hash, **kwargs)
+
+    async def publish_home_tab(
+        self, user: User | str, view: dict | View, hash: str | None = None, **kwargs: Any
+    ) -> AsyncSlackResponse:
+        """Publish a view to the home tab of a user
+
+        Publish a view to the home tab of a user. The view will be visible to the user when they open the home tab of
+        your Slack app. This method can be used both to publish a new view for the home tab or update an existing view.
+        You can provide a hash of the view that you want to update to prevent race conditions.
+
+        Note: be careful with the use of this method, as you might be overwriting the user's home tab that was set by
+        another Slack Machine plugin enabled in your bot.
+
+        :param user: user for whom to publish or update the home tab
+        :param view: view definition for the home tab
+        :param hash: hash of the view to update
+        :return: response from the Slack Web API
+        """
+        return await self._client.publish_home_tab(user=user, view=view, hash=hash, **kwargs)
diff --git a/machine/plugins/block_action.py b/machine/plugins/block_action.py
index 59db9244..680e8a1d 100644
--- a/machine/plugins/block_action.py
+++ b/machine/plugins/block_action.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
-from typing import Any, Optional, Sequence, Union
+from collections.abc import Sequence
+from typing import Any, Optional, Union
 
 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 +33,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 +77,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 +139,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..4e426f10 100644
--- a/machine/plugins/command.py
+++ b/machine/plugins/command.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
-from typing import Any, Sequence
+from collections.abc import Sequence
+from typing import Any
 
 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 +119,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/decorators.py b/machine/plugins/decorators.py
index 379240ad..b8a07efa 100644
--- a/machine/plugins/decorators.py
+++ b/machine/plugins/decorators.py
@@ -2,9 +2,9 @@
 
 import inspect
 import re
-from dataclasses import dataclass, field
+from collections.abc import Awaitable
 from datetime import datetime, tzinfo
-from typing import Any, Awaitable, Callable, Protocol, TypeVar, Union, cast
+from typing import Any, Callable, Protocol, TypeVar, Union, cast
 
 from structlog.stdlib import get_logger
 from typing_extensions import ParamSpec
@@ -13,44 +13,10 @@
 from machine.plugins.admin_utils import RoleCombinator, matching_roles_by_user_id
 from machine.plugins.base import MachineBasePlugin
 from machine.plugins.message import Message
+from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, Metadata, ModalConfig
 
 logger = get_logger(__name__)
 
-
-@dataclass
-class MatcherConfig:
-    regex: re.Pattern[str]
-    handle_changed_message: bool = False
-
-
-@dataclass
-class CommandConfig:
-    command: str
-    is_generator: bool = False
-
-
-@dataclass
-class ActionConfig:
-    action_id: Union[re.Pattern[str], str, None] = None
-    block_id: Union[re.Pattern[str], str, None] = None
-
-
-@dataclass
-class PluginActions:
-    process: list[str] = field(default_factory=list)
-    listen_to: list[MatcherConfig] = field(default_factory=list)
-    respond_to: list[MatcherConfig] = field(default_factory=list)
-    schedule: dict[str, Any] | None = None
-    commands: list[CommandConfig] = field(default_factory=list)
-    actions: list[ActionConfig] = field(default_factory=list)
-
-
-@dataclass
-class Metadata:
-    plugin_actions: PluginActions = field(default_factory=PluginActions)
-    required_settings: list[str] = field(default_factory=list)
-
-
 P = ParamSpec("P")
 R = TypeVar("R", covariant=True, bound=Union[Awaitable[None], MachineBasePlugin])
 
@@ -188,6 +154,54 @@ def action_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]:
     return action_decorator
 
 
+def modal(callback_id: Union[re.Pattern[str], str]) -> Callable[[Callable[P, R]], DecoratedPluginFunc[P, R]]:
+    """Respond to modal submissions
+
+    This decorator will enable a Plugin method to be triggered when certain modals are submitted.
+    The Plugin method will be called when a modal submission event is received for which the
+    callback_id matches the provided value. The callback_id can be a string or a regex pattern.
+
+    :param callback_id: the callback_id to respond to, can be a string or regex pattern
+    :return: wrapped method
+    """
+
+    def modal_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]:
+        fn = cast(DecoratedPluginFunc, f)
+        fn.metadata = getattr(f, "metadata", Metadata())
+        is_generator = inspect.isasyncgenfunction(f)
+        fn.metadata.plugin_actions.modal_submissions.append(
+            ModalConfig(callback_id=callback_id, is_generator=is_generator)
+        )
+        return fn
+
+    return modal_decorator
+
+
+def modal_closed(callback_id: Union[re.Pattern[str], str]) -> Callable[[Callable[P, R]], DecoratedPluginFunc[P, R]]:
+    """Respond to modal closures
+
+    This decorator will enable a Plugin method to be triggered when certain modals are closed.
+    The Plugin method will be called when a modal closure event is received for which the
+    callback_id matches the provided value. The callback_id can be a string or a regex pattern.
+
+    Note: in order to receive modal close events, the modal must have the `notify_on_close` property set to `True`.
+
+    :param callback_id: the callback_id to respond to, can be a string or regex pattern
+    :return: wrapped method
+    """
+
+    def modal_closed_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]:
+        fn = cast(DecoratedPluginFunc, f)
+        fn.metadata = getattr(f, "metadata", Metadata())
+        is_generator = inspect.isasyncgenfunction(f)
+        if is_generator:
+            raise ValueError("Modal closed handlers cannot be async generators")
+        fn.metadata.plugin_actions.modal_closures.append(ModalConfig(callback_id=callback_id))
+        return fn
+
+    return modal_closed_decorator
+
+
 def schedule(
     year: int | str | None = None,
     month: int | str | None = None,
diff --git a/machine/plugins/message.py b/machine/plugins/message.py
index c1159b6f..0f03cebb 100644
--- a/machine/plugins/message.py
+++ b/machine/plugins/message.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
+from collections.abc import Sequence
 from datetime import datetime
-from typing import Any, Sequence, cast
+from typing import Any, cast
 
 from slack_sdk.models.attachments import Attachment
 from slack_sdk.models.blocks import Block
diff --git a/machine/plugins/metadata.py b/machine/plugins/metadata.py
new file mode 100644
index 00000000..11835fda
--- /dev/null
+++ b/machine/plugins/metadata.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, Union
+
+
+@dataclass
+class MatcherConfig:
+    regex: re.Pattern[str]
+    handle_changed_message: bool = False
+
+
+@dataclass
+class CommandConfig:
+    command: str
+    is_generator: bool = False
+
+
+@dataclass
+class ActionConfig:
+    action_id: Union[re.Pattern[str], str, None] = None
+    block_id: Union[re.Pattern[str], str, None] = None
+
+
+@dataclass
+class ModalConfig:
+    callback_id: Union[re.Pattern[str], str]
+    is_generator: bool = False
+
+
+@dataclass
+class PluginActions:
+    process: list[str] = field(default_factory=list)
+    listen_to: list[MatcherConfig] = field(default_factory=list)
+    respond_to: list[MatcherConfig] = field(default_factory=list)
+    schedule: dict[str, Any] | None = None
+    commands: list[CommandConfig] = field(default_factory=list)
+    actions: list[ActionConfig] = field(default_factory=list)
+    modal_submissions: list[ModalConfig] = field(default_factory=list)
+    modal_closures: list[ModalConfig] = field(default_factory=list)
+
+
+@dataclass
+class Metadata:
+    plugin_actions: PluginActions = field(default_factory=PluginActions)
+    required_settings: list[str] = field(default_factory=list)
diff --git a/machine/plugins/modals.py b/machine/plugins/modals.py
new file mode 100644
index 00000000..8b88d33c
--- /dev/null
+++ b/machine/plugins/modals.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+from slack_sdk.models.attachments import Attachment
+from slack_sdk.models.blocks import Block
+from slack_sdk.models.views import View as SlackSDKView
+from slack_sdk.web.async_slack_response import AsyncSlackResponse
+
+from machine.clients.slack import SlackClient
+from machine.models import User
+from machine.models.interactive import View, ViewClosedPayload, ViewSubmissionPayload
+
+
+class ModalSubmission:
+    payload: ViewSubmissionPayload
+
+    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 | SlackSDKView,
+        **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 | SlackSDKView,
+        **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 | SlackSDKView,
+        **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
+
+    def __init__(self, client: SlackClient, payload: ViewClosedPayload):
+        self._client = client
+        self.payload = payload
+
+    @property
+    def user(self) -> User:
+        """The user that closed the modal
+
+        :return: the user that closed the modal
+        """
+        return self._client.users[self.payload.user.id]
+
+    @property
+    def view(self) -> View:
+        """The view that was closed including the state of all the elements in the view when it was closed
+
+        :return: the view that was closed
+        """
+        return self.payload.view
+
+    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 closed the modal
+
+        Send a Direct Message to the user that closed 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)
diff --git a/machine/settings.py b/machine/settings.py
index efd6fcb3..8082a185 100644
--- a/machine/settings.py
+++ b/machine/settings.py
@@ -1,6 +1,5 @@
 import os
 from importlib import import_module
-from typing import Tuple
 
 from structlog.stdlib import get_logger
 
@@ -9,7 +8,7 @@
 logger = get_logger(__name__)
 
 
-def import_settings(settings_module: str = "local_settings") -> Tuple[CaseInsensitiveDict, bool]:
+def import_settings(settings_module: str = "local_settings") -> tuple[CaseInsensitiveDict, bool]:
     default_settings = {
         "PLUGINS": [
             "machine.plugins.builtin.general.PingPongPlugin",
diff --git a/machine/storage/backends/base.py b/machine/storage/backends/base.py
index 466f6dd2..4ff72ecc 100644
--- a/machine/storage/backends/base.py
+++ b/machine/storage/backends/base.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 
 
 class MachineBaseStorage(ABC):
diff --git a/machine/storage/backends/dynamodb.py b/machine/storage/backends/dynamodb.py
index 3fb3e05a..7b135519 100644
--- a/machine/storage/backends/dynamodb.py
+++ b/machine/storage/backends/dynamodb.py
@@ -4,8 +4,9 @@
 import calendar
 import datetime
 import typing
+from collections.abc import Mapping
 from contextlib import AsyncExitStack
-from typing import Any, Mapping, cast
+from typing import Any, cast
 
 import aioboto3
 from botocore.exceptions import ClientError
diff --git a/machine/storage/backends/memory.py b/machine/storage/backends/memory.py
index 0ef3ae45..ee208ea0 100644
--- a/machine/storage/backends/memory.py
+++ b/machine/storage/backends/memory.py
@@ -1,8 +1,9 @@
 from __future__ import annotations
 
 import sys
+from collections.abc import Mapping
 from datetime import datetime, timedelta
-from typing import Any, Mapping
+from typing import Any
 
 from machine.storage.backends.base import MachineBaseStorage
 
diff --git a/machine/storage/backends/redis.py b/machine/storage/backends/redis.py
index b0634917..9da44fc6 100644
--- a/machine/storage/backends/redis.py
+++ b/machine/storage/backends/redis.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 
 from redis.asyncio import Redis
 
diff --git a/machine/storage/backends/sqlite.py b/machine/storage/backends/sqlite.py
index 5f95c82a..3b0fa685 100644
--- a/machine/storage/backends/sqlite.py
+++ b/machine/storage/backends/sqlite.py
@@ -1,7 +1,8 @@
 from __future__ import annotations
 
 import time
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 
 import aiosqlite
 
diff --git a/machine/utils/collections.py b/machine/utils/collections.py
index 564886cf..40ca226c 100644
--- a/machine/utils/collections.py
+++ b/machine/utils/collections.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from typing import Iterable, Iterator, Mapping, MutableMapping, TypeVar, cast
+from collections.abc import Iterable, Iterator, Mapping, MutableMapping
+from typing import TypeVar, cast
 
 KT = TypeVar("KT", bound=str)
 VT = TypeVar("VT")
diff --git a/machine/utils/logging.py b/machine/utils/logging.py
index 64b02343..609e31e1 100644
--- a/machine/utils/logging.py
+++ b/machine/utils/logging.py
@@ -2,7 +2,8 @@
 
 import logging
 import sys
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 
 import structlog
 from structlog.processors import CallsiteParameter
diff --git a/machine/utils/module_loading.py b/machine/utils/module_loading.py
index 8adcff46..c84aa893 100644
--- a/machine/utils/module_loading.py
+++ b/machine/utils/module_loading.py
@@ -1,9 +1,8 @@
 import inspect
 from importlib import import_module
-from typing import List, Tuple, Type
 
 
-def import_string(dotted_path: str) -> List[Tuple[str, Type]]:
+def import_string(dotted_path: str) -> list[tuple[str, type]]:
     """
     Import all Classes from the module specified by
     the dotted_path. If dotted_path is not a module, try
diff --git a/machine/utils/redis.py b/machine/utils/redis.py
index 79dc34d3..7d11449d 100644
--- a/machine/utils/redis.py
+++ b/machine/utils/redis.py
@@ -1,4 +1,5 @@
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 from urllib.parse import urlparse
 
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 815f4b8f..0c84101d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -38,6 +38,7 @@ nav:
       - 'plugins/slash-commands.md'
       - 'plugins/interacting.md'
       - 'plugins/block-kit-actions.md'
+      - 'plugins/modals.md'
       - 'plugins/settings.md'
       - 'plugins/storage.md'
       - 'plugins/misc.md'
@@ -67,4 +68,3 @@ plugins:
             members_order: source
           import:
             - https://docs.python.org/dev/objects.inv
-            - https://slack.dev/python-slack-sdk/objects.inv
diff --git a/poetry.lock b/poetry.lock
index 32756879..53cee4ac 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
 
 [[package]]
 name = "aioboto3"
@@ -65,102 +65,87 @@ files = [
 
 [[package]]
 name = "aiohttp"
-version = "3.10.11"
+version = "3.11.6"
 description = "Async http client/server framework (asyncio)"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e"},
-    {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298"},
-    {file = "aiohttp-3.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177"},
-    {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217"},
-    {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a"},
-    {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a"},
-    {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115"},
-    {file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a"},
-    {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3"},
-    {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038"},
-    {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519"},
-    {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc"},
-    {file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d"},
-    {file = "aiohttp-3.10.11-cp310-cp310-win32.whl", hash = "sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120"},
-    {file = "aiohttp-3.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674"},
-    {file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07"},
-    {file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695"},
-    {file = "aiohttp-3.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24"},
-    {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382"},
-    {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa"},
-    {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625"},
-    {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9"},
-    {file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac"},
-    {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a"},
-    {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b"},
-    {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16"},
-    {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730"},
-    {file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8"},
-    {file = "aiohttp-3.10.11-cp311-cp311-win32.whl", hash = "sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9"},
-    {file = "aiohttp-3.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f"},
-    {file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710"},
-    {file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d"},
-    {file = "aiohttp-3.10.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97"},
-    {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725"},
-    {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636"},
-    {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385"},
-    {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087"},
-    {file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f"},
-    {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03"},
-    {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d"},
-    {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a"},
-    {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e"},
-    {file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4"},
-    {file = "aiohttp-3.10.11-cp312-cp312-win32.whl", hash = "sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb"},
-    {file = "aiohttp-3.10.11-cp312-cp312-win_amd64.whl", hash = "sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27"},
-    {file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127"},
-    {file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413"},
-    {file = "aiohttp-3.10.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461"},
-    {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288"},
-    {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067"},
-    {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e"},
-    {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1"},
-    {file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006"},
-    {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f"},
-    {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6"},
-    {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31"},
-    {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d"},
-    {file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00"},
-    {file = "aiohttp-3.10.11-cp313-cp313-win32.whl", hash = "sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71"},
-    {file = "aiohttp-3.10.11-cp313-cp313-win_amd64.whl", hash = "sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e"},
-    {file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2"},
-    {file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339"},
-    {file = "aiohttp-3.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95"},
-    {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92"},
-    {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7"},
-    {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d"},
-    {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca"},
-    {file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa"},
-    {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b"},
-    {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658"},
-    {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39"},
-    {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9"},
-    {file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7"},
-    {file = "aiohttp-3.10.11-cp38-cp38-win32.whl", hash = "sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4"},
-    {file = "aiohttp-3.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec"},
-    {file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106"},
-    {file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6"},
-    {file = "aiohttp-3.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01"},
-    {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e"},
-    {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829"},
-    {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8"},
-    {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc"},
-    {file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa"},
-    {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b"},
-    {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138"},
-    {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777"},
-    {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261"},
-    {file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f"},
-    {file = "aiohttp-3.10.11-cp39-cp39-win32.whl", hash = "sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9"},
-    {file = "aiohttp-3.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb"},
-    {file = "aiohttp-3.10.11.tar.gz", hash = "sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7"},
+    {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7510b3ca2275691875ddf072a5b6cd129278d11fe09301add7d292fc8d3432de"},
+    {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfab0d2c3380c588fc925168533edb21d3448ad76c3eadc360ff963019161724"},
+    {file = "aiohttp-3.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf02dba0f342f3a8228f43fae256aafc21c4bc85bffcf537ce4582e2b1565188"},
+    {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92daedf7221392e7a7984915ca1b0481a94c71457c2f82548414a41d65555e70"},
+    {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2274a7876e03429e3218589a6d3611a194bdce08c3f1e19962e23370b47c0313"},
+    {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a2e1eae2d2f62f3660a1591e16e543b2498358593a73b193006fb89ee37abc6"},
+    {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978ec3fb0a42efcd98aae608f58c6cfcececaf0a50b4e86ee3ea0d0a574ab73b"},
+    {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51f87b27d9219ed4e202ed8d6f1bb96f829e5eeff18db0d52f592af6de6bdbf"},
+    {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:04d1a02a669d26e833c8099992c17f557e3b2fdb7960a0c455d7b1cbcb05121d"},
+    {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3679d5fcbc7f1ab518ab4993f12f80afb63933f6afb21b9b272793d398303b98"},
+    {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a4b24e03d04893b5c8ec9cd5f2f11dc9c8695c4e2416d2ac2ce6c782e4e5ffa5"},
+    {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d9abdfd35ecff1c95f270b7606819a0e2de9e06fa86b15d9080de26594cf4c23"},
+    {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b5c3e7928a0ad80887a5eba1c1da1830512ddfe7394d805badda45c03db3109"},
+    {file = "aiohttp-3.11.6-cp310-cp310-win32.whl", hash = "sha256:913dd9e9378f3c38aeb5c4fb2b8383d6490bc43f3b427ae79f2870651ae08f22"},
+    {file = "aiohttp-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:4ac26d482c2000c3a59bf757a77adc972828c9d4177b4bd432a46ba682ca7271"},
+    {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26ac4c960ea8debf557357a172b3ef201f2236a462aefa1bc17683a75483e518"},
+    {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b1f13ebc99fb98c7c13057b748f05224ccc36d17dee18136c695ef23faaf4ff"},
+    {file = "aiohttp-3.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4679f1a47516189fab1774f7e45a6c7cac916224c91f5f94676f18d0b64ab134"},
+    {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74491fdb3d140ff561ea2128cb7af9ba0a360067ee91074af899c9614f88a18f"},
+    {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f51e1a90412d387e62aa2d243998c5eddb71373b199d811e6ed862a9f34f9758"},
+    {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72ab89510511c3bb703d0bb5504787b11e0ed8be928ed2a7cf1cda9280628430"},
+    {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6681c9e046d99646e8059266688374a063da85b2e4c0ebfa078cda414905d080"},
+    {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a17f8a6d3ab72cbbd137e494d1a23fbd3ea973db39587941f32901bb3c5c350"},
+    {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:867affc7612a314b95f74d93aac550ce0909bc6f0b6c658cc856890f4d326542"},
+    {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00d894ebd609d5a423acef885bd61e7f6a972153f99c5b3ea45fc01fe909196c"},
+    {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:614c87be9d0d64477d1e4b663bdc5d1534fc0a7ebd23fb08347ab9fd5fe20fd7"},
+    {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:533ed46cf772f28f3bffae81c0573d916a64dee590b5dfaa3f3d11491da05b95"},
+    {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589884cfbc09813afb1454816b45677e983442e146183143f988f7f5a040791a"},
+    {file = "aiohttp-3.11.6-cp311-cp311-win32.whl", hash = "sha256:1da63633ba921669eec3d7e080459d4ceb663752b3dafb2f31f18edd248d2170"},
+    {file = "aiohttp-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:d778ddda09622e7d83095cc8051698a0084c155a1474bfee9bac27d8613dbc31"},
+    {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:943a952df105a5305257984e7a1f5c2d0fd8564ff33647693c4d07eb2315446d"},
+    {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d24ec28b7658970a1f1d98608d67f88376c7e503d9d45ff2ba1949c09f2b358c"},
+    {file = "aiohttp-3.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6720e809a660fdb9bec7c168c582e11cfedce339af0a5ca847a5d5b588dce826"},
+    {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4252d30da0ada6e6841b325869c7ef5104b488e8dd57ec439892abbb8d7b3615"},
+    {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f65f43ff01b238aa0b5c47962c83830a49577efe31bd37c1400c3d11d8a32835"},
+    {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc5933f6c9b26404444d36babb650664f984b8e5fa0694540e7b7315d11a4ff"},
+    {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bf546ba0c029dfffc718c4b67748687fd4f341b07b7c8f1719d6a3a46164798"},
+    {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c351d05bbeae30c088009c0bb3b17dda04fd854f91cc6196c448349cc98f71c3"},
+    {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:10499079b063576fad1597898de3f9c0a2ce617c19cc7cd6b62fdcff6b408bf7"},
+    {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:442ee82eda47dd59798d6866ce020fb8d02ea31ac9ac82b3d719ed349e6a9d52"},
+    {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:86fce9127bc317119b34786d9e9ae8af4508a103158828a535f56d201da6ab19"},
+    {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:973d26a5537ce5d050302eb3cd876457451745b1da0624cbb483217970e12567"},
+    {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532b8f038a4e001137d3600cea5d3439d1881df41bdf44d0f9651264d562fdf0"},
+    {file = "aiohttp-3.11.6-cp312-cp312-win32.whl", hash = "sha256:4863c59f748dbe147da82b389931f2a676aebc9d3419813ed5ca32d057c9cb32"},
+    {file = "aiohttp-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:5d7f481f82c18ac1f7986e31ba6eea9be8b2e2c86f1ef035b6866179b6c5dd68"},
+    {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40f502350496ba4c6820816d3164f8a0297b9aa4e95d910da31beb189866a9df"},
+    {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9072669b0bffb40f1f6977d0b5e8a296edc964f9cefca3a18e68649c214d0ce3"},
+    {file = "aiohttp-3.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:518160ecf4e6ffd61715bc9173da0925fcce44ae6c7ca3d3f098fe42585370fb"},
+    {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69cc1b45115ac44795b63529aa5caa9674be057f11271f65474127b24fc1ce6"},
+    {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6be90a6beced41653bda34afc891617c6d9e8276eef9c183f029f851f0a3c3d"},
+    {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00c22fe2486308770d22ef86242101d7b0f1e1093ce178f2358f860e5149a551"},
+    {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2607ebb783e3aeefa017ec8f34b506a727e6b6ab2c4b037d65f0bc7151f4430a"},
+    {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f761d6819870c2a8537f75f3e2fc610b163150cefa01f9f623945840f601b2c"},
+    {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e44d1bc6c88f5234115011842219ba27698a5f2deee245c963b180080572aaa2"},
+    {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e0cb6a1b1f499cb2aa0bab1c9f2169ad6913c735b7447e058e0c29c9e51c0b5"},
+    {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a76b4d4ca34254dca066acff2120811e2a8183997c135fcafa558280f2cc53f3"},
+    {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69051c1e45fb18c0ae4d39a075532ff0b015982e7997f19eb5932eb4a3e05c17"},
+    {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aff2ed18274c0bfe0c1d772781c87d5ca97ae50f439729007cec9644ee9b15fe"},
+    {file = "aiohttp-3.11.6-cp313-cp313-win32.whl", hash = "sha256:2fbea25f2d44df809a46414a8baafa5f179d9dda7e60717f07bded56300589b3"},
+    {file = "aiohttp-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f77bc29a465c0f9f6573d1abe656d385fa673e34efe615bd4acc50899280ee47"},
+    {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:de6123b298d17bca9e53581f50a275b36e10d98e8137eb743ce69ee766dbdfe9"},
+    {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a10200f705f4fff00e148b7f41e5d1d929c7cd4ac523c659171a0ea8284cd6fb"},
+    {file = "aiohttp-3.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7776ef6901b54dd557128d96c71e412eec0c39ebc07567e405ac98737995aad"},
+    {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e5c2a55583cd91936baf73d223807bb93ace6eb1fe54424782690f2707162ab"},
+    {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b032bd6cf7422583bf44f233f4a1489fee53c6d35920123a208adc54e2aba41e"},
+    {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fe2d99acbc5cf606f75d7347bf3a027c24c27bc052d470fb156f4cfcea5739"},
+    {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84a79c366375c2250934d1238abe5d5ea7754c823a1c7df0c52bf0a2bfded6a9"},
+    {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33cbbe97dc94a34d1295a7bb68f82727bcbff2b284f73ae7e58ecc05903da97"},
+    {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:19e4fb9ac727834b003338dcdd27dcfe0de4fb44082b01b34ed0ab67c3469fc9"},
+    {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a97f6b2afbe1d27220c0c14ea978e09fb4868f462ef3d56d810d206bd2e057a2"},
+    {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c3f7afeea03a9bc49be6053dfd30809cd442cc12627d6ca08babd1c1f9e04ccf"},
+    {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0d10967600ce5bb69ddcb3e18d84b278efb5199d8b24c3c71a4959c2f08acfd0"},
+    {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:60f2f631b9fe7aa321fa0f0ff3f5d8b9f7f9b72afd4eecef61c33cf1cfea5d58"},
+    {file = "aiohttp-3.11.6-cp39-cp39-win32.whl", hash = "sha256:4d2b75333deb5c5f61bac5a48bba3dbc142eebbd3947d98788b6ef9cc48628ae"},
+    {file = "aiohttp-3.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:8908c235421972a2e02abcef87d16084aabfe825d14cc9a1debd609b3cfffbea"},
+    {file = "aiohttp-3.11.6.tar.gz", hash = "sha256:fd9f55c1b51ae1c20a1afe7216a64a88d38afee063baa23c7fce03757023c999"},
 ]
 
 [package.dependencies]
@@ -170,7 +155,8 @@ async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""}
 attrs = ">=17.3.0"
 frozenlist = ">=1.1.1"
 multidict = ">=4.5,<7.0"
-yarl = ">=1.12.0,<2.0"
+propcache = ">=0.2.0"
+yarl = ">=1.17.0,<2.0"
 
 [package.extras]
 speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
@@ -2373,29 +2359,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "ruff"
-version = "0.7.4"
+version = "0.8.0"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"},
-    {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"},
-    {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"},
-    {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"},
-    {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"},
-    {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"},
-    {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"},
+    {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"},
+    {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"},
+    {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"},
+    {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"},
+    {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"},
+    {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"},
+    {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"},
 ]
 
 [[package]]
@@ -3440,4 +3426,4 @@ sqlite = ["aiosqlite"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "0ad3424fdf37339ba383115e986902beb28f803e5baa5deda178eb75c410e42f"
+content-hash = "347abcd547496740cfd26a6c7ed6bd181b341f2d24d943100efdf60fcd552472"
diff --git a/pyproject.toml b/pyproject.toml
index 6c237be0..a8b98f77 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,7 @@ APScheduler = "^3.9.1"
 redis = {version = ">=4.3.4,<6.0.0", optional = true}
 hiredis = {version = ">=2,<4", optional = true}
 slack-sdk = "^3.18.1"
-aiohttp = ">=3.8.5,<3.11.0"
+aiohttp = "^3.8.5"
 pyee = ">=9.0.4,<13.0.0"
 httpx = ">=0.23,<0.28"
 aioboto3 = {version = ">=10,<14", optional = true}
@@ -67,7 +67,7 @@ types-redis = "^4.3.21"
 aioboto3 = ">=10,<14"
 types-aiobotocore = {extras = ["essential"], version = "^2.4.0"}
 aiosqlite = ">=0.18,<0.21"
-ruff = ">=0.4.3,<0.8.0"
+ruff = ">=0.8.0,<0.9.0"
 
 [tool.poetry.group.docs]
 optional = true
@@ -88,6 +88,7 @@ slack-machine = 'machine.bin.run:main'
 
 [tool.ruff]
 line-length = 120
+target-version = "py39"
 
 [tool.ruff.lint]
 exclude = [
@@ -126,7 +127,7 @@ mock_use_standalone_module = true
 addopts = "--verbose --cov-report term-missing --cov-report xml --junit-xml pytest.xml --cov=machine"
 
 [tool.mypy]
-python_version = "3.12"
+python_version = "3.13"
 ignore_missing_imports = true
 show_column_numbers = true
 show_error_codes = true
diff --git a/tests/clients/test_slack_client.py b/tests/clients/test_slack_client.py
index e941c968..8353f1aa 100644
--- a/tests/clients/test_slack_client.py
+++ b/tests/clients/test_slack_client.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-import sys
 from typing import Any
+from zoneinfo import ZoneInfo
 
 import pytest
 from slack_sdk.socket_mode.aiohttp import SocketModeClient
@@ -12,11 +12,6 @@
 from machine.models.channel import Channel
 from machine.models.user import User
 
-if sys.version_info >= (3, 9):
-    from zoneinfo import ZoneInfo
-else:
-    from backports.zoneinfo import ZoneInfo
-
 # TODO: more tests
 
 
diff --git a/tests/fake_plugins.py b/tests/fake_plugins.py
index 0f207b52..2d2c7980 100644
--- a/tests/fake_plugins.py
+++ b/tests/fake_plugins.py
@@ -1,7 +1,7 @@
 import re
 
 from machine.plugins.base import MachineBasePlugin
-from machine.plugins.decorators import action, command, listen_to, process, respond_to
+from machine.plugins.decorators import action, command, listen_to, modal, modal_closed, process, respond_to
 
 
 class FakePlugin(MachineBasePlugin):
@@ -29,6 +29,18 @@ async def generator_command_function(self, payload):
     async def block_action_function(self, payload):
         pass
 
+    @modal(callback_id=re.compile(r"my_modal.*", re.IGNORECASE))
+    async def modal_function(self, payload):
+        pass
+
+    @modal(callback_id="my_generator_modal")
+    async def generator_modal_function(self, payload):
+        yield "hello"
+
+    @modal_closed(callback_id="my_modal_2")
+    async def modal_closed_function(self, payload):
+        pass
+
 
 class FakePlugin2(MachineBasePlugin):
     async def init(self):
diff --git a/tests/handlers/conftest.py b/tests/handlers/conftest.py
index b5922eec..9d70192f 100644
--- a/tests/handlers/conftest.py
+++ b/tests/handlers/conftest.py
@@ -3,10 +3,9 @@
 
 import pytest
 from slack_sdk.socket_mode.aiohttp import SocketModeClient
-from slack_sdk.socket_mode.request import SocketModeRequest
 
 from machine.clients.slack import SlackClient
-from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions
+from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, ModalHandler, RegisteredActions
 from machine.storage import MachineBaseStorage
 from machine.utils.collections import CaseInsensitiveDict
 from tests.fake_plugins import FakePlugin
@@ -36,6 +35,9 @@ def fake_plugin(mocker, slack_client, storage):
     mocker.spy(plugin_instance, "command_function")
     mocker.spy(plugin_instance, "generator_command_function")
     mocker.spy(plugin_instance, "block_action_function")
+    mocker.spy(plugin_instance, "modal_function")
+    mocker.spy(plugin_instance, "generator_modal_function")
+    mocker.spy(plugin_instance, "modal_closed_function")
     return plugin_instance
 
 
@@ -46,6 +48,10 @@ def plugin_actions(fake_plugin):
     process_fn = fake_plugin.process_function
     command_fn = fake_plugin.command_function
     generator_command_fn = fake_plugin.generator_command_function
+    block_action_fn = fake_plugin.block_action_function
+    modal_fn = fake_plugin.modal_function
+    generator_modal_fn = fake_plugin.generator_modal_function
+    modal_closed_fn = fake_plugin.modal_closed_function
     plugin_actions = RegisteredActions(
         listen_to={
             "TestPlugin.listen_function-hi": MessageHandler(
@@ -90,16 +96,39 @@ def plugin_actions(fake_plugin):
             "TestPlugin.block_action_function-my_action.*-my_block": BlockActionHandler(
                 class_=fake_plugin,
                 class_name="tests.fake_plugins.FakePlugin",
-                function=fake_plugin.block_action_function,
-                function_signature=Signature.from_callable(fake_plugin.block_action_function),
+                function=block_action_fn,
+                function_signature=Signature.from_callable(block_action_fn),
                 action_id_matcher=re.compile("my_action.*", re.IGNORECASE),
                 block_id_matcher="my_block",
             )
         },
+        modal={
+            "TestPlugin.modal_function-my_modal.*": ModalHandler(
+                class_=fake_plugin,
+                class_name="tests.fake_plugins.FakePlugin",
+                function=modal_fn,
+                function_signature=Signature.from_callable(modal_fn),
+                callback_id_matcher=re.compile("my_modal.*", re.IGNORECASE),
+                is_generator=False,
+            ),
+            "TestPlugin.generator_modal_function-my_generator_modal": ModalHandler(
+                class_=fake_plugin,
+                class_name="tests.fake_plugins.FakePlugin",
+                function=generator_modal_fn,
+                function_signature=Signature.from_callable(generator_modal_fn),
+                callback_id_matcher="my_generator_modal",
+                is_generator=True,
+            ),
+        },
+        modal_closed={
+            "TestPlugin.modal_closed_function-my_modal_2": ModalHandler(
+                class_=fake_plugin,
+                class_name="tests.fake_plugins.FakePlugin",
+                function=modal_closed_fn,
+                function_signature=Signature.from_callable(modal_closed_fn),
+                callback_id_matcher="my_modal_2",
+                is_generator=False,
+            )
+        },
     )
     return plugin_actions
-
-
-def gen_command_request(command: str, text: str):
-    payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"}
-    return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload)
diff --git a/tests/handlers/requests.py b/tests/handlers/requests.py
new file mode 100644
index 00000000..7f587755
--- /dev/null
+++ b/tests/handlers/requests.py
@@ -0,0 +1,173 @@
+from slack_sdk.socket_mode.request import SocketModeRequest
+
+
+def gen_command_request(command: str, text: str):
+    payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"}
+    return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload)
+
+
+def _gen_block_action_request(action_id, block_id):
+    payload = {
+        "type": "block_actions",
+        "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"},
+        "api_app_id": "A12345678",
+        "token": "verification_token",
+        "container": {
+            "type": "message",
+            "message_ts": "1234567890.123456",
+            "channel_id": "C12345678",
+            "is_ephemeral": False,
+        },
+        "channel": {"id": "C12345678", "name": "channel-name"},
+        "message": {
+            "type": "message",
+            "user": "U87654321",
+            "ts": "1234567890.123456",
+            "bot_id": "B12345678",
+            "app_id": "A12345678",
+            "text": "Hello, world!",
+            "team": "T12345678",
+            "blocks": [
+                {
+                    "type": "actions",
+                    "block_id": block_id,
+                    "elements": [
+                        {
+                            "type": "button",
+                            "action_id": action_id,
+                            "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True},
+                            "style": "primary",
+                            "value": "U12345678",
+                        },
+                    ],
+                },
+            ],
+        },
+        "state": {"values": {}},
+        "trigger_id": "1234567890.123456",
+        "team": {"id": "T12345678", "domain": "workspace-domain"},
+        "enterprise": None,
+        "is_enterprise_install": False,
+        "actions": [
+            {
+                "type": "button",
+                "action_id": action_id,
+                "block_id": block_id,
+                "action_ts": "1234567890.123456",
+                "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True},
+                "value": "U12345678",
+                "style": "primary",
+            }
+        ],
+        "response_url": "https://hooks.slack.com/actions/T12345678/1234567890/1234567890",
+    }
+    return SocketModeRequest(type="interactive", envelope_id="x", payload=payload)
+
+
+def _gen_view_submission_request(callback_id):
+    payload = {
+        "type": "view_submission",
+        "team": {"id": "T12345678", "domain": "workspace-domain"},
+        "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"},
+        "api_app_id": "A12345678",
+        "token": "verification_token",
+        "trigger_id": "1234567890.123456",
+        "enterprise": None,
+        "is_enterprise_install": False,
+        "response_urls": [],
+        "view": {
+            "id": "V1234567890",
+            "team_id": "T12345678",
+            "type": "modal",
+            "blocks": [
+                {
+                    "type": "header",
+                    "block_id": "k3dNV",
+                    "text": {"type": "plain_text", "text": "What do you want?", "emoji": True},
+                },
+                {
+                    "type": "input",
+                    "block_id": "modal_input",
+                    "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True},
+                    "optional": False,
+                    "dispatch_action": False,
+                    "element": {
+                        "type": "plain_text_input",
+                        "action_id": "opinion",
+                        "multiline": True,
+                        "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]},
+                    },
+                },
+            ],
+            "private_metadata": "",
+            "callback_id": callback_id,
+            "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}},
+            "hash": "1717180005.lGLYVzOE",
+            "title": {"type": "plain_text", "text": "My App", "emoji": True},
+            "clear_on_close": False,
+            "notify_on_close": True,
+            "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True},
+            "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True},
+            "previous_view_id": None,
+            "root_view_id": "V1234567890",
+            "app_id": "A12345678",
+            "external_id": "",
+            "app_installed_team_id": "T12345678",
+            "bot_id": "B1234567890",
+        },
+    }
+    return SocketModeRequest(type="interactive", envelope_id="x", payload=payload)
+
+
+def _gen_view_closed_request(callback_id):
+    payload = {
+        "type": "view_closed",
+        "team": {"id": "T12345678", "domain": "workspace-domain"},
+        "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"},
+        "api_app_id": "A12345678",
+        "token": "verification_token",
+        "enterprise": None,
+        "is_enterprise_install": False,
+        "is_cleared": True,
+        "view": {
+            "id": "V1234567890",
+            "team_id": "T12345678",
+            "type": "modal",
+            "blocks": [
+                {
+                    "type": "header",
+                    "block_id": "k3dNV",
+                    "text": {"type": "plain_text", "text": "What do you want?", "emoji": True},
+                },
+                {
+                    "type": "input",
+                    "block_id": "modal_input",
+                    "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True},
+                    "optional": False,
+                    "dispatch_action": False,
+                    "element": {
+                        "type": "plain_text_input",
+                        "action_id": "opinion",
+                        "multiline": True,
+                        "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]},
+                    },
+                },
+            ],
+            "private_metadata": "",
+            "callback_id": callback_id,
+            "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}},
+            "hash": "1717180005.lGLYVzOE",
+            "title": {"type": "plain_text", "text": "My App", "emoji": True},
+            "clear_on_close": False,
+            "notify_on_close": True,
+            "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True},
+            "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True},
+            "previous_view_id": None,
+            "root_view_id": "V1234567890",
+            "app_id": "A12345678",
+            "external_id": "",
+            "app_installed_team_id": "T12345678",
+            "bot_id": "B1234567890",
+        },
+    }
+    return SocketModeRequest(type="interactive", envelope_id="x", payload=payload)
diff --git a/tests/handlers/test_command_handler.py b/tests/handlers/test_command_handler.py
index 5db510d0..4becd130 100644
--- a/tests/handlers/test_command_handler.py
+++ b/tests/handlers/test_command_handler.py
@@ -2,7 +2,7 @@
 
 from machine.handlers import create_slash_command_handler
 from machine.plugins.command import Command
-from tests.handlers.conftest import gen_command_request
+from tests.handlers.requests import gen_command_request
 
 
 def _assert_command(args, command, text):
diff --git a/tests/handlers/test_interactive_handler.py b/tests/handlers/test_interactive_handler.py
index beb7c6d0..c1df00ec 100644
--- a/tests/handlers/test_interactive_handler.py
+++ b/tests/handlers/test_interactive_handler.py
@@ -1,68 +1,11 @@
 import re
 
 import pytest
-from slack_sdk.socket_mode.request import SocketModeRequest
 
 from machine.handlers.interactive_handler import _matches, create_interactive_handler
 from machine.plugins.block_action import BlockAction
-
-
-def _gen_block_action_request(action_id, block_id):
-    payload = {
-        "type": "block_actions",
-        "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"},
-        "api_app_id": "A12345678",
-        "token": "verification_token",
-        "container": {
-            "type": "message",
-            "message_ts": "1234567890.123456",
-            "channel_id": "C12345678",
-            "is_ephemeral": False,
-        },
-        "channel": {"id": "C12345678", "name": "channel-name"},
-        "message": {
-            "type": "message",
-            "user": "U87654321",
-            "ts": "1234567890.123456",
-            "bot_id": "B12345678",
-            "app_id": "A12345678",
-            "text": "Hello, world!",
-            "team": "T12345678",
-            "blocks": [
-                {
-                    "type": "actions",
-                    "block_id": block_id,
-                    "elements": [
-                        {
-                            "type": "button",
-                            "action_id": action_id,
-                            "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True},
-                            "style": "primary",
-                            "value": "U12345678",
-                        },
-                    ],
-                },
-            ],
-        },
-        "state": {"values": {}},
-        "trigger_id": "1234567890.123456",
-        "team": {"id": "T12345678", "domain": "workspace-domain"},
-        "enterprise": None,
-        "is_enterprise_install": False,
-        "actions": [
-            {
-                "type": "button",
-                "action_id": action_id,
-                "block_id": block_id,
-                "action_ts": "1234567890.123456",
-                "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True},
-                "value": "U12345678",
-                "style": "primary",
-            }
-        ],
-        "response_url": "https://hooks.slack.com/actions/T12345678/1234567890/1234567890",
-    }
-    return SocketModeRequest(type="interactive", envelope_id="x", payload=payload)
+from machine.plugins.modals import ModalClosure, ModalSubmission
+from tests.handlers.requests import _gen_block_action_request, _gen_view_closed_request, _gen_view_submission_request
 
 
 def test_matches():
@@ -89,3 +32,58 @@ async def test_create_interactive_handler_for_block_actions(
     resp = socket_mode_client.send_socket_mode_response.call_args.args[0]
     assert resp.envelope_id == "x"
     assert resp.payload is None
+
+
+@pytest.mark.asyncio
+async def test_create_interactive_handler_for_view_submission(
+    plugin_actions, fake_plugin, socket_mode_client, slack_client
+):
+    handler = create_interactive_handler(plugin_actions, slack_client)
+    request = _gen_view_submission_request("my_modal_1")
+    await handler(socket_mode_client, request)
+    assert fake_plugin.modal_function.call_count == 1
+    args = fake_plugin.modal_function.call_args
+    assert isinstance(args[0][0], ModalSubmission)
+    assert args[0][0].payload.view.callback_id == "my_modal_1"
+    socket_mode_client.send_socket_mode_response.assert_called_once()
+    resp = socket_mode_client.send_socket_mode_response.call_args.args[0]
+    assert resp.envelope_id == "x"
+    assert resp.payload is None
+    assert fake_plugin.generator_modal_function.call_count == 0
+
+
+@pytest.mark.asyncio
+async def test_create_interactive_handler_for_view_submission_generator(
+    plugin_actions, fake_plugin, socket_mode_client, slack_client
+):
+    handler = create_interactive_handler(plugin_actions, slack_client)
+    request = _gen_view_submission_request("my_generator_modal")
+    await handler(socket_mode_client, request)
+    assert fake_plugin.generator_modal_function.call_count == 1
+    args = fake_plugin.generator_modal_function.call_args
+    assert isinstance(args[0][0], ModalSubmission)
+    assert args[0][0].payload.view.callback_id == "my_generator_modal"
+    socket_mode_client.send_socket_mode_response.assert_called_once()
+    resp = socket_mode_client.send_socket_mode_response.call_args.args[0]
+    assert resp.envelope_id == "x"
+    assert resp.payload == {"text": "hello"}
+    assert fake_plugin.modal_function.call_count == 0
+
+
+@pytest.mark.asyncio
+async def test_create_interactive_handler_for_view_closed(
+    plugin_actions, fake_plugin, socket_mode_client, slack_client
+):
+    handler = create_interactive_handler(plugin_actions, slack_client)
+    request = _gen_view_closed_request("my_modal_2")
+    await handler(socket_mode_client, request)
+    assert fake_plugin.modal_closed_function.call_count == 1
+    args = fake_plugin.modal_closed_function.call_args
+    assert isinstance(args[0][0], ModalClosure)
+    assert args[0][0].payload.view.callback_id == "my_modal_2"
+    socket_mode_client.send_socket_mode_response.assert_called_once()
+    resp = socket_mode_client.send_socket_mode_response.call_args.args[0]
+    assert resp.envelope_id == "x"
+    assert resp.payload is None
+    assert fake_plugin.modal_function.call_count == 0
+    assert fake_plugin.generator_modal_function.call_count == 0
diff --git a/tests/handlers/test_logging.py b/tests/handlers/test_logging.py
index 3b8ae886..4a33317d 100644
--- a/tests/handlers/test_logging.py
+++ b/tests/handlers/test_logging.py
@@ -2,7 +2,7 @@
 from structlog.testing import capture_logs
 
 from machine.handlers import log_request
-from tests.handlers.conftest import gen_command_request
+from tests.handlers.requests import gen_command_request
 
 
 @pytest.mark.asyncio
diff --git a/tests/models/example_payloads/view_closed.py b/tests/models/example_payloads/view_closed.py
new file mode 100644
index 00000000..f18f9266
--- /dev/null
+++ b/tests/models/example_payloads/view_closed.py
@@ -0,0 +1,166 @@
+payload = {
+    "type": "view_closed",
+    "team": {"id": "TQSD32X16", "domain": "dandydev"},
+    "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"},
+    "api_app_id": "A039QKQ6G1E",
+    "token": "ZMBw88SmAGYGpwguEEH4bZ84",
+    "view": {
+        "id": "V075UPJMERW",
+        "team_id": "TQSD32X16",
+        "type": "modal",
+        "blocks": [
+            {
+                "type": "section",
+                "block_id": "JyQHG",
+                "text": {
+                    "type": "plain_text",
+                    "text": ":wave: Hey David!\n\nWe'd love to hear from you how we can make this place the best place you’ve ever worked.",  # noqa: E501
+                    "emoji": True,
+                },
+            },
+            {"type": "divider", "block_id": "PQxVr"},
+            {
+                "type": "input",
+                "block_id": "my_block_id",
+                "label": {"type": "plain_text", "text": "Select a channel to post the result on", "emoji": True},
+                "optional": True,
+                "dispatch_action": False,
+                "element": {
+                    "type": "conversations_select",
+                    "action_id": "my_action_id",
+                    "default_to_current_conversation": True,
+                    "response_url_enabled": True,
+                    "initial_conversation": "CQEUMSV7D",
+                },
+            },
+            {
+                "type": "input",
+                "block_id": "working_here",
+                "label": {"type": "plain_text", "text": "You enjoy working here at Pistachio & Co", "emoji": True},
+                "optional": False,
+                "dispatch_action": False,
+                "element": {
+                    "type": "radio_buttons",
+                    "action_id": "working_here_options",
+                    "options": [
+                        {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"},
+                        {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"},
+                        {
+                            "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True},
+                            "value": "3",
+                        },
+                        {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"},
+                        {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"},
+                    ],
+                },
+            },
+            {
+                "type": "input",
+                "block_id": "WQ3Jr",
+                "label": {"type": "plain_text", "text": "What do you want for our team weekly lunch?", "emoji": True},
+                "optional": False,
+                "dispatch_action": False,
+                "element": {
+                    "type": "multi_static_select",
+                    "placeholder": {"type": "plain_text", "text": "Select your favorites", "emoji": True},
+                    "options": [
+                        {"text": {"type": "plain_text", "text": ":pizza: Pizza", "emoji": True}, "value": "value-0"},
+                        {
+                            "text": {"type": "plain_text", "text": ":fried_shrimp: Thai food", "emoji": True},
+                            "value": "value-1",
+                        },
+                        {
+                            "text": {"type": "plain_text", "text": ":desert_island: Hawaiian", "emoji": True},
+                            "value": "value-2",
+                        },
+                        {
+                            "text": {"type": "plain_text", "text": ":meat_on_bone: Texas BBQ", "emoji": True},
+                            "value": "value-3",
+                        },
+                        {
+                            "text": {"type": "plain_text", "text": ":hamburger: Burger", "emoji": True},
+                            "value": "value-4",
+                        },
+                        {"text": {"type": "plain_text", "text": ":taco: Tacos", "emoji": True}, "value": "value-5"},
+                        {
+                            "text": {"type": "plain_text", "text": ":green_salad: Salad", "emoji": True},
+                            "value": "value-6",
+                        },
+                        {"text": {"type": "plain_text", "text": ":stew: Indian", "emoji": True}, "value": "value-7"},
+                    ],
+                    "action_id": "+OG15",
+                },
+            },
+            {
+                "type": "input",
+                "block_id": "tzZoU",
+                "label": {
+                    "type": "plain_text",
+                    "text": "What can we do to improve your experience working here?",
+                    "emoji": True,
+                },
+                "optional": False,
+                "dispatch_action": False,
+                "element": {
+                    "type": "plain_text_input",
+                    "multiline": True,
+                    "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]},
+                    "action_id": "B+FPE",
+                },
+            },
+            {
+                "type": "input",
+                "block_id": "xpaXr",
+                "label": {"type": "plain_text", "text": "Anything else you want to tell us?", "emoji": True},
+                "optional": True,
+                "dispatch_action": False,
+                "element": {
+                    "type": "plain_text_input",
+                    "multiline": True,
+                    "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]},
+                    "action_id": "ME5+y",
+                },
+            },
+            {
+                "type": "section",
+                "block_id": "DZbIt",
+                "text": {"type": "mrkdwn", "text": "Pick a date for the deadline.", "verbatim": False},
+                "accessory": {
+                    "type": "datepicker",
+                    "action_id": "datepicker-action",
+                    "initial_date": "1990-04-28",
+                    "placeholder": {"type": "plain_text", "text": "Select a date", "emoji": True},
+                },
+            },
+        ],
+        "private_metadata": "",
+        "callback_id": "",
+        "state": {
+            "values": {
+                "my_block_id": {
+                    "my_action_id": {
+                        "type": "conversations_select",
+                        "selected_conversation": "CQEUMSV7D",
+                        "response_url_enabled": True,
+                    }
+                },
+                "DZbIt": {"datepicker-action": {"type": "datepicker", "selected_date": "1990-04-28"}},
+            }
+        },
+        "hash": "1716738939.7GIdWSjv",
+        "title": {"type": "plain_text", "text": "Workplace check-in", "emoji": True},
+        "clear_on_close": False,
+        "notify_on_close": True,
+        "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True},
+        "submit": {"type": "plain_text", "text": ":heart: Submit", "emoji": True},
+        "previous_view_id": None,
+        "root_view_id": "V075UPJMERW",
+        "app_id": "A039QKQ6G1E",
+        "external_id": "",
+        "app_installed_team_id": "TQSD32X16",
+        "bot_id": "B0390TCABQB",
+    },
+    "is_cleared": True,
+    "is_enterprise_install": False,
+    "enterprise": None,
+}
diff --git a/tests/models/test_block_actions.py b/tests/models/test_block_actions.py
index 90c4202f..4f52ce7d 100644
--- a/tests/models/test_block_actions.py
+++ b/tests/models/test_block_actions.py
@@ -1,4 +1,4 @@
-from machine.models.interactive import BlockActionsPayload
+from machine.models.interactive import BlockActionsPayload, InteractivePayload
 from tests.models.example_payloads.block_action_button import payload as button_payload
 from tests.models.example_payloads.block_action_button2 import payload as button_payload2
 from tests.models.example_payloads.block_action_button_no_value import payload as button_no_value_payload
@@ -18,68 +18,84 @@
 
 
 def test_block_action_radio_button():
-    validated_radio_button_payload = BlockActionsPayload.model_validate(radio_button_payload)
+    validated_radio_button_payload = InteractivePayload.validate_python(radio_button_payload)
     assert validated_radio_button_payload is not None
+    assert isinstance(validated_radio_button_payload, BlockActionsPayload)
 
 
 def test_block_action_button():
-    validated_button_payload = BlockActionsPayload.model_validate(button_payload)
+    validated_button_payload = InteractivePayload.validate_python(button_payload)
     assert validated_button_payload is not None
-    validated_button_payload2 = BlockActionsPayload.model_validate(button_payload2)
+    assert isinstance(validated_button_payload, BlockActionsPayload)
+    validated_button_payload2 = InteractivePayload.validate_python(button_payload2)
     assert validated_button_payload2 is not None
-    validated_button_no_value_payload = BlockActionsPayload.model_validate(button_no_value_payload)
+    assert isinstance(validated_button_payload2, BlockActionsPayload)
+    validated_button_no_value_payload = InteractivePayload.validate_python(button_no_value_payload)
     assert validated_button_no_value_payload is not None
+    assert isinstance(validated_button_no_value_payload, BlockActionsPayload)
 
 
 def test_block_action_checkboxes():
-    validated_checkboxes_payload = BlockActionsPayload.model_validate(checkboxes_payload)
+    validated_checkboxes_payload = InteractivePayload.validate_python(checkboxes_payload)
     assert validated_checkboxes_payload is not None
-    validated_checkboxes_payload2 = BlockActionsPayload.model_validate(checkboxes_payload2)
+    assert isinstance(validated_checkboxes_payload, BlockActionsPayload)
+    validated_checkboxes_payload2 = InteractivePayload.validate_python(checkboxes_payload2)
     assert validated_checkboxes_payload2 is not None
+    assert isinstance(validated_checkboxes_payload2, BlockActionsPayload)
 
 
 def test_block_action_datepicker():
-    validated_datepicker_payload = BlockActionsPayload.model_validate(datepicker_payload)
+    validated_datepicker_payload = InteractivePayload.validate_python(datepicker_payload)
     assert validated_datepicker_payload is not None
-    validated_datepicker_payload2 = BlockActionsPayload.model_validate(datepicker_payload2)
+    assert isinstance(validated_datepicker_payload, BlockActionsPayload)
+    validated_datepicker_payload2 = InteractivePayload.validate_python(datepicker_payload2)
     assert validated_datepicker_payload2 is not None
+    assert isinstance(validated_datepicker_payload2, BlockActionsPayload)
 
 
 def test_block_action_static_select():
-    validated_static_select_payload = BlockActionsPayload.model_validate(static_select_payload)
+    validated_static_select_payload = InteractivePayload.validate_python(static_select_payload)
     assert validated_static_select_payload is not None
+    assert isinstance(validated_static_select_payload, BlockActionsPayload)
 
 
 def test_block_action_conversations_select():
-    validated_conversations_select_payload = BlockActionsPayload.model_validate(conversations_select_payload)
+    validated_conversations_select_payload = InteractivePayload.validate_python(conversations_select_payload)
     assert validated_conversations_select_payload is not None
+    assert isinstance(validated_conversations_select_payload, BlockActionsPayload)
 
 
 def test_block_action_multi_static_select():
-    validated_multi_static_select_payload = BlockActionsPayload.model_validate(multi_static_select_payload)
+    validated_multi_static_select_payload = InteractivePayload.validate_python(multi_static_select_payload)
     assert validated_multi_static_select_payload is not None
+    assert isinstance(validated_multi_static_select_payload, BlockActionsPayload)
 
 
 def test_block_action_multi_channels_select():
-    validated_multi_channels_select_payload = BlockActionsPayload.model_validate(multi_channels_select_payload)
+    validated_multi_channels_select_payload = InteractivePayload.validate_python(multi_channels_select_payload)
     assert validated_multi_channels_select_payload is not None
+    assert isinstance(validated_multi_channels_select_payload, BlockActionsPayload)
 
 
 def test_block_action_timepicker():
-    validated_timepicker_payload = BlockActionsPayload.model_validate(timepicker_payload)
+    validated_timepicker_payload = InteractivePayload.validate_python(timepicker_payload)
     assert validated_timepicker_payload is not None
+    assert isinstance(validated_timepicker_payload, BlockActionsPayload)
 
 
 def test_block_action_url_input():
-    validated_url_payload = BlockActionsPayload.model_validate(url_payload)
+    validated_url_payload = InteractivePayload.validate_python(url_payload)
     assert validated_url_payload is not None
+    assert isinstance(validated_url_payload, BlockActionsPayload)
 
 
 def test_block_action_overflow():
-    validated_overflow_payload = BlockActionsPayload.model_validate(overflow_payload)
+    validated_overflow_payload = InteractivePayload.validate_python(overflow_payload)
     assert validated_overflow_payload is not None
+    assert isinstance(validated_overflow_payload, BlockActionsPayload)
 
 
 def test_block_action_in_modal():
-    validated_in_modal_payload = BlockActionsPayload.model_validate(in_modal_payload)
+    validated_in_modal_payload = InteractivePayload.validate_python(in_modal_payload)
     assert validated_in_modal_payload is not None
+    assert isinstance(validated_in_modal_payload, BlockActionsPayload)
diff --git a/tests/models/test_views.py b/tests/models/test_views.py
new file mode 100644
index 00000000..3b0d1e18
--- /dev/null
+++ b/tests/models/test_views.py
@@ -0,0 +1,15 @@
+from machine.models.interactive import InteractivePayload, ViewClosedPayload, ViewSubmissionPayload
+from tests.models.example_payloads.view_closed import payload as view_closed_payload
+from tests.models.example_payloads.view_submission import payload as view_submission_payload
+
+
+def test_view_submission():
+    validated_view_submission_payload = InteractivePayload.validate_python(view_submission_payload)
+    assert validated_view_submission_payload is not None
+    assert isinstance(validated_view_submission_payload, ViewSubmissionPayload)
+
+
+def test_view_closed():
+    validated_view_closed_payload = InteractivePayload.validate_python(view_closed_payload)
+    assert validated_view_closed_payload is not None
+    assert isinstance(validated_view_closed_payload, ViewClosedPayload)
diff --git a/tests/plugins/test_decorators.py b/tests/plugins/test_decorators.py
index c6430aba..3c070bf2 100644
--- a/tests/plugins/test_decorators.py
+++ b/tests/plugins/test_decorators.py
@@ -5,18 +5,18 @@
 
 from machine.plugins import ee
 from machine.plugins.decorators import (
-    ActionConfig,
-    CommandConfig,
-    MatcherConfig,
     action,
     command,
     listen_to,
+    modal,
+    modal_closed,
     on,
     process,
     required_settings,
     respond_to,
     schedule,
 )
+from machine.plugins.metadata import ActionConfig, CommandConfig, MatcherConfig, ModalConfig
 
 
 @pytest.fixture(scope="module")
@@ -118,6 +118,51 @@ def f(action_paylaod):
     return f
 
 
+@pytest.fixture(scope="module")
+def modal_f():
+    @modal("modal_1")
+    def f(modal_payload):
+        pass
+
+    return f
+
+
+@pytest.fixture(scope="module")
+def modal_f_regex():
+    @modal(re.compile(r"modal_\d", re.IGNORECASE))
+    def f(modal_payload):
+        pass
+
+    return f
+
+
+@pytest.fixture(scope="module")
+def modal_generator_f():
+    @modal("modal_1")
+    async def f(modal_payload):
+        yield "hello"
+
+    return f
+
+
+@pytest.fixture(scope="module")
+def modal_closed_f():
+    @modal_closed("modal_1")
+    def f(modal_closed_payload):
+        pass
+
+    return f
+
+
+@pytest.fixture(scope="module")
+def modal_closed_f_regex():
+    @modal_closed(re.compile(r"modal_\d", re.IGNORECASE))
+    def f(modal_closed_payload):
+        pass
+
+    return f
+
+
 @pytest.fixture(scope="module")
 def multi_decorator_f():
     @respond_to(r"hello-respond", re.IGNORECASE)
@@ -276,6 +321,55 @@ def f(action_paylaod):
             pass
 
 
+def test_modal(modal_f):
+    assert hasattr(modal_f, "metadata")
+    assert hasattr(modal_f.metadata, "plugin_actions")
+    assert hasattr(modal_f.metadata.plugin_actions, "modal_submissions")
+    assert modal_f.metadata.plugin_actions.modal_submissions == [ModalConfig(callback_id="modal_1")]
+
+
+def test_modal_regex(modal_f_regex):
+    assert hasattr(modal_f_regex, "metadata")
+    assert hasattr(modal_f_regex.metadata, "plugin_actions")
+    assert hasattr(modal_f_regex.metadata.plugin_actions, "modal_submissions")
+    assert modal_f_regex.metadata.plugin_actions.modal_submissions == [
+        ModalConfig(callback_id=re.compile(r"modal_\d", re.IGNORECASE))
+    ]
+
+
+def test_modal_generator(modal_generator_f):
+    assert hasattr(modal_generator_f, "metadata")
+    assert hasattr(modal_generator_f.metadata, "plugin_actions")
+    assert hasattr(modal_generator_f.metadata.plugin_actions, "modal_submissions")
+    assert modal_generator_f.metadata.plugin_actions.modal_submissions == [
+        ModalConfig(callback_id="modal_1", is_generator=True)
+    ]
+
+
+def test_modal_closed(modal_closed_f):
+    assert hasattr(modal_closed_f, "metadata")
+    assert hasattr(modal_closed_f.metadata, "plugin_actions")
+    assert hasattr(modal_closed_f.metadata.plugin_actions, "modal_closures")
+    assert modal_closed_f.metadata.plugin_actions.modal_closures == [ModalConfig(callback_id="modal_1")]
+
+
+def test_modal_closed_regex(modal_closed_f_regex):
+    assert hasattr(modal_closed_f_regex, "metadata")
+    assert hasattr(modal_closed_f_regex.metadata, "plugin_actions")
+    assert hasattr(modal_closed_f_regex.metadata.plugin_actions, "modal_closures")
+    assert modal_closed_f_regex.metadata.plugin_actions.modal_closures == [
+        ModalConfig(callback_id=re.compile(r"modal_\d", re.IGNORECASE))
+    ]
+
+
+def test_modal_closed_generator():
+    with pytest.raises(ValueError, match="Modal closed handlers cannot be async generators"):
+
+        @modal_closed("modal_1")
+        async def f(modal_payload):
+            yield "hello"
+
+
 def test_required_settings_list(required_settings_list_f):
     assert hasattr(required_settings_list_f, "metadata")
     assert hasattr(required_settings_list_f.metadata, "required_settings")
diff --git a/tests/test_plugin_registration.py b/tests/test_plugin_registration.py
index e30277a4..039e9af1 100644
--- a/tests/test_plugin_registration.py
+++ b/tests/test_plugin_registration.py
@@ -4,7 +4,7 @@
 
 from machine import Machine
 from machine.clients.slack import SlackClient
-from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions
+from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, ModalHandler, RegisteredActions
 from machine.plugins.decorators import required_settings
 from machine.utils.collections import CaseInsensitiveDict
 from machine.utils.logging import configure_logging
@@ -97,6 +97,31 @@ async def test_load_and_register_plugins(settings, slack_client):
     assert isinstance(actions.block_actions[block_action_key].block_id_matcher, str)
     assert actions.block_actions[block_action_key].block_id_matcher == "my_block"
 
+    # Test registration of modal actions
+    modal_key = "tests.fake_plugins.FakePlugin.modal_function-my_modal.*"
+    assert modal_key in actions.modal
+    assert isinstance(actions.modal[modal_key], ModalHandler)
+    assert actions.modal[modal_key].class_name == "tests.fake_plugins.FakePlugin"
+    assert isinstance(actions.modal[modal_key].callback_id_matcher, re.Pattern)
+    assert actions.modal[modal_key].callback_id_matcher == re.compile("my_modal.*", re.IGNORECASE)
+    assert not actions.modal[modal_key].is_generator
+
+    # Test registration of generator modal actions
+    generator_modal_key = "tests.fake_plugins.FakePlugin.generator_modal_function-my_generator_modal"
+    assert generator_modal_key in actions.modal
+    assert isinstance(actions.modal[generator_modal_key], ModalHandler)
+    assert actions.modal[generator_modal_key].class_name == "tests.fake_plugins.FakePlugin"
+    assert actions.modal[generator_modal_key].callback_id_matcher == "my_generator_modal"
+    assert actions.modal[generator_modal_key].is_generator
+
+    # Test registration of modal_closed actions
+    modal_closed_key = "tests.fake_plugins.FakePlugin.modal_closed_function-my_modal_2"
+    assert modal_closed_key in actions.modal_closed
+    assert isinstance(actions.modal_closed[modal_closed_key], ModalHandler)
+    assert actions.modal_closed[modal_closed_key].class_name == "tests.fake_plugins.FakePlugin"
+    assert actions.modal_closed[modal_closed_key].callback_id_matcher == "my_modal_2"
+    assert not actions.modal_closed[modal_closed_key].is_generator
+
 
 @pytest.mark.asyncio
 async def test_plugin_storage_fq_plugin_name(settings, slack_client):
diff --git a/tests/utils/test_datetime.py b/tests/utils/test_datetime.py
index 133426b1..a420ea9a 100644
--- a/tests/utils/test_datetime.py
+++ b/tests/utils/test_datetime.py
@@ -1,10 +1,6 @@
-import sys
 from datetime import datetime
+from zoneinfo import ZoneInfo
 
-if sys.version_info >= (3, 9):
-    from zoneinfo import ZoneInfo
-else:
-    from backports.zoneinfo import ZoneInfo
 from machine.utils.datetime import calculate_epoch