diff --git a/voxtelesys_integration/api/sms.py b/voxtelesys_integration/api/sms.py new file mode 100644 index 0000000..2e062e3 --- /dev/null +++ b/voxtelesys_integration/api/sms.py @@ -0,0 +1,325 @@ +""" +voxtelesys_integration.api.sms +============================== + +Voxtelesys SMS API integration. Two surfaces: + + 1. send_sms(to, body) — whitelisted RPC for the desk UI / scripts. + 2. handle_inbound_sms() — public webhook for inbound MO messages and + delivery receipts posted from Voxtelesys. + +All SMS activity is persisted to the Voxtelesys SMS Log doctype, mirroring how +voxtelesys.py persists calls to Voxtelesys Call Log. When the message is sent +or received in the context of an HD Ticket / Contact / Lead, a Dynamic Link +row is attached so the message shows up on those forms. + +Endpoint shape (per Voxtelesys docs): + POST https://smsapi.voxtelesys.net/api/v1/sms + Authorization: Bearer + Content-Type: application/json + Body: { "to": ["+15551234567"], "from": "+15557654321", "body": "hi" } +""" +from __future__ import annotations + +import hashlib +import hmac +import json +from typing import Any + +import frappe +import requests +from frappe import _ +from frappe.utils import now_datetime + +SMS_API_BASE = "https://smsapi.voxtelesys.net/api/v1" + + +# ----------------------------------------------------------------------------- +# Outbound +# ----------------------------------------------------------------------------- +@frappe.whitelist() +def send_sms( + to: str, + body: str, + from_: str = "", + reference_doctype: str = "", + reference_name: str = "", +) -> dict[str, Any]: + """Send a single SMS via Voxtelesys. + + Args: + to: E.164 destination (e.g. "+12025551234"). Voxtelesys accepts an + array but we keep it one-to-one to match how the desk UI calls us. + body: Message text (<=1600 chars; longer is auto-segmented by Voxtelesys). + from_: Override sender DID; defaults to Voxtelesys Settings.caller_id. + reference_doctype/name: Optional Frappe doc to link this SMS to so it + appears on that form's history sidebar. + """ + from voxtelesys_integration.voxtelesys_integration.doctype.voxtelesys_settings.voxtelesys_settings import ( + VoxtelesysSettings, + ) + + if not VoxtelesysSettings.is_enabled(): + frappe.throw(_("Voxtelesys integration is not enabled.")) + + to = (to or "").strip() + body = (body or "").strip() + if not to: + frappe.throw(_("Destination number is required.")) + if not body: + frappe.throw(_("Message body is required.")) + + sender = (from_ or VoxtelesysSettings.get_caller_id() or "").strip() + if not sender: + frappe.throw(_("No sender number configured. Set caller_id in Voxtelesys Settings.")) + + token = VoxtelesysSettings.get_api_token() + payload = {"to": [to], "from": sender, "body": body} + + try: + resp = requests.post( + f"{SMS_API_BASE}/sms", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=payload, + timeout=15, + ) + resp.raise_for_status() + result = resp.json() if resp.content else {} + except requests.HTTPError as exc: + frappe.log_error( + f"Status {exc.response.status_code}: {exc.response.text}", + "Voxtelesys: SMS send failed", + ) + frappe.throw( + _("Voxtelesys SMS API error: {0}").format(str(exc)), + title=_("SMS Failed"), + ) + + message_id = result.get("id") or result.get("message_id") or result.get("sid") or "" + + log = _upsert_sms_log( + message_id=message_id, + direction="Outbound", + status="Sent", + from_number=sender, + to_number=to, + body=body, + sent_at=now_datetime(), + ) + + if reference_doctype and reference_name: + _add_link(log, reference_doctype, reference_name) + + return {"ok": True, "sms_log": log.name, "message_id": message_id} + + +# ----------------------------------------------------------------------------- +# Inbound webhook +# ----------------------------------------------------------------------------- +@frappe.whitelist(allow_guest=True) +def handle_inbound_sms(): + """Webhook target for inbound SMS and delivery receipts from Voxtelesys. + + Voxtelesys' SMS webhook payload shape is documented in their portal; we + accept both their snake_case format and a Twilio-compatible mapping so the + same endpoint can serve as a fallback / multi-provider relay. + """ + if frappe.request.method != "POST": + frappe.throw(_("Method not allowed"), frappe.PermissionError) + + raw = frappe.request.data + sig = frappe.request.headers.get("X-Voxtelesys-Signature", "") + if not _verify_signature(raw, sig): + frappe.throw(_("Invalid webhook signature"), frappe.PermissionError) + + data = _parse_webhook_body(raw) + + message_id = ( + data.get("id") + or data.get("message_id") + or data.get("MessageSid") + or data.get("sid") + or "" + ) + from_number = data.get("from") or data.get("From") or "" + to_number = data.get("to") or data.get("To") or "" + body = data.get("body") or data.get("Body") or data.get("text") or "" + raw_status = (data.get("status") or data.get("MessageStatus") or "").lower() + + # Heuristic: if there's a body and no status field, treat as a new MO message. + # Otherwise treat as a delivery receipt for an existing outbound row. + is_status_update = bool(raw_status) and not body + + status_map = { + "queued": "Queued", + "sending": "Sending", + "sent": "Sent", + "delivered": "Delivered", + "undelivered": "Undelivered", + "failed": "Failed", + "received": "Received", + } + status = status_map.get(raw_status, "Received" if not is_status_update else "Sent") + + fields: dict[str, Any] = { + "status": status, + "from_number": from_number, + "to_number": to_number, + } + if body and not is_status_update: + fields["body"] = body + fields["direction"] = "Inbound" + fields["received_at"] = now_datetime() + + log = _upsert_sms_log(message_id=message_id, **fields) + + # Inbound MO: link to contact and (optionally) auto-open / create an HD Ticket + if not is_status_update and body: + _link_contact(log, from_number) + _maybe_attach_to_ticket(log, from_number, body) + frappe.publish_realtime( + "voxtelesys_sms_received", + { + "sms_log": log.name, + "message_id": message_id, + "from": from_number, + "to": to_number, + "body": body[:200], + }, + ) + + frappe.local.response["http_status_code"] = 200 + return {"ok": True, "sms_log": log.name} + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- +def _verify_signature(body: bytes, signature: str) -> bool: + secret = frappe.db.get_single_value("Voxtelesys Settings", "webhook_secret") + if not secret: + return True + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature or "") + + +def _parse_webhook_body(raw: bytes) -> dict[str, Any]: + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + return dict(frappe.form_dict) + + +def _upsert_sms_log(message_id: str, **fields): + if message_id and frappe.db.exists("Voxtelesys SMS Log", {"message_id": message_id}): + name = frappe.db.get_value("Voxtelesys SMS Log", {"message_id": message_id}) + doc = frappe.get_doc("Voxtelesys SMS Log", name) + for k, v in fields.items(): + if v is not None: + doc.set(k, v) + doc.save(ignore_permissions=True) + else: + doc = frappe.new_doc("Voxtelesys SMS Log") + doc.message_id = message_id + for k, v in fields.items(): + doc.set(k, v) + doc.insert(ignore_permissions=True) + frappe.db.commit() + return doc + + +def _link_contact(doc, phone_number: str): + if not phone_number: + return + normalised = phone_number.replace("+", "").replace(" ", "").replace("-", "") + contact = frappe.db.sql( + """ + SELECT parent FROM `tabContact Phone` + WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s + LIMIT 1 + """, + {"num": f"%{normalised}"}, + as_dict=True, + ) + if contact: + _add_link(doc, "Contact", contact[0].parent) + + +def _add_link(doc, link_doctype: str, link_name: str): + for row in doc.get("links") or []: + if row.link_doctype == link_doctype and row.link_name == link_name: + return + doc.append("links", {"link_doctype": link_doctype, "link_name": link_name}) + doc.save(ignore_permissions=True) + + +def _maybe_attach_to_ticket(doc, from_number: str, body: str): + """If the sender has an open HD Ticket, attach this SMS as a Communication; + otherwise leave it on the Contact (or unattached) — agents can convert it + to a ticket manually from the SMS Log form.""" + if not from_number: + return + + normalised = from_number.replace("+", "").replace(" ", "").replace("-", "") + contact = frappe.db.sql( + """ + SELECT parent FROM `tabContact Phone` + WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s + LIMIT 1 + """, + {"num": f"%{normalised}"}, + as_dict=True, + ) + if not contact: + return + + open_ticket = frappe.db.get_value( + "HD Ticket", + {"contact": contact[0].parent, "status": ["not in", ["Closed", "Resolved"]]}, + "name", + order_by="creation desc", + ) + if not open_ticket: + return + + _add_link(doc, "HD Ticket", open_ticket) + + # Also create a Communication row so the SMS body shows up in the ticket thread. + try: + comm = frappe.new_doc("Communication") + comm.communication_type = "Communication" + comm.communication_medium = "SMS" + comm.sent_or_received = "Received" + comm.subject = f"Inbound SMS from {from_number}" + comm.content = body + comm.reference_doctype = "HD Ticket" + comm.reference_name = open_ticket + comm.insert(ignore_permissions=True) + except Exception: + frappe.log_error(frappe.get_traceback(), "Voxtelesys: Failed to create SMS Communication") + + +@frappe.whitelist() +def get_linked_sms_logs(doctype: str, name: str, **kwargs): + """Return SMS history linked to the given document, for the form sidebar.""" + rows = frappe.db.sql( + """ + SELECT vsl.name, vsl.direction, vsl.status, vsl.from_number, vsl.to_number, + vsl.body, vsl.sent_at, vsl.received_at + FROM `tabVoxtelesys SMS Log` vsl + INNER JOIN `tabDynamic Link` dl + ON dl.parent = vsl.name AND dl.parenttype = 'Voxtelesys SMS Log' + AND dl.link_doctype = %(doctype)s AND dl.link_name = %(name)s + ORDER BY COALESCE(vsl.received_at, vsl.sent_at) DESC + LIMIT 50 + """, + {"doctype": doctype, "name": name}, + as_dict=True, + ) + for r in rows: + r["doctype"] = "Voxtelesys SMS Log" + return rows