Repository: PrefectHQ/prefect

Issue:
Allow an alert to be sent via Twilio on user selected states
Add the ability to send flow state notifications via Twilio as supported by [Automations Actions](https://docs-v1.prefect.io/orchestration/concepts/automations.html#actions) in Prefect 1.0. Would most likely take the form of a notification block that integrates with the flow run state notification feature in PRefect 2.0.


Discussion:

Resolving patch:
diff --git a/src/prefect/blocks/notifications.py b/src/prefect/blocks/notifications.py
--- a/src/prefect/blocks/notifications.py
+++ b/src/prefect/blocks/notifications.py
@@ -1,9 +1,11 @@
 from abc import ABC, abstractmethod
-from typing import Optional
+from typing import List, Optional
 
 import apprise
 from apprise import Apprise, AppriseAsset, NotifyType
+from apprise.plugins.NotifyTwilio import NotifyTwilio
 from pydantic import Field, SecretStr
+from typing_extensions import Literal
 
 from prefect.blocks.core import Block
 from prefect.utilities.asyncutils import sync_compatible
@@ -37,22 +39,22 @@ class PrefectNotifyType(NotifyType):
 apprise.NOTIFY_TYPES += (PrefectNotifyType.DEFAULT,)
 
 
-class AppriseNotificationBlock(NotificationBlock, ABC):
+class AbstractAppriseNotificationBlock(NotificationBlock, ABC):
     """
-    A base class for sending notifications using Apprise.
-
-    Attributes:
-        url: Incoming webhook URL used to send notifications.
+    An abstract class for sending notifications using Apprise.
     """
 
-    url: SecretStr = Field(
-        default=...,
-        title="Webhook URL",
-        description="Incoming webhook URL used to send notifications.",
-        example="https://hooks.example.com/XXX",
+    notify_type: Literal[
+        "prefect_default", "info", "success", "warning", "failure"
+    ] = Field(
+        default=PrefectNotifyType.DEFAULT,
+        description=(
+            "The type of notification being performed; the prefect_default "
+            "is a plain notification that does not attach an image."
+        ),
     )
 
-    def block_initialization(self) -> None:
+    def _start_apprise_client(self, url: SecretStr):
         # A custom `AppriseAsset` that ensures Prefect Notifications
         # appear correctly across multiple messaging platforms
         prefect_app_data = AppriseAsset(
@@ -62,15 +64,31 @@ def block_initialization(self) -> None:
         )
 
         self._apprise_client = Apprise(asset=prefect_app_data)
-        self._apprise_client.add(self.url.get_secret_value())
+        self._apprise_client.add(url.get_secret_value())
+
+    def block_initialization(self) -> None:
+        self._start_apprise_client(self.url)
 
     @sync_compatible
     async def notify(self, body: str, subject: Optional[str] = None):
         await self._apprise_client.async_notify(
-            body=body, title=subject, notify_type=PrefectNotifyType.DEFAULT
+            body=body, title=subject, notify_type=self.notify_type
         )
 
 
+class AppriseNotificationBlock(AbstractAppriseNotificationBlock, ABC):
+    """
+    A base class for sending notifications using Apprise, through webhook URLs.
+    """
+
+    url: SecretStr = Field(
+        default=...,
+        title="Webhook URL",
+        description="Incoming webhook URL used to send notifications.",
+        example="https://hooks.example.com/XXX",
+    )
+
+
 # TODO: Move to prefect-slack once collection block auto-registration is
 # available
 class SlackWebhook(AppriseNotificationBlock):
@@ -128,3 +146,62 @@ class MicrosoftTeamsWebhook(AppriseNotificationBlock):
         description="The Teams incoming webhook URL used to send notifications.",
         example="https://your-org.webhook.office.com/webhookb2/XXX/IncomingWebhook/YYY/ZZZ",
     )
+
+
+class TwilioSMS(AbstractAppriseNotificationBlock):
+    """Enables sending notifications via Twilio SMS.
+    Find more on sending Twilio SMS messages in the [docs](https://www.twilio.com/docs/sms).
+
+    Examples:
+        Load a saved `TwilioSMS` block and send a message:
+        ```python
+        from prefect.blocks.notifications import TwilioSMS
+        twilio_webhook_block = TwilioSMS.load("BLOCK_NAME")
+        twilio_webhook_block.notify("Hello from Prefect!")
+        ```
+    """
+
+    _description = "Enables sending notifications via Twilio SMS."
+    _block_type_name = "Twilio SMS"
+    _block_type_slug = "twilio-sms"
+    _logo_url = "https://images.ctfassets.net/zscdif0zqppk/YTCgPL6bnK3BczP2gV9md/609283105a7006c57dbfe44ee1a8f313/58482bb9cef1014c0b5e4a31.png?h=250"  # noqa
+
+    account_sid: str = Field(
+        default=...,
+        description=(
+            "The Twilio Account SID - it can be found on the homepage "
+            "of the Twilio console."
+        ),
+    )
+
+    auth_token: SecretStr = Field(
+        default=...,
+        description=(
+            "The Twilio Authentication Token - "
+            "it can be found on the homepage of the Twilio console."
+        ),
+    )
+
+    from_phone_number: str = Field(
+        default=...,
+        description="The valid Twilio phone number to send the message from.",
+        example="18001234567",
+    )
+
+    to_phone_numbers: List[str] = Field(
+        default=...,
+        description="A list of valid Twilio phone number(s) to send the message to.",
+        # not wrapped in brackets because of the way UI displays examples; in code should be ["18004242424"]
+        example="18004242424",
+    )
+
+    def block_initialization(self) -> None:
+        url = SecretStr(
+            NotifyTwilio(
+                account_sid=self.account_sid,
+                auth_token=self.auth_token.get_secret_value(),
+                source=self.from_phone_number,
+                targets=self.to_phone_numbers,
+            ).url()
+        )
+        self._start_apprise_client(url)


Test patch:
diff --git a/tests/blocks/test_notifications.py b/tests/blocks/test_notifications.py
--- a/tests/blocks/test_notifications.py
+++ b/tests/blocks/test_notifications.py
@@ -6,7 +6,11 @@
 import pytest
 
 import prefect
-from prefect.blocks.notifications import AppriseNotificationBlock, PrefectNotifyType
+from prefect.blocks.notifications import (
+    AppriseNotificationBlock,
+    PrefectNotifyType,
+    TwilioSMS,
+)
 from prefect.testing.utilities import AsyncMock
 
 
@@ -69,7 +73,7 @@ def test_notify_sync(self, block_class: Type[AppriseNotificationBlock]):
             apprise_instance_mock.add.assert_called_once_with(
                 block.url.get_secret_value()
             )
-            apprise_instance_mock.async_notify.assert_awaited_once_with(
+            apprise_instance_mock.async_notify.assert_called_once_with(
                 body="test", title=None, notify_type=PrefectNotifyType.DEFAULT
             )
 
@@ -79,3 +83,84 @@ def test_is_picklable(self, block_class: Type[AppriseNotificationBlock]):
         pickled = cloudpickle.dumps(block)
         unpickled = cloudpickle.loads(pickled)
         assert isinstance(unpickled, block_class)
+
+
+class TestTwilioSMS:
+    @pytest.fixture
+    def valid_apprise_url(self) -> str:
+        return (
+            "twilio://ACabcdefabcdefabcdefabcdef"
+            ":XXXXXXXXXXXXXXXXXXXXXXXX"
+            "@%2B15555555555/%2B15555555556/%2B15555555557/"
+            "?format=text&overflow=upstream&rto=4.0&cto=4.0&verify=yes"
+        )
+
+    async def test_twilio_notify_async(self, valid_apprise_url):
+        with patch("apprise.Apprise", autospec=True) as AppriseMock:
+            reload_modules()
+
+            client_instance_mock = AppriseMock.return_value
+            client_instance_mock.async_notify = AsyncMock()
+
+            twilio_sms_block = TwilioSMS(
+                account_sid="ACabcdefabcdefabcdefabcdef",
+                auth_token="XXXXXXXXXXXXXXXXXXXXXXXX",
+                from_phone_number="+15555555555",
+                to_phone_numbers=["+15555555556", "+15555555557"],
+            )
+
+            await twilio_sms_block.notify("hello from prefect")
+
+            AppriseMock.assert_called_once()
+            client_instance_mock.add.assert_called_once_with(valid_apprise_url)
+
+            client_instance_mock.async_notify.assert_awaited_once_with(
+                body="hello from prefect",
+                title=None,
+                notify_type=PrefectNotifyType.DEFAULT,
+            )
+
+    def test_twilio_notify_sync(self, valid_apprise_url):
+        with patch("apprise.Apprise", autospec=True) as AppriseMock:
+            reload_modules()
+
+            client_instance_mock = AppriseMock.return_value
+            client_instance_mock.async_notify = AsyncMock()
+
+            twilio_sms_block = TwilioSMS(
+                account_sid="ACabcdefabcdefabcdefabcdef",
+                auth_token="XXXXXXXXXXXXXXXXXXXXXXXX",
+                from_phone_number="+15555555555",
+                to_phone_numbers=["+15555555556", "+15555555557"],
+            )
+
+            twilio_sms_block.notify("hello from prefect")
+
+            AppriseMock.assert_called_once()
+            client_instance_mock.add.assert_called_once_with(valid_apprise_url)
+
+            client_instance_mock.async_notify.assert_awaited_once_with(
+                body="hello from prefect",
+                title=None,
+                notify_type=PrefectNotifyType.DEFAULT,
+            )
+
+    def test_invalid_from_phone_number_raises_validation_error(self):
+        with pytest.raises(TypeError):
+            TwilioSMS(
+                account_sid="ACabcdefabcdefabcdefabcdef",
+                auth_token="XXXXXXXXXXXXXXXX",
+                to_phone_numbers=["+15555555555"],
+                from_phone_number="0000000",
+            )
+
+    def test_invalid_to_phone_numbers_raises_warning(self, caplog):
+        with caplog.at_level("WARNING"):
+            TwilioSMS(
+                account_sid="ACabcdefabcdefabcdefabcdef",
+                auth_token="XXXXXXXXXXXXXXXX",
+                to_phone_numbers=["0000000"],
+                from_phone_number="+15555555555",
+            )
+
+            assert "Dropped invalid phone # (0000000) specified." in caplog.text