v2.1: add SMS module + SMS Log doctype + fixtures: sms.py
This commit is contained in:
parent
6c2cb4a55b
commit
05dc1c0dce
1 changed files with 325 additions and 0 deletions
325
voxtelesys_integration/api/sms.py
Normal file
325
voxtelesys_integration/api/sms.py
Normal file
|
|
@ -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 <token>
|
||||
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
|
||||
Loading…
Reference in a new issue