178 lines
8.0 KiB
PHP
178 lines
8.0 KiB
PHP
<?php
|
|||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sinch Engage (formerly MessageMedia) Messages API adapter (server-only).
|
||
|
|
*
|
||
|
|
* Verified against the Sinch Engage OpenAPI spec (github.com/messagemedia/sinchengage-developer):
|
||
|
|
* POST {SINCH_API_BASE}/v1/messages
|
||
|
|
* Authorization: Basic base64(api_key:api_secret)
|
||
|
|
* Account: <sub-account id> (optional; lets a parent key send as a campus sub-account)
|
||
|
|
* { "messages": [ { "content", "destination_number", "source_number", "format": "SMS",
|
||
|
|
* "delivery_report": true, "metadata": {...}, "message_expiry_timestamp" } ] }
|
||
|
|
*
|
||
|
|
* Regional hosts: https://us.app.api.sinch.com (US), https://eu.app.api.sinch.com, https://au.app.api.sinch.com
|
||
|
|
*
|
||
|
|
* Config: SINCH_API_BASE, SINCH_API_KEY, SINCH_API_SECRET, SINCH_DEFAULT_SOURCE_NUMBER,
|
||
|
|
* SINCH_USE_CAMPUS_SUBACCOUNTS (default true), SINCH_INTERNAL_NOTIFY_NUMBER (optional)
|
||
|
|
*
|
||
|
|
* Idempotency: a lead is only sent once. The DB row is marked "sending" before the
|
||
|
|
* request and updated with the provider message_id after; retries skip rows that are
|
||
|
|
* not in a retryable state.
|
||
|
|
*/
|
||
|
|
final class Sinch
|
||
|
|
{
|
||
|
|
public const TEMPLATE_DEFAULT = "Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string,mixed> $lead
|
||
|
|
* @param array<string,mixed> $campus
|
||
|
|
* @return array{status:string, detail:?string, message_id:?string, transient:bool}
|
||
|
|
*/
|
||
|
|
public static function sendVisitConfirmation(array $lead, array $campus): array
|
||
|
|
{
|
||
|
|
if (!(int)$lead['sms_consent']) {
|
||
|
|
return ['status' => 'skipped', 'detail' => 'no sms consent', 'message_id' => null, 'transient' => false];
|
||
|
|
}
|
||
|
|
$to = $lead['phone_e164'] ?? null;
|
||
|
|
if (!$to || !Phone::isE164($to)) {
|
||
|
|
return ['status' => 'failed', 'detail' => 'no valid phone', 'message_id' => null, 'transient' => false];
|
||
|
|
}
|
||
|
|
$content = self::buildConfirmation($lead, $campus);
|
||
|
|
$from = self::sourceNumberFor($campus);
|
||
|
|
$account = self::accountFor($campus);
|
||
|
|
|
||
|
|
if (Config::isMock()) {
|
||
|
|
Log::info('[mock] Sinch send', ['account' => $account, 'source' => $from, 'chars' => mb_strlen($content)]);
|
||
|
|
return ['status' => 'mock', 'detail' => 'mock mode; would send from ' . ($from ?? 'default') . ($account ? " as $account" : ''), 'message_id' => 'mock-' . substr($lead['submission_id'], 0, 8), 'transient' => false];
|
||
|
|
}
|
||
|
|
|
||
|
|
$message = [
|
||
|
|
'content' => $content,
|
||
|
|
'destination_number' => $to,
|
||
|
|
'format' => 'SMS',
|
||
|
|
'delivery_report' => true,
|
||
|
|
'metadata' => ['submission_id' => $lead['submission_id'], 'campus' => $campus['id'], 'source' => 'visit.waterschurch.org'],
|
||
|
|
'message_expiry_timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() + 6 * 3600),
|
||
|
|
];
|
||
|
|
if ($from) {
|
||
|
|
$message['source_number'] = $from;
|
||
|
|
}
|
||
|
|
$r = self::post('/v1/messages', ['messages' => [$message]], $account);
|
||
|
|
if ($r['status'] === 202 || $r['status'] === 200) {
|
||
|
|
$msg = $r['json']['messages'][0] ?? [];
|
||
|
|
$id = is_array($msg) ? ($msg['message_id'] ?? null) : null;
|
||
|
|
$status = is_array($msg) ? ($msg['status'] ?? 'queued') : 'queued';
|
||
|
|
// Only claim success when the provider actually accepted the message.
|
||
|
|
if (!$id) {
|
||
|
|
return ['status' => 'failed', 'detail' => 'provider response missing message_id', 'message_id' => null, 'transient' => true];
|
||
|
|
}
|
||
|
|
return ['status' => 'sent', 'detail' => "provider status: $status", 'message_id' => $id, 'transient' => false];
|
||
|
|
}
|
||
|
|
$detail = is_array($r['json']) ? json_encode($r['json']) : ($r['error'] ?? "HTTP {$r['status']}");
|
||
|
|
Log::warn('Sinch send failed', ['status' => $r['status'], 'detail' => substr((string)$detail, 0, 300)]);
|
||
|
|
return ['status' => 'failed', 'detail' => substr((string)$detail, 0, 500), 'message_id' => null, 'transient' => Http::isTransient($r['status'], $r['error'])];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Optional internal heads-up to campus staff. Enabled only when
|
||
|
|
* SINCH_INTERNAL_NOTIFY_NUMBER is set. Contains no visitor PII beyond first name.
|
||
|
|
*
|
||
|
|
* @param array<string,mixed> $lead
|
||
|
|
* @param array<string,mixed> $campus
|
||
|
|
*/
|
||
|
|
public static function sendInternalLeadNotification(array $lead, array $campus): ?array
|
||
|
|
{
|
||
|
|
$to = Config::get('SINCH_INTERNAL_NOTIFY_NUMBER');
|
||
|
|
if (!$to) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$content = sprintf('New first-time visitor plan: %s → %s%s. View in the leads export.', $lead['first_name'], $campus['name'], $lead['visit_date'] ? ' on ' . $lead['visit_date'] : '');
|
||
|
|
if (Config::isMock()) {
|
||
|
|
Log::info('[mock] Sinch internal notify', ['to' => $to]);
|
||
|
|
return ['status' => 'mock'];
|
||
|
|
}
|
||
|
|
$r = self::post('/v1/messages', ['messages' => [['content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'source_number' => Config::get('SINCH_DEFAULT_SOURCE_NUMBER')]]], null);
|
||
|
|
return ['status' => ($r['status'] === 202 || $r['status'] === 200) ? 'sent' : 'failed'];
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function normalizePhoneNumber(string $raw): ?string
|
||
|
|
{
|
||
|
|
return Phone::normalizeNanp($raw);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string,mixed> $lead
|
||
|
|
* @param array<string,mixed> $campus
|
||
|
|
*/
|
||
|
|
public static function buildConfirmation(array $lead, array $campus): string
|
||
|
|
{
|
||
|
|
$template = Config::get('SINCH_CONFIRMATION_TEMPLATE') ?: self::TEMPLATE_DEFAULT;
|
||
|
|
$when = self::serviceOrDate($lead, $campus);
|
||
|
|
$vars = [
|
||
|
|
'{{first_name}}' => $lead['first_name'],
|
||
|
|
'{{campus_name}}' => $campus['name'],
|
||
|
|
'{{service_or_date}}' => $when,
|
||
|
|
'{{address}}' => $campus['address']['line1'] . ', ' . $campus['address']['city'],
|
||
|
|
];
|
||
|
|
$text = strtr($template, $vars);
|
||
|
|
$text = preg_replace('/\s+/', ' ', trim($text)) ?? $text;
|
||
|
|
// Keep to a sane length; carriers concatenate but cost rises. 320 chars = 2 segments (GSM) or ~4 (UCS-2).
|
||
|
|
return mb_substr($text, 0, 320);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string,mixed> $lead
|
||
|
|
* @param array<string,mixed> $campus
|
||
|
|
*/
|
||
|
|
public static function serviceOrDate(array $lead, array $campus): string
|
||
|
|
{
|
||
|
|
$parts = [];
|
||
|
|
if (!empty($lead['visit_date'])) {
|
||
|
|
$ts = strtotime($lead['visit_date']);
|
||
|
|
$parts[] = $ts ? date('D, M j', $ts) : $lead['visit_date'];
|
||
|
|
}
|
||
|
|
if (!empty($lead['service_time'])) {
|
||
|
|
$parts[] = $lead['service_time'];
|
||
|
|
}
|
||
|
|
if (!$parts) {
|
||
|
|
$parts[] = Campuses::timesSummary($campus);
|
||
|
|
}
|
||
|
|
return implode(' · ', $parts);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @param array<string,mixed> $campus */
|
||
|
|
public static function sourceNumberFor(array $campus): ?string
|
||
|
|
{
|
||
|
|
if (Config::bool('SINCH_USE_CAMPUS_NUMBERS', true) && !empty($campus['smsNumber'])) {
|
||
|
|
return $campus['smsNumber'];
|
||
|
|
}
|
||
|
|
return Config::get('SINCH_DEFAULT_SOURCE_NUMBER');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @param array<string,mixed> $campus */
|
||
|
|
public static function accountFor(array $campus): ?string
|
||
|
|
{
|
||
|
|
if (Config::bool('SINCH_USE_CAMPUS_SUBACCOUNTS', true) && !empty($campus['sinchAccountId'])) {
|
||
|
|
return $campus['sinchAccountId'];
|
||
|
|
}
|
||
|
|
return Config::get('SINCH_ACCOUNT_ID');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string,mixed> $payload
|
||
|
|
* @return array{status:int, body:string, json:mixed, error:?string}
|
||
|
|
*/
|
||
|
|
private static function post(string $path, array $payload, ?string $account): array
|
||
|
|
{
|
||
|
|
$base = rtrim(Config::get('SINCH_API_BASE', 'https://us.app.api.sinch.com') ?? '', '/');
|
||
|
|
$key = Config::require('SINCH_API_KEY');
|
||
|
|
$secret = Config::require('SINCH_API_SECRET');
|
||
|
|
$headers = ['Authorization' => 'Basic ' . base64_encode("$key:$secret")];
|
||
|
|
if ($account) {
|
||
|
|
$headers['Account'] = $account;
|
||
|
|
}
|
||
|
|
return Http::json('POST', $base . $path, $payload, $headers, 10);
|
||
|
|
}
|
||
|
|
}
|