diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bdf419e7..308e8933b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow.
### Upgrading
-Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's five database changes (3.5.4 to 3.5.9) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees job in Maintenance > Cron is set up by itself, and so are Manager Check-in and Managed Instances on an install whose config.php turns managed mode on (see New Features & Updates).
+Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's eight database changes (3.5.4 to 3.6.1) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees and Text Messages jobs in Maintenance > Cron are set up by themselves, and so are Manager Check-in and Managed Instances on an install whose config.php turns managed mode on (see New Features & Updates).
### Security
@@ -27,6 +27,12 @@ Update from Maintenance > Update — Update ITFlow hands the job to cron, which
- API keys: agents are notified in ITFlow a week before an API key expires, so an integration doesn't stop without warning.
- Payment Providers: Stripe's fee on a card payment is now recorded as an expense about 15 minutes after the payment, instead of the next morning. A new Payment Provider Fees job in Maintenance > Cron does it, and you can run it more often there. Record fees now still works any time.
- Payment Providers: clients can pay the card fee. Turn on Clients pay the card fee on Stripe in Admin > Payment Providers and enter what Stripe charges you, as 2.9% plus 0.30. Each card payment is then made bigger by Stripe's fee on it, so you get the full invoice. The fee goes on the invoice as a Card Fee line, with no tax, when the payment goes through. Clients see it before they pay: on Pay invoices and the checkout page in the client portal, on the page an invoice's link opens, and next to autopay. It applies to payments in the client portal, through an invoice's link, by autopay, and when you charge a saved card, where the Charge Saved Card window shows it too. One payment for several invoices has one fee, shared over them. A client can be let off it with No card fee on their Billing tab. Refunding a card payment from ITFlow takes its Card Fee line back off the invoice. It is off until you turn it on. Card surcharges have rules where you and your clients are - in the US, for one, the card networks cap them and don't allow them on debit cards - so check them before turning it on.
+- Text messaging: ITFlow can text through Skyetel, from one support number. Set it up in Admin > Integrations > Text Messaging with your Skyetel SID, secret and number, paste the address it shows into the number's SMS Callback URL in Skyetel's portal, and send a test text. A US number has to be registered for texting in Skyetel's portal, or carriers block what it sends.
+- Tickets: a text from one of your contacts opens a ticket for them, and they're texted back its number. What they text next goes on it as their reply, with any photos attached, and opens it again if it was resolved. Pick **Text reply** on a ticket to answer by text - it goes to the contact's mobile as plain text and shows in the conversation with whether it went. Turn this off, or open tickets for unknown numbers too, in Admin > Settings > Tickets > Text to ticket.
+- Texts: a new page under Support for what comes to your number that isn't a contact. Unmatched holds texts from numbers that aren't a contact's - open a ticket from one (saving the number on the contact as you go), dismiss it, or mark it spam, which blocks the number. Codes holds verification and MFA codes, kept out of tickets: only agents who can view credentials see them, each code is masked until revealed (every reveal is in the audit log), it can be revealed for 15 minutes, and its words are wiped after a day. Spam holds texts from blocked numbers and the usual bait.
+- Texts to clients: contacts who agree to texts hear about their work by text - a ticket opened for them and resolved (and, if you choose, each emailed reply), a visit or remote work booked or cancelled, a reminder before it, and invoices and payment reminders with a link to pay. Replying to a text about a ticket adds to the ticket, and a reply to the resolved text opens it again. Choose which in Admin > Settings > Notifications > Texts to clients. Texts ITFlow sends by itself that would land between 9pm and 8am wait for the morning.
+- Contacts: nobody is texted these until they agree. Turn on **Texts** on a contact (with their say-so), use **Ask them by text** on their page - a YES turns them on - or they turn it on under Your account in the client portal. The contact's page says when and how they agreed, or that they replied STOP.
+- Text messaging: STOP, START and HELP are answered as carriers require. A number that replies STOP gets nothing more until it replies START - Text reply is greyed out for it - and the ticket says so. Admin > Text Messaging lists who opted out and what is blocked; Maintenance > Text Messages has every text sent and received, with retry and cancel.
- Managed mode: one ITFlow can now manage others - a client's, a reseller's or your own hosted ones. It's off until config.php turns it on, each side on its own: `$config_enable_managed_instances = true;` on the ITFlow that does the managing adds Admin > Managed Instances, and `$config_enable_managed_by = true;` on each one it manages adds Admin > Managed By. An install with neither line sees nothing of it - no menu, pages, cron jobs or sign-in. Then add one in Admin > Managed Instances, and paste the code it gives you into the other ITFlow's Admin > Managed By. From then on that ITFlow checks in every minute, and you see how each one is doing: its version and whether an update is waiting (a security one stands out), whether cron is running, its last backup, the errors it logged today, and its agents against its seat limit - and you're notified when one stops checking in. From the manager you can update an instance, or let it update itself a set number of days after a release comes out and straight away for a security release; set how many agents it can have and its white-label key; make it read-only, where its people can still sign in, look and export, or suspend it, with a message of your own; and sign in to it as support in a new tab, for two hours, with no password or seat of its own and every sign-in in its audit log. What the manager sets is locked on the instance, which says so - everything else stays the instance's own, and its admins can disconnect it unless its config.php keeps it managed (`$config_manager_required = true`, for a provider hosting it, which turns Managed By on as well). Instances only ever call the manager, so they can sit behind a firewall, and they send how they're doing - never client data.
- Export Data: the documentation PDF can go out as a secure link, like a login, document or file. Share a link next to Download PDF - or Share the IT documentation on a client's Shared Links - makes the PDF from the parts you tick and gives you a link that works for as long and as many times as you set, with a passphrase or an emailed code first if you like, and tells you when it's downloaded. The PDF is made when the link is, so later changes aren't in it, and it's kept encrypted until the link stops working - then it's deleted. With passwords and 2FA secrets in it, the PDF is locked with a password of its own too, as a download is, and the page the link opens says so.
- Maintenance > Update is easier to follow. It opens with where your install stands - up to date, an update available, an update running, or the database still to finish - and the one button for it, now Update ITFlow instead of Queue Update. Below it is what's new: the release notes for the versions between yours and the latest, read from the changelog each time ITFlow checks for updates, with the developer notes folded away and the commits behind them a click further. Before you update it lists what to do first, and says whether any of ITFlow's own files have been changed on your install, which updating would put back. Once you click Update ITFlow, the page shows the update running, refreshes itself until it's done, and then says when ITFlow was updated, or what stopped it. Alongside are this install's version, branch, database, when it last checked and updated, whether cron is running, and the command to run the same update from a shell, with the folder and user filled in for your server.
@@ -43,6 +49,15 @@ Update from Maintenance > Update — Update ITFlow hands the job to cron, which
### Developer Updates
+- Text messaging is `functions/sms.php`. Database 3.6.0 adds `sms_providers` (the service, its SID and secret, the number in E.164 digits, the key in its inbound address), `sms_messages` (every text both ways - `sms_message_direction` `out`/`in`, `sms_message_status` queued/sending/sent/failed/blocked/cancelled going out and ticket/unmatched/code/spam/keyword/dismissed coming in) and `sms_numbers` (a number's name, `sms_number_opted_out_at`, `sms_number_blocked_at`), and `settings.config_ticket_sms_parse`, `config_ticket_sms_unknown_senders` and `config_ticket_sms_acknowledge`, read where they're used by `smsTicketSettings()`.
+- Sending: `smsQueue($e164, $text, $meta)` splits at `SMS_TEXT_LIMIT` and refuses a number that replied STOP (a `blocked` row) unless `force` - only the STOP answer itself. `smsSendQueued()` claims a row before sending, so a request and the new Text Messages job (`cron/sms_queue.php`, `smsQueueRun()`, every minute) can't both send it; a 401 fails at once, a timeout, 403/429 or 5xx is tried again up to `SMS_MAX_ATTEMPTS`. The job keeps under Skyetel's 300 texts in five minutes and a second apart, and `smsTidy()` wipes codes after `SMS_CODE_KEEP_HOURS`, deletes spam and dismissed texts after `SMS_SPAM_KEEP_DAYS` and the rest after `SMS_KEEP_DAYS`. Providers are `smsProviderKinds()` - another service is an entry there with its own send function and `api/sms/ AI Providers Text Messaging
| Number | +What | ++ |
|---|---|---|
|
+ = escapeHtml(smsNumberText($number)) ?>
+
+ = escapeHtml($label !== '' ? $label : $contacts[0]['contact_name'] . ($contacts[0]['client_name'] ? ' - ' . $contacts[0]['client_name'] : '')) ?>
+
+ |
+
+
+ Replied STOP = escapeHtml(timeAgo($row['sms_number_opted_out_at'])) ?>
+
+
+ Blocked as spam = escapeHtml(timeAgo($row['sms_number_blocked_at'])) ?>= $row['user_name'] ? ' by ' . escapeHtml($row['user_name']) : '' ?>
+
+ |
+ + + Opt back in + + + Unblock + + | +
| = $sort_link('sms_provider_name', 'Provider') ?> | += $sort_link('sms_provider_number', 'Number') ?> | +Texts come back to | += $sort_link('sms_provider_last_inbound_at', 'Last text in') ?> | ++ |
|---|---|---|---|---|
|
+
+
+
+
+ = escapeHtml($name) ?>
+
+ Off
+
+
+
+ = escapeHtml($row['sms_provider_description']) ?>
+
+
+ = escapeHtml(smsNumberText($row['sms_provider_number'])) ?>
+ |
+ = escapeHtml(smsNumberText($row['sms_provider_number'])) ?> | +
+
+
+
+
+ In = escapeHtml($name) ?>'s portal: the number > SMS > Callback URL, by POST.
+ |
+ + + = escapeHtml(timeAgo($row['sms_provider_last_inbound_at'])) ?> + + None yet - text the number to try it + + | +
+
+
+
+
+ |
+
+ Texts + + + = $nav_texts_waiting ?> + +
+ +| = $sort_link('sms_message_number', 'From') ?> | +Code | +Text | += $sort_link('sms_message_created_at', 'When') ?> | ++ |
|---|---|---|---|---|
|
+ = escapeHtml($label !== '' && $codes ? $label : smsNumberText($number)) ?>
+
+ = escapeHtml(smsNumberText($number)) ?>
+
+ = escapeHtml($who) ?>
+
+
+ = nl2br(escapeHtml(mb_strimwidth($body, 0, 200, '...'))) ?>
+ |
+
+ + + Wiped + + = escapeHtml(smsCodeMasked((string) $row['sms_message_code'])) ?> + + + + = escapeHtml(smsCodeMasked((string) $row['sms_message_code'])) ?> + Expired + + | + +
+ = nl2br(escapeHtml(mb_strimwidth($body, 0, 600, '...'))) ?>
+
+ = count($media) ?> photo= count($media) === 1 ? '' : 's' ?> or file= count($media) === 1 ? '' : 's' ?> - fetched onto the ticket if one is opened from it
+
+ |
+
+ = escapeHtml(timeAgo($row['sms_message_created_at'])) ?>
+ = escapeHtml(date('M j, g:i A', strtotime($row['sms_message_created_at']))) ?>
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
' . escapeHtml($note) . '
') . "', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = 0, ticket_reply_ticket_id = $ticket_id"); + } +} + +/* A text's words as a reply or a ticket's details - its lines kept, nothing in it taken as HTML */ +function smsTextHtml(string $text): string +{ + return $text === '' ? '' : '' . nl2br(escapeHtml($text)) . '
'; +} + +/* + * Open a ticket from a text - for a contact, a client, or no client (from someone you don't + * know). $by is the agent opening it from Texts (0: it came in), who isn't texted the number; + * $more_ids, other texts from the number that go on it with this one. Its id. + */ +function smsTicketOpen(int $message_id, string $from, string $text, array $media, int $contact_id, int $client_id, string $subject = '', int $by = 0, array $more_ids = []): int +{ + global $mysqli; + + $settings = smsTicketSettings(); + $config_ticket_prefix = (string) $settings['config_ticket_prefix']; + + mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number), config_ticket_next_number = config_ticket_next_number + 1 WHERE company_id = 1"); + $ticket_number = mysqli_insert_id($mysqli); + + $contact_name = $contact_id ? trim((string) getFieldById('contacts', $contact_id, 'contact_name')) : ''; + $who = $contact_name !== '' ? $contact_name : smsNumberText($from); + if (trim($subject) === '') { + $first_line = trim(strtok($text, "\n") ?: ''); + $subject = $first_line !== '' ? mb_strimwidth($first_line, 0, 80, '...') : "Text from $who"; + } + $prefix = escapeSql($config_ticket_prefix); + $details = smsSql(smsTextHtml($text !== '' ? $text : '(A photo or file, attached)')); + $url_key = randomString(32); + $billable = intval($settings['config_ticket_default_billable']) ? 1 : 0; + + mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$prefix', ticket_number = $ticket_number, ticket_source = 'Text', ticket_subject = '" . escapeSql(mb_substr($subject, 0, 500)) . "', + ticket_details = '$details', ticket_priority = 'Medium', ticket_status = 1, ticket_billable = $billable, ticket_created_by = " . intval($by) . ", + ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id"); + $ticket_id = mysqli_insert_id($mysqli); + applyTicketSla($ticket_id); + $ids = implode(',', array_unique(array_merge([intval($message_id)], array_map('intval', $more_ids)))); + mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_ticket_id = $ticket_id, sms_message_contact_id = $contact_id, sms_message_client_id = $client_id WHERE sms_message_id IN ($ids)"); + + if ($media) { + smsSaveMedia($media, $ticket_id, null, $message_id); + } + + $reference = $config_ticket_prefix . $ticket_number; + logAudit('Ticket', 'Create', escapeSql($by ? "Text: ticket $reference was opened from a text from $who ($subject)" : "Text: $who texted in and ticket $reference was opened ($subject)"), $client_id, $ticket_id); + logTicketHistory($ticket_id, escapeSql("Opened from a text from $who" . ($contact_name !== '' ? ' (' . smsNumberText($from) . ')' : ''))); + + // The new-ticket address and the client's contacts who hear about new tickets - by email, as for any other + ticketMailNewTicketAddress($ticket_id, 'text'); + ticketMailColleaguesNew($ticket_id); + triggerCustomAction('ticket_create', $ticket_id); + + // They're texted back the number - so they know it landed, and that replying adds to it + if (!$by && intval($settings['config_ticket_sms_acknowledge']) === 1 && smsNumberIsPhone($from)) { + $company = trim((string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0] ?? '')); + $queued = smsQueue($from, ($company !== '' ? "$company: " : '') . "thanks, we got your text and opened ticket $reference. Reply here to add to it.", + ['purpose' => 'acknowledge', 'ticket_id' => $ticket_id, 'contact_id' => $contact_id, 'client_id' => $client_id]); + if ($queued['ids']) { + smsSendNow($queued['ids']); + } + } + return $ticket_id; +} + +/* A text on the ticket it is texting on - a client reply, its photos with it, the ticket opened again. The reply's id */ +function smsTicketReply(int $message_id, array $conversation, string $from, string $text, array $media): int +{ + global $mysqli; + + $ticket_id = intval($conversation['ticket_id']); + $row = ticketRow($ticket_id); + $client_id = intval($row['ticket_client_id']); + + // Who it is - the ticket's contact, or another of the client's contacts with the number + $contact_id = 0; + foreach (smsContactsFor($from) as $contact) { + if (intval($contact['contact_id']) === intval($row['ticket_contact_id'])) { + $contact_id = intval($contact['contact_id']); + break; + } + if (!$contact_id && intval($contact['contact_client_id']) === $client_id) { + $contact_id = intval($contact['contact_id']); + } + } + $who = $contact_id ? (string) getFieldById('contacts', $contact_id, 'contact_name') : smsNumberText($from); + + $html = smsTextHtml($text !== '' ? $text : '(A photo or file, attached)'); + mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '" . smsSql($html) . "', ticket_reply_type = 'Client', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $contact_id, ticket_reply_ticket_id = $ticket_id"); + $reply_id = mysqli_insert_id($mysqli); + mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_contact_id = $contact_id WHERE sms_message_id = " . intval($message_id)); + if ($media) { + smsSaveMedia($media, $ticket_id, $reply_id, $message_id); + } + + // Back to Open - unless it is in a status of your own set to stay put when the client replies + $status = intval($row['ticket_status']); + $new_status = ticketStatusAfterClientReply($status); + mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $new_status, ticket_resolved_at = NULL, ticket_updated_at = NOW() WHERE ticket_id = $ticket_id"); + resetTicketResolutionSla($ticket_id); + logTicketHistory($ticket_id, escapeSql("$who replied by text" . ($new_status !== $status ? ', reopening the ticket' : ''))); + logAudit('Ticket', 'Edit', escapeSql("Text: $who replied by text on ticket " . ticketReference($row)), $client_id, $ticket_id); + + // The agent it is assigned to - in ITFlow and by email, with what they wrote + $assigned = intval($row['ticket_assigned_to']); + if ($assigned) { + mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '" . escapeSql("$who replied by text on Ticket " . ticketReference($row) . " - " . $row['ticket_subject']) . "', + notification_action = '" . escapeSql('/agent/' . ticketUrl($ticket_id, $client_id)) . "', notification_client_id = $client_id, notification_user_id = $assigned"); + } + ticketMailAssigneeReplied($ticket_id, 'text', $html, $who); + triggerCustomAction('ticket_reply_client', $ticket_id); + return $reply_id; +} + +/* + * Fetch the photos and files a text brought and keep them on the ticket - on a reply, or on + * the ticket itself. Only https, only to a public address, each at most SMS_MEDIA_MAX_BYTES, + * only the kinds a ticket takes. What couldn't be fetched is noted on the text. How many were kept. + */ +function smsSaveMedia(array $urls, int $ticket_id, ?int $reply_id, int $message_id): int +{ + global $mysqli; + + $types = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', + 'text/plain' => 'txt', 'text/csv' => 'csv', 'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', + 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/ogg' => 'ogg', 'application/zip' => 'zip', 'application/json' => 'json']; + $dir = dirname(__DIR__) . "/uploads/tickets/$ticket_id/"; + mkdirMissing(dirname(__DIR__) . '/uploads/tickets/'); + mkdirMissing($dir); + + $kept = 0; + $missed = []; + foreach (array_slice($urls, 0, SMS_MEDIA_MAX_FILES) as $i => $url) { + $host = parse_url($url, PHP_URL_HOST); + $ip = $host ? gethostbyname($host) : ''; + if (!preg_match('#^https://#i', $url) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + $missed[] = 'one at an address ITFlow won\'t fetch from'; + continue; + } + $data = ''; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => false, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_USERAGENT => 'ITFlow', + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$data) { + $data .= $chunk; + return strlen($data) > SMS_MEDIA_MAX_BYTES ? 0 : strlen($chunk); + }, + ]); + $ok = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $type = strtolower(trim(explode(';', (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE))[0])); + curl_close($ch); + if (strlen($data) > SMS_MEDIA_MAX_BYTES) { + $missed[] = 'one over ' . round(SMS_MEDIA_MAX_BYTES / 1048576) . ' MB'; + continue; + } + if (!$ok || $code !== 200 || $data === '') { + $missed[] = "one that couldn't be fetched" . ($code ? " ($code)" : ''); + continue; + } + $extension = $types[$type] ?? strtolower(pathinfo((string) parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION)); + if ($extension === 'jpeg') { + $extension = 'jpg'; + } + if (!in_array($extension, TICKET_ATTACHMENT_EXTENSIONS, true)) { + $missed[] = 'a kind of file a ticket doesn\'t take' . ($type !== '' ? " ($type)" : ''); + continue; + } + $stored = bin2hex(random_bytes(16)) . ".$extension"; + if (file_put_contents($dir . $stored, $data) === false) { + $missed[] = "one that couldn't be saved"; + continue; + } + $name = escapeSql('Texted ' . (str_starts_with($type, 'image/') ? 'photo' : 'file') . ' ' . ($i + 1) . ".$extension"); + mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$name', ticket_attachment_reference_name = '$stored', + ticket_attachment_reply_id = " . ($reply_id === null ? 'NULL' : intval($reply_id)) . ", ticket_attachment_ticket_id = $ticket_id"); + $kept++; + } + if ($missed) { + mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_error = '" . escapeSql(mb_substr('Not kept on the ticket: ' . implode(', ', $missed), 0, 500)) . "' WHERE sms_message_id = " . intval($message_id)); + } + return $kept; +} + +/* ------------------------------------------------------------------ The Texts inbox */ + +/* Its tabs - label, icon, which texts - and whether the person looking may see each */ +function smsInboxTabs(): array +{ + return [ + 'unmatched' => ['Unmatched', 'fa-question-circle', "sms_message_status = 'unmatched'", lookupUserPermission('module_support') >= 1], + 'codes' => ['Codes', 'fa-key', "sms_message_status = 'code'", lookupUserPermission('module_credential') >= 1], + 'spam' => ['Spam', 'fa-ban', "sms_message_status = 'spam'", lookupUserPermission('module_support') >= 1], + ]; +} + +/* How many texts wait in Unmatched - and codes still fresh, for those who may see them - for the side nav. 0 before texting is set up */ +function smsInboxWaiting(): int +{ + global $mysqli; + if (!smsProvider()) { + return 0; + } + $where = ["sms_message_status = 'unmatched'"]; + if (lookupUserPermission('module_credential') >= 1) { + $where[] = "(sms_message_status = 'code' AND sms_message_created_at > NOW() - INTERVAL " . SMS_CODE_MASK_MINUTES . " MINUTE)"; + } + try { + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM sms_messages WHERE sms_message_direction = 'in' AND (" . implode(' OR ', $where) . ")"))[0]); + } catch (Throwable $e) { + return 0; + } +} + +/* Whether a code can still be revealed - SMS_CODE_MASK_MINUTES after it came, and while it's kept */ +function smsCodeFresh(array $row): bool +{ + return ($row['sms_message_status'] ?? '') === 'code' && (string) ($row['sms_message_code'] ?? '') !== '' + && strtotime($row['sms_message_created_at']) > time() - SMS_CODE_MASK_MINUTES * 60; +} + +/* A code masked - its length kept, so it can be told apart from the next */ +function smsCodeMasked(string $code): string +{ + return preg_replace('/[0-9A-Za-z]/', '•', $code); +} + +/* One inbound text by id - raw, or null */ +function smsMessageRow($message_id): ?array +{ + global $mysqli; + $message_id = intval($message_id); + try { + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM sms_messages WHERE sms_message_id = $message_id LIMIT 1")); + } catch (Throwable $e) { + return null; + } + return $row ?: null; +} + +/* + * Tidy the log - from the Text Messages job. A code's text is wiped SMS_CODE_KEEP_HOURS after + * it came (that one came from that sender is kept); spam and dismissed texts go after + * SMS_SPAM_KEEP_DAYS, everything after SMS_KEEP_DAYS. + */ +function smsTidy(): void +{ + global $mysqli; + mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_body = NULL, sms_message_code = NULL, sms_message_media = NULL, sms_message_error = 'The code was wiped a day after it came' + WHERE sms_message_status = 'code' AND sms_message_body IS NOT NULL AND sms_message_created_at < NOW() - INTERVAL " . SMS_CODE_KEEP_HOURS . " HOUR"); + mysqli_query($mysqli, "DELETE FROM sms_messages WHERE sms_message_status IN ('spam', 'dismissed') AND sms_message_created_at < NOW() - INTERVAL " . SMS_SPAM_KEEP_DAYS . " DAY"); + mysqli_query($mysqli, "DELETE FROM sms_messages WHERE sms_message_created_at < NOW() - INTERVAL " . SMS_KEEP_DAYS . " DAY"); +} + +/* ------------------------------------------------------------------ The log */ + +/* Texts that didn't go in the last week - for the side nav. 0 before the tables exist */ +function smsFailedRecently(): int +{ + global $mysqli; + try { + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM sms_messages WHERE sms_message_direction = 'out' AND sms_message_status = 'failed' AND sms_message_created_at > NOW() - INTERVAL 7 DAY"))[0]); + } catch (Throwable $e) { + return 0; + } +} + +/* Maintenance > Text Messages' tabs - label, icon, which texts */ +function smsLogTabs(): array +{ + return [ + '' => ['All', 'fa-list', '1'], + 'waiting' => ['Waiting', 'fa-hourglass-half', "sms_message_direction = 'out' AND sms_message_status IN ('queued', 'sending')"], + 'failed' => ['Failed', 'fa-exclamation-triangle', "sms_message_direction = 'out' AND sms_message_status IN ('failed', 'blocked')"], + 'sent' => ['Sent', 'fa-paper-plane', "sms_message_direction = 'out' AND sms_message_status = 'sent'"], + 'received' => ['Received', 'fa-inbox', "sms_message_direction = 'in'"], + ]; +} + +/* How a text stands, for the log - key, label, badge, icon, and why when it didn't go */ +function smsLogState(array $row): array +{ + $states = [ + 'queued' => ['Waiting', 'text-bg-light border', 'fa-hourglass-half'], + 'sending' => ['Sending', 'text-bg-info', 'fa-spinner'], + 'sent' => ['Sent', 'text-bg-success', 'fa-check'], + 'failed' => ['Failed', 'text-bg-danger', 'fa-exclamation-triangle'], + 'blocked' => ['Not sent', 'text-bg-warning', 'fa-hand-paper'], + 'cancelled' => ['Cancelled', 'text-bg-secondary', 'fa-ban'], + 'ticket' => ['On a ticket', 'text-bg-primary', 'fa-life-ring'], + 'unmatched' => ['In Texts', 'text-bg-warning', 'fa-question-circle'], + 'code' => ['Code', 'text-bg-dark', 'fa-key'], + 'spam' => ['Spam', 'text-bg-secondary', 'fa-ban'], + 'keyword' => ['Keyword', 'text-bg-light border', 'fa-hand-paper'], + 'dismissed' => ['Dismissed', 'text-bg-light border', 'fa-check'], + 'received' => ['Received', 'text-bg-light border', 'fa-inbox'], + ]; + $status = (string) $row['sms_message_status']; + [$label, $badge, $icon] = $states[$status] ?? [ucfirst($status), 'text-bg-light border', 'fa-circle']; + $error = trim((string) $row['sms_message_error']); + // Waiting for the morning - says when + if ($status === 'queued' && !empty($row['sms_message_send_after']) && strtotime($row['sms_message_send_after']) > time() && $error === '') { + $error = 'Waiting until ' . date('g:ia', strtotime($row['sms_message_send_after'])) . ' - it would land at night'; + } + return ['key' => $status, 'label' => $label, 'badge' => $badge, 'icon' => $icon, 'error' => $error]; +} + +/* A text's words as the log shows them - a code masked, a wiped one said so */ +function smsLogBody(array $row): string +{ + $body = (string) $row['sms_message_body']; + if ($row['sms_message_status'] === 'code') { + if ($row['sms_message_body'] === null) { + return '(A verification code - wiped a day after it came)'; + } + $code = (string) $row['sms_message_code']; + return $code !== '' ? str_replace($code, smsCodeMasked($code), $body) : $body; + } + return $body; +} + +/* What a text ITFlow sent by itself was for, in words - '' for a reply an agent wrote */ +function smsPurposeText(string $purpose): string +{ + return [ + 'acknowledge' => 'The ticket number, for a new ticket', 'keyword' => 'The answer to STOP, START or HELP', 'test' => 'A test', + 'notify_opened' => 'Their ticket was opened', 'notify_resolved' => 'Their ticket was resolved', 'notify_replied' => 'A reply was emailed to them', + 'notify_scheduled' => 'A visit or call was booked', 'notify_unscheduled' => 'A visit or call was cancelled', 'reminder' => 'A reminder of a visit or call', + 'invoice' => 'An invoice, with its link', 'invoice_reminder' => 'A payment reminder, with its link', + 'opt_in_request' => 'Asking whether texts are all right', 'opt_in' => 'Thanks for agreeing to texts', + ][$purpose] ?? ''; +} + +/* What can be done with a text in the log - retry a failed one, cancel a waiting one, delete one that isn't being sent */ +function smsLogCan(array $row, string $action): bool +{ + $status = (string) $row['sms_message_status']; + $out = $row['sms_message_direction'] === 'out'; + if ($action === 'retry') { + return $out && in_array($status, ['failed', 'cancelled'], true); + } + if ($action === 'cancel') { + return $out && $status === 'queued'; + } + return $status !== 'sending'; +} + +/* ------------------------------------------------------------------ On tickets */ + +/* + * Each reply's texts on a ticket - [reply id => ['direction', 'status', 'number', 'error', 'parts']] - + * for the conversation: "Texted to ..." and whether it went, or "By text" on one that came in. + */ +function smsRepliesForTicket(int $ticket_id): array +{ + global $mysqli; + $states = []; + try { + $sql = mysqli_query($mysqli, "SELECT sms_message_ticket_reply_id, sms_message_direction, sms_message_status, sms_message_number, sms_message_error + FROM sms_messages WHERE sms_message_ticket_id = " . intval($ticket_id) . " AND sms_message_ticket_reply_id > 0 ORDER BY sms_message_id ASC"); + } catch (Throwable $e) { + return []; + } + $rank = ['failed' => 5, 'blocked' => 4, 'queued' => 3, 'sending' => 2, 'sent' => 1]; + while ($row = mysqli_fetch_assoc($sql)) { + $reply_id = intval($row['sms_message_ticket_reply_id']); + $state = $states[$reply_id] ?? ['direction' => $row['sms_message_direction'], 'status' => $row['sms_message_status'], 'number' => $row['sms_message_number'], 'error' => '', 'parts' => 0]; + $state['parts']++; + // The worst of its parts is how it stands + if (($rank[$row['sms_message_status']] ?? 0) > ($rank[$state['status']] ?? 0)) { + $state['status'] = $row['sms_message_status']; + } + if ((string) $row['sms_message_error'] !== '' && $state['error'] === '') { + $state['error'] = (string) $row['sms_message_error']; + } + $states[$reply_id] = $state; + } + return $states; +} + +/* How a text reply stands, for the line under it - [icon, words, tone] */ +function smsReplyStateText(array $state): array +{ + $to = smsNumberText($state['number']); + $parts = $state['parts'] > 1 ? ' (' . $state['parts'] . ' texts)' : ''; + switch ($state['status']) { + case 'sent': + return ['fa-check', "Texted to $to$parts", 'text-body-secondary']; + case 'queued': + case 'sending': + return ['fa-hourglass-half', "Texting $to$parts - waiting to go", 'text-body-secondary']; + case 'blocked': + return ['fa-ban', "Not texted to $to - they replied STOP", 'text-danger-emphasis']; + default: + return ['fa-exclamation-triangle', "Not texted to $to" . ($state['error'] !== '' ? ' - ' . $state['error'] : ''), 'text-danger-emphasis']; + } +} + +/* + * Who a text reply on a ticket goes to - ['number', 'name', 'opted_out'] - or null: its + * contact's mobile, else the number it is texting on (someone you don't know). + */ +function smsTicketRecipient(array $ticket): ?array +{ + global $mysqli; + if (!smsReady()) { + return null; + } + $number = ''; + $name = ''; + $contact_id = intval($ticket['ticket_contact_id'] ?? 0); + if ($contact_id) { + $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_mobile, contact_mobile_country_code FROM contacts WHERE contact_id = $contact_id AND contact_archived_at IS NULL")); + if ($contact) { + $number = smsContactNumber($contact); + $name = (string) $contact['contact_name']; + } + } + if ($number === '') { + $last = mysqli_fetch_row(mysqli_query($mysqli, "SELECT sms_message_number FROM sms_messages WHERE sms_message_ticket_id = " . intval($ticket['ticket_id']) . " AND sms_message_direction = 'in' ORDER BY sms_message_id DESC LIMIT 1")); + $number = smsNumberIsPhone((string) ($last[0] ?? '')) ? (string) $last[0] : ''; + } + if ($number === '') { + return null; + } + return ['number' => $number, 'name' => $name, 'opted_out' => smsOptedOut($number)]; +} + +/* + * Text a reply on a ticket to who it goes to (smsTicketRecipient()) - now, the rest by the job + * when it is long. ['ok' => went, 'queued' => waiting to go, 'to' => who, for reading, 'error']. + */ +function smsTicketReplySend(array $ticket, int $reply_id, string $html, int $user_id): array +{ + $recipient = smsTicketRecipient($ticket); + if (!$recipient) { + return ['ok' => false, 'queued' => false, 'to' => '', 'error' => smsReady() ? "there's no mobile number on its contact to text" : "texting isn't set up"]; + } + $to = ($recipient['name'] !== '' ? $recipient['name'] . ' at ' : '') . smsNumberText($recipient['number']); + $text = smsTextFromHtml($html); + if ($text === '') { + return ['ok' => false, 'queued' => false, 'to' => $to, 'error' => 'there are no words in it to text']; + } + $queued = smsQueue($recipient['number'], $text, ['purpose' => 'reply', 'ticket_id' => intval($ticket['ticket_id']), 'ticket_reply_id' => $reply_id, + 'contact_id' => intval($ticket['ticket_contact_id']), 'client_id' => intval($ticket['ticket_client_id']), 'user_id' => $user_id]); + if ($queued['error'] !== '') { + return ['ok' => false, 'queued' => false, 'to' => $to, 'error' => $queued['error']]; + } + $sent = smsSendNow($queued['ids']); + $waiting = count($queued['ids']) - $sent['sent']; + return ['ok' => $waiting === 0, 'queued' => $waiting > 0 && smsQueuedStill($queued['ids']), 'to' => $to, 'error' => $sent['error']]; +} + +/* Whether any of these texts is still waiting to go - tried, and to be tried again */ +function smsQueuedStill(array $ids): bool +{ + global $mysqli; + $ids = array_filter(array_map('intval', $ids)); + if (!$ids) { + return false; + } + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM sms_messages WHERE sms_message_id IN (" . implode(',', $ids) . ") AND sms_message_status = 'queued'"))[0]) > 0; +} + +/* ------------------------------------------------------------------ Texts to clients */ + +/* Whether a table has a column yet - the texts to clients arrive with the 3.6.1 database update. Asked once a request */ +function smsColumnExists(string $table, string $column): bool +{ + global $mysqli; + static $known = []; + $key = "$table.$column"; + if (!isset($known[$key])) { + $result = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . escapeSql($table) . "' AND COLUMN_NAME = '" . escapeSql($column) . "' LIMIT 1"); + $known[$key] = $result && mysqli_num_rows($result) > 0; + } + return $known[$key]; +} + +/* Your company's name, as texts start with it */ +function smsCompanyName(): string +{ + global $mysqli; + static $name = null; + if ($name === null) { + $name = trim((string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0] ?? '')); + if ($name === '') { + $name = 'Support'; + } + } + return $name; +} + +/* Settings > Notifications > Texts to clients - read where they're used */ +function smsClientSettings(): array +{ + global $mysqli; + $settings = ['config_sms_client_tickets' => 1, 'config_sms_client_visits' => 1, 'config_sms_visit_reminder_hours' => 24, 'config_sms_client_invoices' => 1]; + if (!smsColumnExists('settings', 'config_sms_client_tickets')) { + return array_map(function () { return 0; }, $settings); + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_sms_client_tickets, config_sms_client_visits, config_sms_visit_reminder_hours, config_sms_client_invoices FROM settings WHERE company_id = 1")); + return array_map('intval', array_merge($settings, $row ?: [])); +} + +/* The reminder choices on Settings > Notifications - hours before a visit or call */ +function smsReminderChoices(): array +{ + return [0 => 'No reminder', 1 => 'An hour before', 2 => 'Two hours before', 4 => 'Four hours before', 24 => 'The day before']; +} + +/* + * When a text ITFlow sends a client by itself may go - null for now, or the next morning when it + * is night (SMS_QUIET_FROM to SMS_QUIET_UNTIL, your time). $before: what it's about starts then + * (a visit) - when the morning is too late for it, now. + */ +function smsSendAfter(?int $before = null): ?string +{ + $hour = intval(date('G')); + if ($hour >= SMS_QUIET_UNTIL && $hour < SMS_QUIET_FROM) { + return null; + } + $morning = strtotime(($hour >= SMS_QUIET_FROM ? 'tomorrow' : 'today') . ' ' . sprintf('%02d:00', SMS_QUIET_UNTIL)); + if ($before !== null && $morning >= $before - 15 * 60) { + return null; + } + return date('Y-m-d H:i:s', $morning); +} + +/* + * A contact who can be texted about their work - agreed to texts, has a mobile, didn't reply + * STOP, isn't archived - with 'number' added. Null otherwise. + */ +function smsContactTextable(int $contact_id): ?array +{ + global $mysqli; + if (!$contact_id || !smsReady() || !smsColumnExists('contacts', 'contact_sms_opt_in')) { + return null; + } + $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_client_id, contact_mobile, contact_mobile_country_code, contact_sms_opt_in + FROM contacts WHERE contact_id = $contact_id AND contact_archived_at IS NULL")); + if (!$contact || !intval($contact['contact_sms_opt_in'])) { + return null; + } + $contact['number'] = smsContactNumber($contact); + return $contact['number'] !== '' && !smsOptedOut($contact['number']) ? $contact : null; +} + +/* Queue a text to a contact who agreed to texts - at night, for the morning. Whether it was queued */ +function smsNotifyContact(int $contact_id, string $text, array $meta, ?int $before = null): bool +{ + $contact = smsContactTextable($contact_id); + if (!$contact) { + return false; + } + $queued = smsQueue($contact['number'], $text, $meta + ['contact_id' => $contact_id, 'client_id' => intval($contact['contact_client_id']), 'send_after' => smsSendAfter($before)]); + return (bool) $queued['ids'] && $queued['error'] === ''; +} + +/* A ticket's subject, short enough for a text */ +function smsTicketSubject(array $row): string +{ + return mb_strimwidth(trim((string) $row['ticket_subject']), 0, 60, '...'); +} + +/* When a visit is, as a text says it - "today at 2:00pm", "tomorrow at 9:30am", "on Tue, Nov 10 at 2:00pm" */ +function smsWhenText(string $datetime): string +{ + $at = strtotime($datetime); + $time = date('g:ia', $at); + if (date('Y-m-d', $at) === date('Y-m-d')) { + return "today at $time"; + } + if (date('Y-m-d', $at) === date('Y-m-d', strtotime('tomorrow'))) { + return "tomorrow at $time"; + } + return 'on ' . date('D, M j', $at) . " at $time"; +} + +/* + * A ticket's contact told by text, from triggerCustomAction(): ticket_create (opened for them - + * not one that came in by text, which was answered already), ticket_resolve, ticket_schedule and + * ticket_unschedule. Each as Settings > Notifications > Texts to clients has it, and only to a + * contact who agreed. Their reply goes on the ticket. + */ +function smsClientNotify(string $trigger, $ticket_id): void +{ + if (!smsReady()) { + return; + } + $row = ticketRow($ticket_id); + if (!$row || !intval($row['ticket_contact_id'])) { + return; + } + $settings = smsClientSettings(); + $reference = ticketReference($row); + $company = smsCompanyName(); + $meta = ['ticket_id' => intval($row['ticket_id'])]; + $text = ''; + $before = null; + + if ($trigger === 'ticket_create' && $settings['config_sms_client_tickets'] && $row['ticket_source'] !== 'Text') { + $text = "$company: we opened ticket $reference for you - " . smsTicketSubject($row) . ". Reply to this text to add to it."; + $meta['purpose'] = 'notify_opened'; + } elseif ($trigger === 'ticket_resolve' && $settings['config_sms_client_tickets']) { + $text = "$company: ticket $reference (" . smsTicketSubject($row) . ") is resolved. If it isn't fixed, reply here and we'll pick it up again."; + $meta['purpose'] = 'notify_resolved'; + } elseif ($trigger === 'ticket_schedule' && $settings['config_sms_client_visits'] && !empty($row['ticket_schedule'])) { + $what = intval($row['ticket_onsite']) ? 'A visit' : 'Remote work'; + $who = trim((string) $row['user_name']) !== '' ? ' with ' . strtok(trim((string) $row['user_name']), ' ') : ''; + $text = "$company: $what$who for ticket $reference is booked " . smsWhenText($row['ticket_schedule']) . ". Reply here if that doesn't suit."; + $meta['purpose'] = 'notify_scheduled'; + $before = strtotime($row['ticket_schedule']); + } elseif ($trigger === 'ticket_unschedule' && $settings['config_sms_client_visits']) { + $text = "$company: the time booked for ticket $reference is cancelled - we'll be in touch to book another."; + $meta['purpose'] = 'notify_unscheduled'; + } + if ($text !== '') { + smsNotifyContact(intval($row['ticket_contact_id']), $text, $meta, $before); + } +} + +/* An agent's emailed reply, told by text too - when Texts to clients says each reply. Whether it was */ +function smsTicketReplyNotify(array $row): bool +{ + if (!smsReady() || smsClientSettings()['config_sms_client_tickets'] !== 2) { + return false; + } + return smsNotifyContact(intval($row['ticket_contact_id']), smsCompanyName() . " replied on ticket " . ticketReference($row) . " - see your email. Or reply here to answer by text.", + ['ticket_id' => intval($row['ticket_id']), 'purpose' => 'notify_replied']); +} + +/* + * Reminders of visits and calls booked on tickets - from the Text Messages job, the set hours + * before, to contacts who agreed. Once a booking: not when one went in the reminder's window + * already, nor when the booking itself was texted in it. How many were queued. + */ +function smsVisitReminders(): int +{ + global $mysqli; + $settings = smsClientSettings(); + $hours = $settings['config_sms_visit_reminder_hours']; + if (!smsReady() || !$settings['config_sms_client_visits'] || $hours <= 0) { + return 0; + } + $sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets + JOIN contacts ON contact_id = ticket_contact_id AND contact_sms_opt_in = 1 AND contact_archived_at IS NULL + WHERE ticket_schedule > NOW() AND ticket_schedule <= NOW() + INTERVAL $hours HOUR + AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL + AND NOT EXISTS (SELECT 1 FROM sms_messages WHERE sms_message_purpose IN ('reminder', 'notify_scheduled') AND sms_message_ticket_id = ticket_id + AND sms_message_created_at > NOW() - INTERVAL $hours HOUR) + LIMIT 50"); + $queued = 0; + while ($found = mysqli_fetch_row($sql)) { + $row = ticketRow($found[0]); + $who = trim((string) $row['user_name']) !== '' ? strtok(trim((string) $row['user_name']), ' ') : 'we'; + $what = intval($row['ticket_onsite']) ? ($who === 'we' ? 'we are visiting' : "$who is visiting") : ($who === 'we' ? 'we are connecting remotely' : "$who is connecting remotely"); + $text = smsCompanyName() . ": a reminder - $what for ticket " . ticketReference($row) . ' ' . smsWhenText($row['ticket_schedule']) . ". Reply here if that doesn't suit."; + if (smsNotifyContact(intval($row['ticket_contact_id']), $text, ['ticket_id' => intval($row['ticket_id']), 'purpose' => 'reminder'], strtotime($row['ticket_schedule']))) { + $queued++; + } + } + return $queued; +} + +/* + * An invoice, or a reminder of what's owed, texted with its link to the ones of the people it + * was emailed to who agreed to texts - when Texts to clients has invoices on. $kind 'invoice' or + * 'reminder'; $recipients as billingEmailRecipients() gives them. Who it went to, for the + * invoice's history ("Name (+1 ...)"). + */ +function smsInvoiceNotify(array $invoice, string $kind, array $recipients): array +{ + if (!smsReady() || !smsClientSettings()['config_sms_client_invoices']) { + return []; + } + $state = invoiceState($invoice, invoicePaid($invoice['invoice_id'])); + // A draft is being sent as this is asked + if (!in_array($state['state'], ['open', 'overdue', 'draft'], true) || $state['balance'] <= 0) { + return []; + } + $number = billingNumber('invoice', $invoice); + $currency = (string) $invoice['invoice_currency_code']; + $link = billingGuestUrl('invoice', $invoice); + $pay = billingOnlinePayments() ? 'Pay' : 'View it'; + if ($kind === 'reminder') { + $when = $state['days'] < 0 ? abs($state['days']) . ' day' . (abs($state['days']) === 1 ? '' : 's') . ' overdue' : 'due ' . date('M j', strtotime($invoice['invoice_due'])); + $text = smsCompanyName() . ": a reminder - invoice $number has " . billingMoney($state['balance'], $currency) . " still to pay, $when. $pay: $link"; + } else { + $text = smsCompanyName() . ": invoice $number for " . billingMoney($state['balance'], $currency) . ' is due ' . date('M j', strtotime($invoice['invoice_due'])) . ". $pay: $link"; + } + $texted = []; + foreach ($recipients as $recipient) { + $contact = smsContactTextable(intval($recipient['id'] ?? 0)); + if ($contact && smsNotifyContact(intval($contact['contact_id']), $text, ['purpose' => $kind === 'reminder' ? 'invoice_reminder' : 'invoice'])) { + $texted[] = $contact['contact_name'] . ' (' . smsNumberText($contact['number']) . ')'; + } + } + return $texted; +} + +/* + * Turn a contact's texts on or off - $by says how they agreed, for the record ("Alex Rivera, + * with their say-so", "them, in the client portal"). Turned on by the contact themselves, a STOP + * they sent before is lifted: this is them asking again. An agent turning it on doesn't lift one. + */ +function smsOptInSet(int $contact_id, bool $on, string $by, bool $by_them = true): void +{ + global $mysqli; + if (!$contact_id || !smsColumnExists('contacts', 'contact_sms_opt_in')) { + return; + } + if ($on) { + mysqli_query($mysqli, "UPDATE contacts SET contact_sms_opt_in = 1, contact_sms_opt_in_at = NOW(), contact_sms_opt_in_by = '" . escapeSql(mb_substr($by, 0, 200)) . "' WHERE contact_id = $contact_id"); + if ($by_them) { + $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_mobile, contact_mobile_country_code FROM contacts WHERE contact_id = $contact_id")); + $number = $contact ? smsContactNumber($contact) : ''; + if ($number !== '' && smsOptedOut($number)) { + smsNumberSet($number, ['opted_out_at' => null]); + } + } + } else { + mysqli_query($mysqli, "UPDATE contacts SET contact_sms_opt_in = 0, contact_sms_opt_in_at = NULL, contact_sms_opt_in_by = NULL WHERE contact_id = $contact_id"); + } +} + +/* Ask a contact by text whether texts are all right - YES agrees (smsInbound()). ['ok', 'error'] */ +function smsOptInAsk(int $contact_id, int $user_id): array +{ + global $mysqli; + if (!smsReady()) { + return ['ok' => false, 'error' => "texting isn't set up"]; + } + $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_client_id, contact_mobile, contact_mobile_country_code FROM contacts WHERE contact_id = $contact_id AND contact_archived_at IS NULL")); + $number = $contact ? smsContactNumber($contact) : ''; + if ($number === '') { + return ['ok' => false, 'error' => 'they have no mobile number']; + } + if (smsOptedOut($number)) { + return ['ok' => false, 'error' => 'they replied STOP to your texts - only they can turn texts back on, by texting START']; + } + $queued = smsQueue($number, smsCompanyName() . ': can we text you about your tickets, visits and invoices? Reply YES to agree. Reply STOP and you won\'t get texts from us.', + ['purpose' => 'opt_in_request', 'contact_id' => $contact_id, 'client_id' => intval($contact['contact_client_id']), 'user_id' => $user_id]); + if ($queued['error'] !== '') { + return ['ok' => false, 'error' => $queued['error']]; + } + $sent = smsSendNow($queued['ids']); + return ['ok' => $sent['sent'] > 0, 'error' => $sent['error']]; +} + +/* The contact a number was asked about texts for, in the last SMS_OPT_IN_ASK_DAYS days, who hasn't agreed yet - or null */ +function smsOptInPending(string $number): ?array +{ + global $mysqli; + if (!smsColumnExists('contacts', 'contact_sms_opt_in')) { + return null; + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_client_id FROM sms_messages + JOIN contacts ON contact_id = sms_message_contact_id AND contact_archived_at IS NULL AND contact_sms_opt_in = 0 + WHERE sms_message_direction = 'out' AND sms_message_purpose = 'opt_in_request' AND sms_message_number = '" . escapeSql($number) . "' + AND sms_message_created_at > NOW() - INTERVAL " . SMS_OPT_IN_ASK_DAYS . " DAY + ORDER BY sms_message_id DESC LIMIT 1")); + return $row ?: null; +} + +/* + * Where a contact stands with texts, for their page - ['ready' => texting is set up, 'number', + * 'on', 'at', 'by', 'stopped' => replied STOP, 'asked_at' => asked by text and not answered yet]. + */ +function smsContactTextState(int $contact_id): array +{ + global $mysqli; + $state = ['ready' => smsReady() && smsColumnExists('contacts', 'contact_sms_opt_in'), 'number' => '', 'on' => false, 'at' => null, 'by' => '', 'stopped' => false, 'asked_at' => null]; + if (!$state['ready']) { + return $state; + } + $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_mobile, contact_mobile_country_code, contact_sms_opt_in, contact_sms_opt_in_at, contact_sms_opt_in_by FROM contacts WHERE contact_id = $contact_id")); + if (!$contact) { + return $state; + } + $state['number'] = smsContactNumber($contact); + $state['on'] = intval($contact['contact_sms_opt_in']) === 1; + $state['at'] = $contact['contact_sms_opt_in_at']; + $state['by'] = (string) $contact['contact_sms_opt_in_by']; + $state['stopped'] = $state['number'] !== '' && smsOptedOut($state['number']); + if (!$state['on'] && $state['number'] !== '') { + $asked = mysqli_fetch_row(mysqli_query($mysqli, "SELECT MAX(sms_message_created_at) FROM sms_messages WHERE sms_message_purpose = 'opt_in_request' AND sms_message_contact_id = $contact_id + AND sms_message_created_at > NOW() - INTERVAL " . SMS_OPT_IN_ASK_DAYS . " DAY")); + $state['asked_at'] = $asked[0] ?? null; + } + return $state; +} + +/* The contact window's Texts switch, saved - when it was on the window, and changed. With their say-so, an agent's word for it */ +function smsContactOptInFromForm(int $contact_id, array $post, int $client_id): void +{ + global $session_name; + if (empty($post['sms_opt_in_shown']) || !$contact_id) { + return; + } + $state = smsContactTextState($contact_id); + $want = !empty($post['contact_sms_opt_in']); + if (!$state['ready'] || $want === $state['on']) { + return; + } + smsOptInSet($contact_id, $want, stripslashes((string) $session_name) . ', with their say-so', false); + $name = (string) getFieldById('contacts', $contact_id, 'contact_name'); + logAudit('Contact', 'Edit', "$session_name " . ($want ? 'turned on texts for ' : 'turned off texts for ') . escapeSql($name), $client_id, $contact_id); +} diff --git a/functions/ticket.php b/functions/ticket.php index 416bd08d2..a6151bb6d 100644 --- a/functions/ticket.php +++ b/functions/ticket.php @@ -867,7 +867,7 @@ function ticketAgentEmailContent(array $row, $kind, array $opts = []) { 'button' => ['label' => 'Open in ITFlow', 'url' => ticketAgentUrl($row)], ]; if ($kind === 'new') { - $how = ['portal' => ' in the client portal', 'email' => ' by email', 'api' => ' through the API', 'scheduled' => ', as scheduled'][$opts['how'] ?? ''] ?? ''; + $how = ['portal' => ' in the client portal', 'email' => ' by email', 'text' => ' by text', 'api' => ' through the API', 'scheduled' => ', as scheduled'][$opts['how'] ?? ''] ?? ''; $who = ($opts['how'] ?? '') === 'scheduled' ? 'A recurring ticket was raised' : (trim((string) ($row['contact_name'] ?? '')) !== '' ? $row['contact_name'] . ' raised a ticket' : 'A ticket was raised'); $email['kind'] = 'New ticket'; $email['intro'] = [($opts['how'] ?? '') === 'scheduled' ? "$who for $client, as scheduled." : "$who$how for $client."]; @@ -881,7 +881,7 @@ function ticketAgentEmailContent(array $row, $kind, array $opts = []) { "$reference is ready to work on" . (!empty($opts['reopened']) ? ' - it is Open again, from On Hold.' : '.')]; $email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'The conversation']); } elseif ($kind === 'replied') { - $how = ['portal' => ' in the client portal', 'email' => ' by email'][$opts['how'] ?? ''] ?? ''; + $how = ['portal' => ' in the client portal', 'email' => ' by email', 'text' => ' by text'][$opts['how'] ?? ''] ?? ''; $email['kind'] = 'Client replied'; $email['intro'] = [($by !== '' ? $by : 'The client') . " replied to $reference$how."]; $email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => ($by !== '' ? $by : 'They') . ' wrote']; @@ -1572,7 +1572,7 @@ function ticketActivityHtml($reply, $row) { function ticketSourceIcon($source) { $icons = ['Email' => ['fa-envelope', 'Came in by email'], 'Portal' => ['fa-globe', 'Raised in the client portal'], 'Recurring' => ['fa-redo-alt', 'Raised by a recurring ticket'], 'API' => ['fa-plug', 'Raised through the API'], - 'Project' => ['fa-project-diagram', 'From a project template']]; + 'Project' => ['fa-project-diagram', 'From a project template'], 'Text' => ['fa-sms', 'Came in by text']]; foreach ($icons as $match => [$icon, $title]) { if (stripos((string) $source, $match) !== false) { return ''; diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php index 1973011c7..f2b3109c4 100644 --- a/includes/cron_jobs.php +++ b/includes/cron_jobs.php @@ -65,6 +65,14 @@ function cronJobRegistry(): array 'schedule' => 'Interval', 'interval_minutes' => 1, ], + [ + 'name' => 'sms_queue', + 'label' => 'Text Messages', + 'script' => 'sms_queue.php', + 'description' => 'Sends the texts waiting to go, a second apart, texts reminders of visits and calls coming up, and tidies the text log - once texting is set up in Admin > Text Messaging.', + 'schedule' => 'Interval', + 'interval_minutes' => 1, + ], [ 'name' => 'ticket_email_parser', 'label' => 'Ticket Email Parser', diff --git a/js/app.js b/js/app.js index 49b632f41..484d1036e 100644 --- a/js/app.js +++ b/js/app.js @@ -337,7 +337,7 @@ function itflowAiReword(editor, ticket) { */ function itflowAiDraft(editor, ticketId, say) { var buttonApi = null; - var types = { '0': 'internal', '1': 'public', '2': 'email' }; + var types = { '0': 'internal', '1': 'public', '2': 'email', '3': 'text' }; editor.ui.registry.addButton('aidraft', { icon: 'ai-prompt', text: 'Draft',