From b391e2ce6ced00eef08ce17edfdac6a50bde3e10 Mon Sep 17 00:00:00 2001
From: johnnyq 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',