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.
Files changed (95) hide show
  1. pymissive/__init__.py +3 -0
  2. pymissive/__main__.py +9 -0
  3. pymissive/archives/__init__.py +142 -0
  4. pymissive/archives/__main__.py +9 -0
  5. pymissive/archives/address.py +272 -0
  6. pymissive/archives/address_backends/__init__.py +29 -0
  7. pymissive/archives/address_backends/base.py +610 -0
  8. pymissive/archives/address_backends/geoapify.py +221 -0
  9. pymissive/archives/address_backends/geocode_earth.py +210 -0
  10. pymissive/archives/address_backends/google_maps.py +371 -0
  11. pymissive/archives/address_backends/here.py +348 -0
  12. pymissive/archives/address_backends/locationiq.py +271 -0
  13. pymissive/archives/address_backends/mapbox.py +314 -0
  14. pymissive/archives/address_backends/maps_co.py +257 -0
  15. pymissive/archives/address_backends/nominatim.py +348 -0
  16. pymissive/archives/address_backends/opencage.py +292 -0
  17. pymissive/archives/address_backends/pelias_mixin.py +181 -0
  18. pymissive/archives/address_backends/photon.py +322 -0
  19. pymissive/archives/cli.py +42 -0
  20. pymissive/archives/helpers.py +45 -0
  21. pymissive/archives/missive.py +64 -0
  22. pymissive/archives/providers/__init__.py +167 -0
  23. pymissive/archives/providers/apn.py +171 -0
  24. pymissive/archives/providers/ar24.py +204 -0
  25. pymissive/archives/providers/base/__init__.py +203 -0
  26. pymissive/archives/providers/base/_attachments.py +166 -0
  27. pymissive/archives/providers/base/branded.py +341 -0
  28. pymissive/archives/providers/base/common.py +781 -0
  29. pymissive/archives/providers/base/email.py +422 -0
  30. pymissive/archives/providers/base/email_message.py +85 -0
  31. pymissive/archives/providers/base/monitoring.py +150 -0
  32. pymissive/archives/providers/base/notification.py +187 -0
  33. pymissive/archives/providers/base/postal.py +742 -0
  34. pymissive/archives/providers/base/postal_defaults.py +26 -0
  35. pymissive/archives/providers/base/sms.py +213 -0
  36. pymissive/archives/providers/base/voice_call.py +82 -0
  37. pymissive/archives/providers/brevo.py +363 -0
  38. pymissive/archives/providers/certeurope.py +249 -0
  39. pymissive/archives/providers/django_email.py +182 -0
  40. pymissive/archives/providers/fcm.py +91 -0
  41. pymissive/archives/providers/laposte.py +392 -0
  42. pymissive/archives/providers/maileva.py +511 -0
  43. pymissive/archives/providers/mailgun.py +118 -0
  44. pymissive/archives/providers/messenger.py +74 -0
  45. pymissive/archives/providers/notification.py +112 -0
  46. pymissive/archives/providers/sendgrid.py +160 -0
  47. pymissive/archives/providers/ses.py +185 -0
  48. pymissive/archives/providers/signal.py +68 -0
  49. pymissive/archives/providers/slack.py +80 -0
  50. pymissive/archives/providers/smtp.py +190 -0
  51. pymissive/archives/providers/teams.py +91 -0
  52. pymissive/archives/providers/telegram.py +69 -0
  53. pymissive/archives/providers/twilio.py +310 -0
  54. pymissive/archives/providers/vonage.py +208 -0
  55. pymissive/archives/sender.py +339 -0
  56. pymissive/archives/status.py +22 -0
  57. pymissive/cli.py +42 -0
  58. pymissive/config.py +397 -0
  59. pymissive/helpers.py +0 -0
  60. pymissive/providers/apn.py +8 -0
  61. pymissive/providers/ar24.py +8 -0
  62. pymissive/providers/base/__init__.py +64 -0
  63. pymissive/providers/base/acknowledgement.py +6 -0
  64. pymissive/providers/base/attachments.py +10 -0
  65. pymissive/providers/base/branded.py +16 -0
  66. pymissive/providers/base/email.py +2 -0
  67. pymissive/providers/base/notification.py +2 -0
  68. pymissive/providers/base/postal.py +2 -0
  69. pymissive/providers/base/sms.py +2 -0
  70. pymissive/providers/base/voice_call.py +2 -0
  71. pymissive/providers/brevo.py +420 -0
  72. pymissive/providers/certeurope.py +8 -0
  73. pymissive/providers/django_email.py +8 -0
  74. pymissive/providers/fcm.py +8 -0
  75. pymissive/providers/laposte.py +8 -0
  76. pymissive/providers/maileva.py +8 -0
  77. pymissive/providers/mailgun.py +8 -0
  78. pymissive/providers/messenger.py +8 -0
  79. pymissive/providers/notification.py +8 -0
  80. pymissive/providers/partner.py +650 -0
  81. pymissive/providers/scaleway.py +498 -0
  82. pymissive/providers/sendgrid.py +8 -0
  83. pymissive/providers/ses.py +8 -0
  84. pymissive/providers/signal.py +8 -0
  85. pymissive/providers/slack.py +8 -0
  86. pymissive/providers/smtp.py +8 -0
  87. pymissive/providers/teams.py +8 -0
  88. pymissive/providers/telegram.py +8 -0
  89. pymissive/providers/twilio.py +8 -0
  90. pymissive/providers/vonage.py +8 -0
  91. python_missive-0.2.0.dist-info/METADATA +152 -0
  92. python_missive-0.2.0.dist-info/RECORD +95 -0
  93. python_missive-0.2.0.dist-info/WHEEL +5 -0
  94. python_missive-0.2.0.dist-info/entry_points.txt +2 -0
  95. python_missive-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,650 @@
1
+ """Partner providers (SMS, Email, Voice) - Simple implementations."""
2
+
3
+ import base64
4
+ import os
5
+ from typing import Any, Dict, Optional
6
+
7
+ import requests
8
+
9
+ from .base import MissiveProviderBase
10
+
11
+
12
+ class PartnerProvider(MissiveProviderBase):
13
+ """Abstract base class for Partner providers (SMS, Email, Voice)."""
14
+
15
+ abstract = True
16
+ display_name = "Partner"
17
+ description = "French multi-service solution (SMS, Email, Voice)"
18
+ site_url = "https://www.smspartner.fr/"
19
+ documentation_url = "https://www.docpartner.dev/"
20
+ required_packages = ["requests"]
21
+
22
+ API_BASE_SMS = "https://api.smspartner.fr/v1"
23
+ API_BASE_VOICE = "https://api.voicepartner.fr/v1"
24
+ API_BASE_EMAIL = "https://api.mailpartner.fr/v1"
25
+
26
+ STATUS_MAPPING_SMS = {
27
+ "Delivered": "delivered",
28
+ "Not delivered": "failed",
29
+ "Waiting": "pending",
30
+ "Sent": "sent",
31
+ }
32
+
33
+ STATUS_MAPPING_EMAIL = {
34
+ "Delivered": "delivered",
35
+ "Bounced": "bounced",
36
+ "Opened": "opened",
37
+ "Clicked": "clicked",
38
+ "Failed": "failed",
39
+ "Pending": "pending",
40
+ }
41
+
42
+ MAX_EMAIL_ATTACHMENTS = 3
43
+
44
+ ERROR_CODES = {
45
+ 1: "API key required",
46
+ 2: "Phone number required",
47
+ 3: "Message ID required",
48
+ 4: "Message not found",
49
+ 5: "Sending already cancelled",
50
+ 6: "Cannot cancel less than 5 minutes before sending",
51
+ 7: "Cannot cancel already sent message",
52
+ 9: "Constraints not met",
53
+ 10: "Incorrect API key",
54
+ 11: "Low credits",
55
+ }
56
+
57
+ def __init__(self, **kwargs: str | None) -> None:
58
+ super().__init__(**kwargs)
59
+ if not hasattr(self, "attachments"):
60
+ self.attachments = []
61
+
62
+ def _get_api_key(self) -> Optional[str]:
63
+ """Return the API key."""
64
+ return self._get_config_or_env("API_KEY")
65
+
66
+ def _perform_request(
67
+ self,
68
+ method: str,
69
+ url: str,
70
+ *,
71
+ json_payload: Optional[Dict[str, Any]] = None,
72
+ params: Optional[Dict[str, Any]] = None,
73
+ headers: Optional[Dict[str, str]] = None,
74
+ timeout: int = 10,
75
+ ):
76
+ """Perform an HTTP request."""
77
+ request_kwargs: Dict[str, Any] = {"timeout": timeout}
78
+ if json_payload is not None:
79
+ request_kwargs["json"] = json_payload
80
+ if params:
81
+ request_kwargs["params"] = params
82
+ if headers:
83
+ request_kwargs["headers"] = headers
84
+
85
+ http_callable = getattr(requests, method)
86
+ return http_callable(url, **request_kwargs)
87
+
88
+ def _safe_json(self, response: Any) -> Dict[str, Any]:
89
+ """Parse JSON safely."""
90
+ try:
91
+ json_data = response.json()
92
+ if isinstance(json_data, dict):
93
+ return json_data
94
+ return {}
95
+ except Exception as exc:
96
+ raise RuntimeError(f"Invalid JSON response: {exc}") from exc
97
+
98
+ def _get_message_text(self, **kwargs: Any) -> str:
99
+ """Return the message text."""
100
+ body_text = kwargs.get("body_text")
101
+ if body_text:
102
+ return str(body_text)
103
+ body = kwargs.get("body")
104
+ return str(body) if body else ""
105
+
106
+ def _get_error_message(self, code: Optional[int], default: str = "") -> str:
107
+ """Return the error message corresponding to the code."""
108
+ if code is None:
109
+ return default or "Unknown error"
110
+ return self.ERROR_CODES.get(code, default or f"Error {code}")
111
+
112
+ def _update_missive_event(
113
+ self,
114
+ status: str,
115
+ error_message: str | None = None,
116
+ external_id: str | None = None,
117
+ provider: str | None = None,
118
+ ) -> None:
119
+ """Update the missive status."""
120
+ if self.missive:
121
+ if hasattr(self.missive, "status"):
122
+ self.missive.status = status
123
+ if error_message and hasattr(self.missive, "error_message"):
124
+ self.missive.error_message = error_message
125
+ if external_id and hasattr(self.missive, "external_id"):
126
+ self.missive.external_id = external_id
127
+ if provider and hasattr(self.missive, "provider"):
128
+ self.missive.provider = provider
129
+ elif hasattr(self.missive, "provider"):
130
+ self.missive.provider = self.name
131
+ if hasattr(self.missive, "save"):
132
+ self.missive.save()
133
+
134
+ def add_attachment_email(self, content: bytes | str, name: str) -> None:
135
+ """Add an attachment to the email."""
136
+ if not hasattr(self, "attachments"):
137
+ self.attachments = []
138
+
139
+ if isinstance(content, str):
140
+ content = content.encode("utf-8")
141
+
142
+ self.attachments.append({"content": content, "name": os.path.basename(name)})
143
+
144
+ def remove_attachment_email(self, name: str) -> None:
145
+ """Remove an attachment from the email."""
146
+ if not hasattr(self, "attachments"):
147
+ return
148
+
149
+ self.attachments = [att for att in self.attachments if att["name"] != name]
150
+
151
+
152
+ class SmsPartnerProvider(PartnerProvider):
153
+ """SMS Partner provider specialized for SMS."""
154
+
155
+ name = "sms_partner"
156
+ display_name = "SMS Partner"
157
+ supported_types = ["SMS"]
158
+ services = ["sms", "sms_low_cost", "sms_premium"]
159
+ config_keys = ["API_KEY", "SENDER"]
160
+ config_defaults = {
161
+ "SENDER": "Missive",
162
+ }
163
+
164
+ def __init__(self, **kwargs: str | None) -> None:
165
+ """Initialize SMS Partner provider."""
166
+ super().__init__(**kwargs)
167
+ self._api_base = self.API_BASE_SMS
168
+
169
+ def prepare_sms(self, **kwargs: Any) -> Dict[str, Any]:
170
+ """Prepare SMS payload."""
171
+ phone = kwargs.get("recipient_phone")
172
+ if not phone:
173
+ raise ValueError("recipient_phone is required")
174
+
175
+ api_key = self._get_api_key()
176
+ if not api_key:
177
+ raise ValueError("API_KEY is required")
178
+
179
+ message = self._get_message_text(**kwargs)
180
+ if not message:
181
+ raise ValueError("Message text is required")
182
+
183
+ payload: Dict[str, Any] = {
184
+ "apiKey": api_key,
185
+ "phoneNumbers": phone,
186
+ "message": message,
187
+ "sender": self._get_config_or_env("SENDER", "Missive"),
188
+ "gamme": 1,
189
+ }
190
+
191
+ return payload
192
+
193
+ def send_sms(self, **kwargs: Any) -> bool:
194
+ """Send an SMS via SMSPartner."""
195
+ try:
196
+ payload = self.prepare_sms(**kwargs)
197
+
198
+ response = self._perform_request(
199
+ "post",
200
+ f"{self._api_base}/send",
201
+ json_payload=payload,
202
+ headers={
203
+ "Content-Type": "application/json",
204
+ "Cache-Control": "no-cache",
205
+ },
206
+ )
207
+ result = self._safe_json(response)
208
+ except Exception as exc:
209
+ self._update_missive_event("FAILED", error_message=str(exc))
210
+ return False
211
+
212
+ if result.get("success") is True:
213
+ message_id = result.get("message_id") or result.get("messageId")
214
+ self._update_missive_event(
215
+ "SENT",
216
+ external_id=str(message_id) if message_id else None,
217
+ provider=self.name,
218
+ )
219
+ return True
220
+
221
+ code = result.get("code")
222
+ message = result.get("message", "")
223
+ error_msg = self._get_error_message(code, message)
224
+ self._update_missive_event("FAILED", error_message=error_msg)
225
+ return False
226
+
227
+ def cancel_sms(self, **kwargs: Any) -> bool:
228
+ """Cancel an SMS."""
229
+ external_id = kwargs.get("external_id")
230
+ if not external_id:
231
+ return False
232
+
233
+ api_key = self._get_api_key()
234
+ if not api_key:
235
+ return False
236
+
237
+ try:
238
+ response = self._perform_request(
239
+ "delete",
240
+ f"{self._api_base}/message-cancel/{external_id}",
241
+ params={"apiKey": api_key},
242
+ timeout=5,
243
+ )
244
+ result = self._safe_json(response)
245
+ except Exception:
246
+ return False
247
+
248
+ return result.get("success") is True
249
+
250
+ def status_sms(self, **kwargs: Any) -> Dict[str, Any]:
251
+ """Check SMS delivery status."""
252
+ external_id = kwargs.get("external_id")
253
+ phone = kwargs.get("recipient_phone")
254
+ if not external_id or not phone:
255
+ return {
256
+ "status": "unknown",
257
+ "delivered_at": None,
258
+ "error_code": None,
259
+ "error_message": "Missing external_id or recipient_phone",
260
+ "details": {},
261
+ }
262
+
263
+ api_key = self._get_api_key()
264
+ if not api_key:
265
+ return {
266
+ "status": "unknown",
267
+ "delivered_at": None,
268
+ "error_code": None,
269
+ "error_message": "API_KEY missing",
270
+ "details": {},
271
+ }
272
+
273
+ try:
274
+ response = self._perform_request(
275
+ "get",
276
+ f"{self._api_base}/message-status",
277
+ params={
278
+ "apiKey": api_key,
279
+ "phoneNumber": phone,
280
+ "messageId": external_id,
281
+ },
282
+ timeout=5,
283
+ )
284
+ result = self._safe_json(response)
285
+ except Exception as exc:
286
+ return {
287
+ "status": "unknown",
288
+ "delivered_at": None,
289
+ "error_code": None,
290
+ "error_message": str(exc),
291
+ "details": {},
292
+ }
293
+
294
+ if result.get("success") is True:
295
+ status_label = result.get("statut", "Unknown")
296
+ status = self.STATUS_MAPPING_SMS.get(status_label, "unknown")
297
+ return {
298
+ "status": status,
299
+ "delivered_at": result.get("date"),
300
+ "error_code": None,
301
+ "error_message": None,
302
+ "details": {
303
+ "original_status": status_label,
304
+ "cost": result.get("cost"),
305
+ "currency": result.get("currency", "EUR"),
306
+ },
307
+ }
308
+
309
+ code = result.get("code")
310
+ message = result.get("message", "")
311
+ error_msg = self._get_error_message(code, message)
312
+ return {
313
+ "status": "unknown",
314
+ "delivered_at": None,
315
+ "error_code": code,
316
+ "error_message": error_msg,
317
+ "details": result,
318
+ }
319
+
320
+
321
+ class EmailPartnerProvider(PartnerProvider):
322
+ """Email Partner provider specialized for emails."""
323
+
324
+ name = "email_partner"
325
+ display_name = "Email Partner"
326
+ supported_types = ["EMAIL"]
327
+ services = ["email"]
328
+ config_keys = ["API_KEY", "FROM_EMAIL", "FROM_NAME"]
329
+ config_defaults = {
330
+ "FROM_EMAIL": "noreply@example.com",
331
+ "FROM_NAME": "",
332
+ }
333
+
334
+ def __init__(self, **kwargs: str | None) -> None:
335
+ """Initialize Email Partner provider."""
336
+ super().__init__(**kwargs)
337
+ self._api_base = self.API_BASE_EMAIL
338
+
339
+ def prepare_email(self, **kwargs: Any) -> Dict[str, Any]:
340
+ """Prepare email payload."""
341
+ email = kwargs.get("recipient_email")
342
+ if not email:
343
+ raise ValueError("recipient_email is required")
344
+
345
+ api_key = self._get_api_key()
346
+ if not api_key:
347
+ raise ValueError("API_KEY is required")
348
+
349
+ from_email = self._get_config_or_env("FROM_EMAIL", "noreply@example.com")
350
+ from_name = self._get_config_or_env("FROM_NAME", "")
351
+
352
+ payload: Dict[str, Any] = {
353
+ "apiKey": api_key,
354
+ "subject": kwargs.get("subject", ""),
355
+ "htmlContent": self._get_message_text(**kwargs),
356
+ "from": {"email": from_email, "name": from_name},
357
+ "to": [{"email": email}],
358
+ }
359
+
360
+ if self.attachments:
361
+ payload["attachments"] = [
362
+ {
363
+ "base64Content": base64.b64encode(att["content"]).decode("utf-8")
364
+ if isinstance(att["content"], bytes)
365
+ else att["content"],
366
+ "contentType": "application/octet-stream",
367
+ "filename": att["name"],
368
+ }
369
+ for att in self.attachments[: self.MAX_EMAIL_ATTACHMENTS]
370
+ ]
371
+
372
+ return payload
373
+
374
+ def send_email(self, **kwargs: Any) -> bool:
375
+ """Send an email via MailPartner."""
376
+ try:
377
+ payload = self.prepare_email(**kwargs)
378
+
379
+ response = self._perform_request(
380
+ "post",
381
+ f"{self._api_base}/send",
382
+ json_payload=payload,
383
+ headers={
384
+ "Content-Type": "application/json",
385
+ "Cache-Control": "no-cache",
386
+ },
387
+ )
388
+ result = self._safe_json(response)
389
+ except Exception as exc:
390
+ self._update_missive_event("FAILED", error_message=str(exc))
391
+ return False
392
+
393
+ if result.get("success") is True:
394
+ message_id = result.get("messageId") or result.get("message_id")
395
+ self._update_missive_event(
396
+ "SENT",
397
+ external_id=str(message_id) if message_id else None,
398
+ provider=self.name,
399
+ )
400
+ return True
401
+
402
+ code = result.get("code")
403
+ message = result.get("message", "")
404
+ error_msg = self._get_error_message(code, message)
405
+ self._update_missive_event("FAILED", error_message=error_msg)
406
+ return False
407
+
408
+ def cancel_email(self, **kwargs: Any) -> bool:
409
+ """Cancel an email."""
410
+ external_id = kwargs.get("external_id")
411
+ if not external_id:
412
+ return False
413
+
414
+ api_key = self._get_api_key()
415
+ if not api_key:
416
+ return False
417
+
418
+ try:
419
+ response = self._perform_request(
420
+ "get",
421
+ f"{self._api_base}/message-cancel",
422
+ params={"apiKey": api_key, "messageId": external_id},
423
+ timeout=5,
424
+ )
425
+ result = self._safe_json(response)
426
+ except Exception:
427
+ return False
428
+
429
+ return result.get("success") is True
430
+
431
+ def status_email(self, **kwargs: Any) -> Dict[str, Any]:
432
+ """Check email delivery status."""
433
+ external_id = kwargs.get("external_id")
434
+ if not external_id:
435
+ return {
436
+ "status": "unknown",
437
+ "delivered_at": None,
438
+ "error_code": None,
439
+ "error_message": "Missing external_id",
440
+ "details": {},
441
+ }
442
+
443
+ api_key = self._get_api_key()
444
+ if not api_key:
445
+ return {
446
+ "status": "unknown",
447
+ "delivered_at": None,
448
+ "error_code": None,
449
+ "error_message": "API_KEY missing",
450
+ "details": {},
451
+ }
452
+
453
+ try:
454
+ response = self._perform_request(
455
+ "get",
456
+ f"{self._api_base}/bulk-status",
457
+ params={"apiKey": api_key, "messageId": external_id},
458
+ timeout=5,
459
+ )
460
+ result = self._safe_json(response)
461
+ except Exception as exc:
462
+ return {
463
+ "status": "unknown",
464
+ "delivered_at": None,
465
+ "error_code": None,
466
+ "error_message": str(exc),
467
+ "details": {},
468
+ }
469
+
470
+ if result.get("success") is True:
471
+ status_list = result.get("StatutResponseList", [])
472
+ status_entry = status_list[0] if status_list else {}
473
+ status_label = status_entry.get("statut", "Unknown")
474
+ status = self.STATUS_MAPPING_EMAIL.get(status_label, "unknown")
475
+ return {
476
+ "status": status,
477
+ "delivered_at": status_entry.get("date"),
478
+ "error_code": None,
479
+ "error_message": None,
480
+ "details": {
481
+ "original_status": status_label,
482
+ "cost": status_entry.get("cost"),
483
+ "currency": status_entry.get("currency", "EUR"),
484
+ },
485
+ }
486
+
487
+ code = result.get("code")
488
+ message = result.get("message", "")
489
+ error_msg = self._get_error_message(code, message)
490
+ return {
491
+ "status": "unknown",
492
+ "delivered_at": None,
493
+ "error_code": code,
494
+ "error_message": error_msg,
495
+ "details": result,
496
+ }
497
+
498
+
499
+ class VoiceCallPartnerProvider(PartnerProvider):
500
+ """Voice Call Partner provider specialized for voice calls."""
501
+
502
+ name = "voice_call_partner"
503
+ display_name = "Voice Call Partner"
504
+ supported_types = ["VOICE_CALL"]
505
+ services = ["voice_message", "voice_call"]
506
+ config_keys = ["API_KEY"]
507
+
508
+ def __init__(self, **kwargs: str | None) -> None:
509
+ """Initialize Voice Call Partner provider."""
510
+ super().__init__(**kwargs)
511
+ self._api_base = self.API_BASE_VOICE
512
+
513
+ def prepare_voice_call(self, **kwargs: Any) -> Dict[str, Any]:
514
+ """Prepare voice call payload."""
515
+ phone = kwargs.get("recipient_phone")
516
+ if not phone:
517
+ raise ValueError("recipient_phone is required")
518
+
519
+ api_key = self._get_api_key()
520
+ if not api_key:
521
+ raise ValueError("API_KEY is required")
522
+
523
+ message = self._get_message_text(**kwargs)
524
+ if not message:
525
+ raise ValueError("Message text is required")
526
+
527
+ payload: Dict[str, Any] = {
528
+ "apiKey": api_key,
529
+ "phoneNumbers": phone,
530
+ "text": message,
531
+ "lang": "fr",
532
+ }
533
+
534
+ return payload
535
+
536
+ def send_voice_call(self, **kwargs: Any) -> bool:
537
+ """Send a voice call via VoicePartner."""
538
+ try:
539
+ payload = self.prepare_voice_call(**kwargs)
540
+
541
+ response = self._perform_request(
542
+ "post",
543
+ f"{self._api_base}/tts/send",
544
+ json_payload=payload,
545
+ headers={
546
+ "Content-Type": "application/json",
547
+ "Cache-Control": "no-cache",
548
+ },
549
+ )
550
+ result = self._safe_json(response)
551
+ except Exception as exc:
552
+ self._update_missive_event("FAILED", error_message=str(exc))
553
+ return False
554
+
555
+ if result.get("success") is True:
556
+ campaign_id = result.get("campaignId")
557
+ self._update_missive_event(
558
+ "SENT",
559
+ external_id=str(campaign_id) if campaign_id else None,
560
+ provider=self.name,
561
+ )
562
+ return True
563
+
564
+ code = result.get("code")
565
+ message = result.get("message", "")
566
+ error_msg = self._get_error_message(code, message)
567
+ self._update_missive_event("FAILED", error_message=error_msg)
568
+ return False
569
+
570
+ def cancel_voice_call(self, **kwargs: Any) -> bool:
571
+ """Cancel a voice call."""
572
+ external_id = kwargs.get("external_id")
573
+ if not external_id:
574
+ return False
575
+
576
+ api_key = self._get_api_key()
577
+ if not api_key:
578
+ return False
579
+
580
+ try:
581
+ response = self._perform_request(
582
+ "delete",
583
+ f"{self._api_base}/campaign/cancel/{api_key}/{external_id}",
584
+ timeout=5,
585
+ )
586
+ result = self._safe_json(response)
587
+ except Exception:
588
+ return False
589
+
590
+ return result.get("success") is True
591
+
592
+ def status_voice_call(self, **kwargs: Any) -> Dict[str, Any]:
593
+ """Check voice call delivery status."""
594
+ external_id = kwargs.get("external_id")
595
+ if not external_id:
596
+ return {
597
+ "status": "unknown",
598
+ "delivered_at": None,
599
+ "error_code": None,
600
+ "error_message": "Missing external_id",
601
+ "details": {},
602
+ }
603
+
604
+ api_key = self._get_api_key()
605
+ if not api_key:
606
+ return {
607
+ "status": "unknown",
608
+ "delivered_at": None,
609
+ "error_code": None,
610
+ "error_message": "API_KEY missing",
611
+ "details": {},
612
+ }
613
+
614
+ try:
615
+ response = self._perform_request(
616
+ "get",
617
+ f"{self._api_base}/campaign/{api_key}/{external_id}",
618
+ timeout=5,
619
+ )
620
+ result = self._safe_json(response)
621
+ except Exception as exc:
622
+ return {
623
+ "status": "unknown",
624
+ "delivered_at": None,
625
+ "error_code": None,
626
+ "error_message": str(exc),
627
+ "details": {},
628
+ }
629
+
630
+ if result.get("success") is True:
631
+ status_label = result.get("status", "Unknown")
632
+ status = status_label.lower()
633
+ return {
634
+ "status": status,
635
+ "delivered_at": result.get("endDate"),
636
+ "error_code": None,
637
+ "error_message": None,
638
+ "details": result,
639
+ }
640
+
641
+ code = result.get("code")
642
+ message = result.get("message", "")
643
+ error_msg = self._get_error_message(code, message)
644
+ return {
645
+ "status": "unknown",
646
+ "delivered_at": None,
647
+ "error_code": code,
648
+ "error_message": error_msg,
649
+ "details": result,
650
+ }