python-missive 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymissive/__init__.py +3 -0
- pymissive/__main__.py +9 -0
- pymissive/archives/__init__.py +142 -0
- pymissive/archives/__main__.py +9 -0
- pymissive/archives/address.py +272 -0
- pymissive/archives/address_backends/__init__.py +29 -0
- pymissive/archives/address_backends/base.py +610 -0
- pymissive/archives/address_backends/geoapify.py +221 -0
- pymissive/archives/address_backends/geocode_earth.py +210 -0
- pymissive/archives/address_backends/google_maps.py +371 -0
- pymissive/archives/address_backends/here.py +348 -0
- pymissive/archives/address_backends/locationiq.py +271 -0
- pymissive/archives/address_backends/mapbox.py +314 -0
- pymissive/archives/address_backends/maps_co.py +257 -0
- pymissive/archives/address_backends/nominatim.py +348 -0
- pymissive/archives/address_backends/opencage.py +292 -0
- pymissive/archives/address_backends/pelias_mixin.py +181 -0
- pymissive/archives/address_backends/photon.py +322 -0
- pymissive/archives/cli.py +42 -0
- pymissive/archives/helpers.py +45 -0
- pymissive/archives/missive.py +64 -0
- pymissive/archives/providers/__init__.py +167 -0
- pymissive/archives/providers/apn.py +171 -0
- pymissive/archives/providers/ar24.py +204 -0
- pymissive/archives/providers/base/__init__.py +203 -0
- pymissive/archives/providers/base/_attachments.py +166 -0
- pymissive/archives/providers/base/branded.py +341 -0
- pymissive/archives/providers/base/common.py +781 -0
- pymissive/archives/providers/base/email.py +422 -0
- pymissive/archives/providers/base/email_message.py +85 -0
- pymissive/archives/providers/base/monitoring.py +150 -0
- pymissive/archives/providers/base/notification.py +187 -0
- pymissive/archives/providers/base/postal.py +742 -0
- pymissive/archives/providers/base/postal_defaults.py +26 -0
- pymissive/archives/providers/base/sms.py +213 -0
- pymissive/archives/providers/base/voice_call.py +82 -0
- pymissive/archives/providers/brevo.py +363 -0
- pymissive/archives/providers/certeurope.py +249 -0
- pymissive/archives/providers/django_email.py +182 -0
- pymissive/archives/providers/fcm.py +91 -0
- pymissive/archives/providers/laposte.py +392 -0
- pymissive/archives/providers/maileva.py +511 -0
- pymissive/archives/providers/mailgun.py +118 -0
- pymissive/archives/providers/messenger.py +74 -0
- pymissive/archives/providers/notification.py +112 -0
- pymissive/archives/providers/sendgrid.py +160 -0
- pymissive/archives/providers/ses.py +185 -0
- pymissive/archives/providers/signal.py +68 -0
- pymissive/archives/providers/slack.py +80 -0
- pymissive/archives/providers/smtp.py +190 -0
- pymissive/archives/providers/teams.py +91 -0
- pymissive/archives/providers/telegram.py +69 -0
- pymissive/archives/providers/twilio.py +310 -0
- pymissive/archives/providers/vonage.py +208 -0
- pymissive/archives/sender.py +339 -0
- pymissive/archives/status.py +22 -0
- pymissive/cli.py +42 -0
- pymissive/config.py +397 -0
- pymissive/helpers.py +0 -0
- pymissive/providers/apn.py +8 -0
- pymissive/providers/ar24.py +8 -0
- pymissive/providers/base/__init__.py +64 -0
- pymissive/providers/base/acknowledgement.py +6 -0
- pymissive/providers/base/attachments.py +10 -0
- pymissive/providers/base/branded.py +16 -0
- pymissive/providers/base/email.py +2 -0
- pymissive/providers/base/notification.py +2 -0
- pymissive/providers/base/postal.py +2 -0
- pymissive/providers/base/sms.py +2 -0
- pymissive/providers/base/voice_call.py +2 -0
- pymissive/providers/brevo.py +420 -0
- pymissive/providers/certeurope.py +8 -0
- pymissive/providers/django_email.py +8 -0
- pymissive/providers/fcm.py +8 -0
- pymissive/providers/laposte.py +8 -0
- pymissive/providers/maileva.py +8 -0
- pymissive/providers/mailgun.py +8 -0
- pymissive/providers/messenger.py +8 -0
- pymissive/providers/notification.py +8 -0
- pymissive/providers/partner.py +650 -0
- pymissive/providers/scaleway.py +498 -0
- pymissive/providers/sendgrid.py +8 -0
- pymissive/providers/ses.py +8 -0
- pymissive/providers/signal.py +8 -0
- pymissive/providers/slack.py +8 -0
- pymissive/providers/smtp.py +8 -0
- pymissive/providers/teams.py +8 -0
- pymissive/providers/telegram.py +8 -0
- pymissive/providers/twilio.py +8 -0
- pymissive/providers/vonage.py +8 -0
- python_missive-0.2.0.dist-info/METADATA +152 -0
- python_missive-0.2.0.dist-info/RECORD +95 -0
- python_missive-0.2.0.dist-info/WHEEL +5 -0
- python_missive-0.2.0.dist-info/entry_points.txt +2 -0
- python_missive-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Generic SMTP provider without Django dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import smtplib
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from email.message import EmailMessage
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ..status import MissiveStatus
|
|
11
|
+
from .base import BaseProvider
|
|
12
|
+
from .base.email_message import build_email_message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SMTPProvider(BaseProvider):
|
|
16
|
+
"""Simple SMTP provider supporting transactional and marketing emails."""
|
|
17
|
+
|
|
18
|
+
name = "smtp"
|
|
19
|
+
display_name = "SMTP"
|
|
20
|
+
supported_types = ["EMAIL", "EMAIL_MARKETING"]
|
|
21
|
+
config_keys = [
|
|
22
|
+
"SMTP_HOST",
|
|
23
|
+
"SMTP_PORT",
|
|
24
|
+
"SMTP_USERNAME",
|
|
25
|
+
"SMTP_PASSWORD",
|
|
26
|
+
"SMTP_USE_TLS",
|
|
27
|
+
"SMTP_USE_SSL",
|
|
28
|
+
"SMTP_TIMEOUT_SECONDS",
|
|
29
|
+
"DEFAULT_FROM_EMAIL",
|
|
30
|
+
]
|
|
31
|
+
required_packages: List[str] = []
|
|
32
|
+
description_text = (
|
|
33
|
+
"Direct SMTP integration with optional TLS/SSL and inline attachment support."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Geographic scopes + pricing baseline
|
|
37
|
+
email_geographic_coverage = ["*"]
|
|
38
|
+
email_geo = email_geographic_coverage
|
|
39
|
+
email_price = 0.0 # delegated to provider pricing
|
|
40
|
+
email_marketing_geographic_coverage = ["*"]
|
|
41
|
+
email_marketing_geo = email_marketing_geographic_coverage
|
|
42
|
+
email_marketing_price = 0.0
|
|
43
|
+
|
|
44
|
+
def validate(self) -> Tuple[bool, str]:
|
|
45
|
+
"""Ensure mandatory connection settings exist."""
|
|
46
|
+
host = self._config.get("SMTP_HOST")
|
|
47
|
+
port = self._config.get("SMTP_PORT")
|
|
48
|
+
if not host or not port:
|
|
49
|
+
return False, "SMTP_HOST and SMTP_PORT must be configured"
|
|
50
|
+
|
|
51
|
+
if "DEFAULT_FROM_EMAIL" not in self._config:
|
|
52
|
+
sender_addr = self._get_missive_value(
|
|
53
|
+
"sender_email"
|
|
54
|
+
) or self._get_missive_value("sender")
|
|
55
|
+
self._raw_config["DEFAULT_FROM_EMAIL"] = (
|
|
56
|
+
sender_addr if isinstance(sender_addr, str) else "noreply@example.com"
|
|
57
|
+
)
|
|
58
|
+
self._config = self._filter_config(self._raw_config)
|
|
59
|
+
|
|
60
|
+
return super().validate()
|
|
61
|
+
|
|
62
|
+
def send_email(self, **kwargs: Any) -> bool:
|
|
63
|
+
"""Send an email via configured SMTP server."""
|
|
64
|
+
recipient = self._get_missive_value(
|
|
65
|
+
"get_recipient_email"
|
|
66
|
+
) or self._get_missive_value("recipient_email")
|
|
67
|
+
if not recipient:
|
|
68
|
+
self._update_status(
|
|
69
|
+
MissiveStatus.FAILED, error_message="Recipient email missing"
|
|
70
|
+
)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
message = self._build_message(recipient)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with self._smtp_connection() as smtp:
|
|
77
|
+
smtp.send_message(message)
|
|
78
|
+
except (OSError, smtplib.SMTPException) as exc:
|
|
79
|
+
return self._handle_send_error(exc)
|
|
80
|
+
|
|
81
|
+
external_id = f"smtp_{getattr(self.missive, 'id', 'unknown')}"
|
|
82
|
+
self._update_status(
|
|
83
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
84
|
+
)
|
|
85
|
+
self._create_event("sent", "Email sent via SMTP")
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def send_email_marketing(self, **kwargs: Any) -> bool:
|
|
89
|
+
"""Marketing campaigns reuse the same pipeline."""
|
|
90
|
+
return self.send_email(**kwargs)
|
|
91
|
+
|
|
92
|
+
def get_email_service_info(self) -> Dict[str, Any]:
|
|
93
|
+
info = super().get_email_service_info()
|
|
94
|
+
info["details"].update(
|
|
95
|
+
{
|
|
96
|
+
"host": self._config.get("SMTP_HOST"),
|
|
97
|
+
"port": self._config.get("SMTP_PORT"),
|
|
98
|
+
"use_tls": self._bool_config("SMTP_USE_TLS", False),
|
|
99
|
+
"use_ssl": self._bool_config("SMTP_USE_SSL", False),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
info["warnings"] = []
|
|
103
|
+
return info
|
|
104
|
+
|
|
105
|
+
def get_email_marketing_service_info(self) -> Dict[str, Any]:
|
|
106
|
+
return self.get_email_service_info()
|
|
107
|
+
|
|
108
|
+
def get_service_status(self) -> Dict[str, Any]:
|
|
109
|
+
"""Return lightweight availability info."""
|
|
110
|
+
service_info = self.get_email_service_info()
|
|
111
|
+
return self._build_generic_service_status(
|
|
112
|
+
status="operational",
|
|
113
|
+
is_available=True,
|
|
114
|
+
credits_type="unlimited",
|
|
115
|
+
rate_limits={},
|
|
116
|
+
warnings=service_info.get("warnings"),
|
|
117
|
+
details=service_info.get("details"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def cancel_email(self, **kwargs: Any) -> bool:
|
|
121
|
+
"""SMTP has no notion of cancel."""
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def cancel_email_marketing(self, **kwargs: Any) -> bool:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def validate_email_webhook_signature(
|
|
128
|
+
self, payload: Any, headers: Dict[str, str]
|
|
129
|
+
) -> Tuple[bool, str]:
|
|
130
|
+
"""SMTP providers do not expose webhooks by default."""
|
|
131
|
+
return True, ""
|
|
132
|
+
|
|
133
|
+
validate_email_marketing_webhook_signature = validate_email_webhook_signature
|
|
134
|
+
|
|
135
|
+
def handle_email_webhook(
|
|
136
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
|
137
|
+
) -> Tuple[bool, str, Optional[Any]]:
|
|
138
|
+
return False, "SMTP provider has no webhooks", None
|
|
139
|
+
|
|
140
|
+
handle_email_marketing_webhook = handle_email_webhook
|
|
141
|
+
|
|
142
|
+
def extract_email_missive_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
extract_email_marketing_missive_id = extract_email_missive_id
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Helpers
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
def _build_message(self, recipient: str) -> EmailMessage:
|
|
151
|
+
from_email = self._config.get("DEFAULT_FROM_EMAIL", "noreply@example.com")
|
|
152
|
+
return build_email_message(self, recipient, from_email=from_email)
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def _smtp_connection(self):
|
|
156
|
+
host = str(self._config.get("SMTP_HOST"))
|
|
157
|
+
port = int(self._config.get("SMTP_PORT"))
|
|
158
|
+
timeout = float(self._config.get("SMTP_TIMEOUT_SECONDS", 10))
|
|
159
|
+
use_ssl = self._bool_config("SMTP_USE_SSL", False)
|
|
160
|
+
use_tls = self._bool_config("SMTP_USE_TLS", False)
|
|
161
|
+
|
|
162
|
+
if use_ssl:
|
|
163
|
+
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host, port, timeout=timeout)
|
|
164
|
+
else:
|
|
165
|
+
smtp = smtplib.SMTP(host, port, timeout=timeout)
|
|
166
|
+
if use_tls:
|
|
167
|
+
smtp.starttls()
|
|
168
|
+
|
|
169
|
+
username = self._config.get("SMTP_USERNAME")
|
|
170
|
+
password = self._config.get("SMTP_PASSWORD")
|
|
171
|
+
if username and password:
|
|
172
|
+
smtp.login(username, password)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
yield smtp
|
|
176
|
+
finally:
|
|
177
|
+
try:
|
|
178
|
+
smtp.quit()
|
|
179
|
+
except Exception:
|
|
180
|
+
smtp.close()
|
|
181
|
+
|
|
182
|
+
def _bool_config(self, key: str, default: bool) -> bool:
|
|
183
|
+
"""Convert config value to boolean."""
|
|
184
|
+
value = self._raw_config.get(key, default)
|
|
185
|
+
if isinstance(value, str):
|
|
186
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
187
|
+
return bool(value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
__all__ = ["SMTPProvider"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Microsoft Teams provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TeamsProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Microsoft Teams provider.
|
|
14
|
+
|
|
15
|
+
Required configuration:
|
|
16
|
+
TEAMS_CLIENT_ID: Azure AD app Client ID
|
|
17
|
+
TEAMS_CLIENT_SECRET: Client Secret
|
|
18
|
+
TEAMS_TENANT_ID: Tenant ID
|
|
19
|
+
|
|
20
|
+
Recipient must have:
|
|
21
|
+
- A Microsoft user_id (in metadata.teams_user_id)
|
|
22
|
+
- OR a Teams channel_id (in metadata.teams_channel_id)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "teams"
|
|
26
|
+
display_name = "Microsoft Teams"
|
|
27
|
+
supported_types = ["BRANDED"] # Uses generic BRANDED type
|
|
28
|
+
services = ["teams", "messaging"]
|
|
29
|
+
brands = ["teams"] # Microsoft Teams only
|
|
30
|
+
config_keys = ["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"]
|
|
31
|
+
required_packages = ["msgraph-core", "msal"]
|
|
32
|
+
site_url = "https://www.microsoft.com/microsoft-teams/"
|
|
33
|
+
status_url = "https://status.azure.com/en-us/status"
|
|
34
|
+
documentation_url = "https://learn.microsoft.com/en-us/microsoftteams/"
|
|
35
|
+
description_text = "Microsoft Teams - Enterprise communication (Microsoft 365)"
|
|
36
|
+
# Geographic scope
|
|
37
|
+
branded_geo = "*"
|
|
38
|
+
|
|
39
|
+
def validate(self) -> tuple[bool, str]:
|
|
40
|
+
"""Validate that the recipient has a Teams user_id or channel_id"""
|
|
41
|
+
if not self.missive:
|
|
42
|
+
return False, "Missive not defined"
|
|
43
|
+
|
|
44
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
45
|
+
if not recipient:
|
|
46
|
+
return False, "Recipient not defined"
|
|
47
|
+
|
|
48
|
+
metadata = getattr(recipient, "metadata", None) or {}
|
|
49
|
+
user_id = metadata.get("teams_user_id")
|
|
50
|
+
channel_id = metadata.get("teams_channel_id")
|
|
51
|
+
|
|
52
|
+
if not user_id and not channel_id:
|
|
53
|
+
return (
|
|
54
|
+
False,
|
|
55
|
+
"Recipient must have a teams_user_id or teams_channel_id in metadata",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return True, ""
|
|
59
|
+
|
|
60
|
+
def send_teams(self) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Send a Teams message via Microsoft Graph API.
|
|
63
|
+
|
|
64
|
+
TODO: Implement actual sending via:
|
|
65
|
+
POST https://graph.microsoft.com/v1.0/chats/{chat-id}/messages
|
|
66
|
+
"""
|
|
67
|
+
is_valid, error = self.validate()
|
|
68
|
+
if not is_valid:
|
|
69
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# TODO: Implement actual sending
|
|
73
|
+
# 1. Get an OAuth access token
|
|
74
|
+
# 2. Send the message via Graph API
|
|
75
|
+
# 3. Handle adaptive cards for rich content
|
|
76
|
+
|
|
77
|
+
external_id = f"teams_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
78
|
+
self._update_status(
|
|
79
|
+
MissiveStatus.SENT,
|
|
80
|
+
external_id=external_id,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
86
|
+
"""Check status via Graph API"""
|
|
87
|
+
# TODO: Implement via Microsoft Graph API
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["TeamsProvider"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Telegram Bot API provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TelegramProvider(BaseProvider):
|
|
12
|
+
"""Telegram provider."""
|
|
13
|
+
|
|
14
|
+
name = "telegram"
|
|
15
|
+
display_name = "Telegram"
|
|
16
|
+
supported_types = ["BRANDED"]
|
|
17
|
+
brands = ["telegram"]
|
|
18
|
+
config_keys = ["TELEGRAM_BOT_TOKEN"]
|
|
19
|
+
required_packages = ["python-telegram-bot"]
|
|
20
|
+
site_url = "https://telegram.org/"
|
|
21
|
+
description_text = "Secure instant messaging with bots"
|
|
22
|
+
# Geographic scope
|
|
23
|
+
branded_geo = "*"
|
|
24
|
+
|
|
25
|
+
def validate(self) -> tuple[bool, str]:
|
|
26
|
+
"""Validates recipient has Telegram chat_id."""
|
|
27
|
+
if not self.missive:
|
|
28
|
+
return False, "Missive not defined"
|
|
29
|
+
|
|
30
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
31
|
+
if not recipient:
|
|
32
|
+
return False, "Recipient not defined"
|
|
33
|
+
|
|
34
|
+
metadata = getattr(recipient, "metadata", None) or {}
|
|
35
|
+
chat_id = metadata.get("telegram_chat_id")
|
|
36
|
+
if not chat_id:
|
|
37
|
+
return False, "Recipient has no telegram_chat_id in metadata"
|
|
38
|
+
|
|
39
|
+
return True, ""
|
|
40
|
+
|
|
41
|
+
def send_branded(self, brand_name: Optional[str] = None, **kwargs) -> bool:
|
|
42
|
+
"""Sends message via Telegram Bot API."""
|
|
43
|
+
is_valid, error = self.validate()
|
|
44
|
+
if not is_valid:
|
|
45
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
# TODO: Implement actual sending
|
|
49
|
+
# For now, simulate sending
|
|
50
|
+
external_id = f"telegram_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
51
|
+
self._update_status(
|
|
52
|
+
MissiveStatus.SENT,
|
|
53
|
+
external_id=external_id,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
59
|
+
"""
|
|
60
|
+
Check status of a Telegram message.
|
|
61
|
+
|
|
62
|
+
Note: Telegram does not provide automatic webhooks for delivery status.
|
|
63
|
+
Can only know if message was sent.
|
|
64
|
+
"""
|
|
65
|
+
# TODO: Implement if needed
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["TelegramProvider"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Twilio provider for SMS and WhatsApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TwilioProvider(BaseProvider):
|
|
12
|
+
"""Twilio provider (SMS and WhatsApp)."""
|
|
13
|
+
|
|
14
|
+
name = "twilio"
|
|
15
|
+
display_name = "Twilio"
|
|
16
|
+
supported_types = ["SMS", "BRANDED", "VOICE_CALL"]
|
|
17
|
+
services = ["sms", "whatsapp", "voice", "verify"]
|
|
18
|
+
brands = ["whatsapp"]
|
|
19
|
+
config_keys = ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"]
|
|
20
|
+
required_packages = ["twilio"]
|
|
21
|
+
site_url = "https://www.twilio.com/"
|
|
22
|
+
status_url = "https://status.twilio.com/"
|
|
23
|
+
documentation_url = "https://www.twilio.com/docs"
|
|
24
|
+
description_text = "Global multi-channel cloud platform (SMS, WhatsApp, Voice)"
|
|
25
|
+
|
|
26
|
+
def send_twilio(self) -> bool:
|
|
27
|
+
"""Dispatches to WhatsApp for BRANDED type."""
|
|
28
|
+
return self.send_whatsapp()
|
|
29
|
+
|
|
30
|
+
def send_sms(self) -> bool:
|
|
31
|
+
"""Sends SMS via Twilio."""
|
|
32
|
+
is_valid, error = self._validate_and_check_recipient(
|
|
33
|
+
"recipient_phone", "Phone missing"
|
|
34
|
+
)
|
|
35
|
+
if not is_valid:
|
|
36
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# TODO: Implement actual Twilio SMS sending
|
|
41
|
+
# from twilio.rest import Client
|
|
42
|
+
#
|
|
43
|
+
# account_sid = self._config.get('TWILIO_ACCOUNT_SID')
|
|
44
|
+
# auth_token = self._config.get('TWILIO_AUTH_TOKEN')
|
|
45
|
+
# from_number = self._config.get('TWILIO_PHONE_NUMBER')
|
|
46
|
+
#
|
|
47
|
+
# client = Client(account_sid, auth_token)
|
|
48
|
+
# message = client.messages.create(
|
|
49
|
+
# body=self.missive.body,
|
|
50
|
+
# from_=from_number,
|
|
51
|
+
# to=self.missive.recipient_phone
|
|
52
|
+
# )
|
|
53
|
+
#
|
|
54
|
+
# external_id = message.sid
|
|
55
|
+
|
|
56
|
+
# Simulation
|
|
57
|
+
external_id = f"tw_sms_{getattr(self.missive, 'id', 'unknown')}"
|
|
58
|
+
|
|
59
|
+
self._update_status(
|
|
60
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
61
|
+
)
|
|
62
|
+
self._create_event("sent", "SMS sent via Twilio")
|
|
63
|
+
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return self._handle_send_error(e)
|
|
68
|
+
|
|
69
|
+
def send_whatsapp(self) -> bool:
|
|
70
|
+
"""Send via WhatsApp via Twilio"""
|
|
71
|
+
is_valid, error = self._validate_and_check_recipient(
|
|
72
|
+
"recipient_phone", "Phone missing"
|
|
73
|
+
)
|
|
74
|
+
if not is_valid:
|
|
75
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# TODO: Integrate with Twilio WhatsApp
|
|
80
|
+
# from twilio.rest import Client
|
|
81
|
+
#
|
|
82
|
+
# account_sid = self._config.get('TWILIO_ACCOUNT_SID')
|
|
83
|
+
# auth_token = self._config.get('TWILIO_AUTH_TOKEN')
|
|
84
|
+
#
|
|
85
|
+
# client = Client(account_sid, auth_token)
|
|
86
|
+
#
|
|
87
|
+
# from_number = f"whatsapp:{self._config.get('TWILIO_WHATSAPP_NUMBER')}"
|
|
88
|
+
# to_number = f"whatsapp:{self.missive.recipient_phone}"
|
|
89
|
+
#
|
|
90
|
+
# message = client.messages.create(
|
|
91
|
+
# body=self.missive.body,
|
|
92
|
+
# from_=from_number,
|
|
93
|
+
# to=to_number,
|
|
94
|
+
# status_callback=self._config.get('TWILIO_WEBHOOK_URL')
|
|
95
|
+
# )
|
|
96
|
+
#
|
|
97
|
+
# external_id = message.sid
|
|
98
|
+
|
|
99
|
+
# Simulation
|
|
100
|
+
external_id = f"tw_wa_{getattr(self.missive, 'id', 'unknown')}"
|
|
101
|
+
|
|
102
|
+
self._update_status(
|
|
103
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
104
|
+
)
|
|
105
|
+
self._create_event("sent", "WhatsApp message sent via Twilio")
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self._update_status(MissiveStatus.FAILED, error_message=str(e))
|
|
111
|
+
self._create_event("failed", str(e))
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def send_voice_call(self) -> bool:
|
|
115
|
+
"""Send voice call via Twilio."""
|
|
116
|
+
is_valid, error = self.validate()
|
|
117
|
+
if not is_valid:
|
|
118
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
if not self._get_missive_value("recipient_phone"):
|
|
122
|
+
self._update_status(MissiveStatus.FAILED, error_message="Phone missing")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
# TODO: Implement actual Twilio Voice call
|
|
127
|
+
# from twilio.rest import Client
|
|
128
|
+
#
|
|
129
|
+
# account_sid = self._config.get('TWILIO_ACCOUNT_SID')
|
|
130
|
+
# auth_token = self._config.get('TWILIO_AUTH_TOKEN')
|
|
131
|
+
#
|
|
132
|
+
# client = Client(account_sid, auth_token)
|
|
133
|
+
# call = client.calls.create(
|
|
134
|
+
# to=self.missive.recipient_phone,
|
|
135
|
+
# from_=self._config.get('TWILIO_PHONE_NUMBER'),
|
|
136
|
+
# url=self._config.get('TWILIO_VOICE_URL'), # TwiML URL
|
|
137
|
+
# )
|
|
138
|
+
#
|
|
139
|
+
# external_id = call.sid
|
|
140
|
+
|
|
141
|
+
# Simulation
|
|
142
|
+
external_id = f"tw_voice_{getattr(self.missive, 'id', 'unknown')}"
|
|
143
|
+
|
|
144
|
+
self._update_status(
|
|
145
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
146
|
+
)
|
|
147
|
+
self._create_event("sent", "Voice call initiated via Twilio")
|
|
148
|
+
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
self._update_status(MissiveStatus.FAILED, error_message=str(e))
|
|
153
|
+
self._create_event("failed", str(e))
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def validate_webhook_signature(
|
|
157
|
+
self, payload: Dict, headers: Dict
|
|
158
|
+
) -> Tuple[bool, str]:
|
|
159
|
+
"""Validate Twilio webhook signature."""
|
|
160
|
+
auth_token = self._config.get("TWILIO_AUTH_TOKEN")
|
|
161
|
+
if not auth_token:
|
|
162
|
+
return True, ""
|
|
163
|
+
|
|
164
|
+
signature = headers.get("HTTP_X_TWILIO_SIGNATURE", "")
|
|
165
|
+
if not signature:
|
|
166
|
+
return False, "Signature missing"
|
|
167
|
+
|
|
168
|
+
# Twilio validation requires the full URL
|
|
169
|
+
# For simplicity, validation can be disabled in dev
|
|
170
|
+
# In production, implement according to:
|
|
171
|
+
# https://www.twilio.com/docs/usage/webhooks/webhooks-security
|
|
172
|
+
|
|
173
|
+
return True, "" # Simplified for now
|
|
174
|
+
|
|
175
|
+
def extract_sms_missive_id(self, payload: Dict) -> Optional[str]:
|
|
176
|
+
"""Extract missive ID from Twilio webhook."""
|
|
177
|
+
# Twilio returns MessageSid, we must have stored it in external_id
|
|
178
|
+
return payload.get("MessageSid")
|
|
179
|
+
|
|
180
|
+
def extract_event_type(self, payload: Dict) -> str:
|
|
181
|
+
"""Extract status from Twilio webhook."""
|
|
182
|
+
return payload.get("MessageStatus", "unknown")
|
|
183
|
+
|
|
184
|
+
def get_status_from_event(self, event_type: str) -> Optional[MissiveStatus]:
|
|
185
|
+
"""Map Twilio statuses to MissiveStatus."""
|
|
186
|
+
status_mapping = {
|
|
187
|
+
"queued": MissiveStatus.PENDING,
|
|
188
|
+
"sending": MissiveStatus.PENDING,
|
|
189
|
+
"sent": MissiveStatus.SENT,
|
|
190
|
+
"delivered": MissiveStatus.DELIVERED,
|
|
191
|
+
"undelivered": MissiveStatus.FAILED,
|
|
192
|
+
"failed": MissiveStatus.FAILED,
|
|
193
|
+
"read": MissiveStatus.READ,
|
|
194
|
+
}
|
|
195
|
+
return status_mapping.get(event_type.lower())
|
|
196
|
+
|
|
197
|
+
def get_service_status(self) -> Dict:
|
|
198
|
+
"""
|
|
199
|
+
Gets Twilio status and credits.
|
|
200
|
+
|
|
201
|
+
Twilio uses a prepaid system in USD.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Dict with status, credits in USD, etc.
|
|
205
|
+
"""
|
|
206
|
+
last_check = self._get_last_check_time()
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"status": "unknown",
|
|
210
|
+
"is_available": None,
|
|
211
|
+
"services": self._get_services(),
|
|
212
|
+
"credits": {
|
|
213
|
+
"type": "money",
|
|
214
|
+
"remaining": None,
|
|
215
|
+
"currency": "USD",
|
|
216
|
+
"limit": None,
|
|
217
|
+
"percentage": None,
|
|
218
|
+
},
|
|
219
|
+
"rate_limits": {
|
|
220
|
+
"per_second": 1,
|
|
221
|
+
"per_minute": 60,
|
|
222
|
+
},
|
|
223
|
+
"sla": {
|
|
224
|
+
"uptime_percentage": 99.95,
|
|
225
|
+
},
|
|
226
|
+
"last_check": last_check,
|
|
227
|
+
"warnings": ["Twilio API not implemented - uncomment the code"],
|
|
228
|
+
"details": {
|
|
229
|
+
"refill_url": "https://www.twilio.com/console/billing",
|
|
230
|
+
"status_page": "https://status.twilio.com/",
|
|
231
|
+
"api_docs": ("https://www.twilio.com/docs/usage/api/usage-record"),
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def cancel_sms(self) -> bool:
|
|
236
|
+
"""
|
|
237
|
+
Cancel SMS sending via Twilio.
|
|
238
|
+
|
|
239
|
+
Twilio allows canceling messages in 'queued' or 'scheduled' status.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
bool: True if cancellation succeeded, False otherwise
|
|
243
|
+
"""
|
|
244
|
+
if not self.missive or not getattr(self.missive, "external_id", None):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
# TODO: Implement actual cancellation
|
|
249
|
+
# from twilio.rest import Client
|
|
250
|
+
#
|
|
251
|
+
# account_sid = self._config.get("TWILIO_ACCOUNT_SID")
|
|
252
|
+
# auth_token = self._config.get("TWILIO_AUTH_TOKEN")
|
|
253
|
+
#
|
|
254
|
+
# if not account_sid or not auth_token:
|
|
255
|
+
# return False
|
|
256
|
+
#
|
|
257
|
+
# client = Client(account_sid, auth_token)
|
|
258
|
+
# message = client.messages(self.missive.external_id).update(
|
|
259
|
+
# status="canceled"
|
|
260
|
+
# )
|
|
261
|
+
#
|
|
262
|
+
# if message.status == "canceled":
|
|
263
|
+
# self._create_event("cancelled", "SMS cancelled via Twilio")
|
|
264
|
+
# return True
|
|
265
|
+
# else:
|
|
266
|
+
# return False
|
|
267
|
+
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
except Exception:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def cancel_twilio(self) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Cancel branded message (WhatsApp) sending via Twilio.
|
|
276
|
+
|
|
277
|
+
Called automatically by cancel_branded() via dispatch.
|
|
278
|
+
Works the same way as cancel_sms() because Twilio uses
|
|
279
|
+
the same API for SMS and WhatsApp.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
bool: True if cancellation succeeded, False otherwise
|
|
283
|
+
"""
|
|
284
|
+
return self.cancel_sms()
|
|
285
|
+
|
|
286
|
+
def cancel_whatsapp(self) -> bool:
|
|
287
|
+
"""
|
|
288
|
+
Cancels WhatsApp message sending via Twilio.
|
|
289
|
+
|
|
290
|
+
Called automatically by cancel_branded("whatsapp") via dispatch.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
bool: True if cancellation succeeded, False otherwise
|
|
294
|
+
"""
|
|
295
|
+
return self.cancel_sms()
|
|
296
|
+
|
|
297
|
+
def get_whatsapp_service_info(self) -> Dict:
|
|
298
|
+
"""
|
|
299
|
+
Gets WhatsApp service information via Twilio.
|
|
300
|
+
|
|
301
|
+
Called automatically by get_branded_service_info("whatsapp") via dispatch.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Dict with status, credits, etc.
|
|
305
|
+
"""
|
|
306
|
+
# WhatsApp via Twilio uses the same credit system as SMS
|
|
307
|
+
return self.get_service_status()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = ["TwilioProvider"]
|