FIX - route company service-line messages

This commit is contained in:
Alec Schitzkat
2026-08-24 01:13:58 +02:00
parent aac916efc6
commit fd24c8b272
15 changed files with 504 additions and 60 deletions
@@ -0,0 +1,133 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
/\r\n/g,
'\n',
)
}
function sourceBlock(
value: string,
startMarker: string,
endMarker: string,
): string {
const start = value.indexOf(startMarker)
const end = value.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return value.slice(start, end)
}
const companiesServer = source('../../sky_phone/source/server/companies.lua')
const messagesServer = source('../../sky_phone/source/server/messages.lua')
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
const configDefaults = source(
'../../sky_phone/source/shared/config_default.lua',
)
const messagesApp = source('./views/apps/MessagesApp.vue')
const phoneFallbacks = source('./stores/phone.ts')
const locales = ['en', 'de', 'es'].map((locale) =>
source(`../../sky_phone/config/locales/${locale}.lua`),
)
describe('Companies service-line message routing contract', () => {
it('enables the police service line and persists an indexed route channel', () => {
const police = sourceBlock(
configDefaults,
' police = {',
' ambulance = {',
)
const schema = sourceBlock(
databaseMigration,
' name = "sky_phone_company_requests",',
' name = "sky_phone_company_request_reads",',
)
expect(police).toContain('Number = "911"')
expect(police).toContain('CanMessage = true')
expect(schema).toContain(
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
)
expect(schema).toContain(
'name = "idx_sky_phone_company_requests_service_line"',
)
})
it('resolves short service numbers before the normal SIM-number path', () => {
const resolver = sourceBlock(
messagesServer,
'local function resolve_message_recipient(value)',
'\n\nlocal function shared_contact',
)
const send = sourceBlock(
messagesServer,
'Bridge.Callbacks.Register("sky_phone:messages:send"',
'\n\nend)',
)
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
expect(resolver).toContain('return service_line.number, service_line')
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
expect(send).toContain('service_line_text_only')
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
send.indexOf('SELECT s.`id`, s.`phone_number`'),
)
})
it('atomically mirrors inbound SMS into one active company request', () => {
const route = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
)
expect(route).toContain('current_device(source, false)')
expect(route).toContain('service_line.canMessage')
expect(route).toContain('UPDATE `sky_phone_sims`')
expect(route).toContain("`channel` = 'service_line'")
expect(route).toContain('AND NOT EXISTS (')
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
expect(route).toContain('Bridge.Database.Transaction(statements)')
expect(route).toContain('emit_request_change(row, true, true, source)')
expect(route).toContain('"sky_phone:companies:notification"')
})
it('mirrors authorized company replies back to the customer SMS thread', () => {
const sendMessage = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
)
expect(sendMessage).toContain('access.row.channel == "service_line"')
expect(
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
).toBe(2)
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
expect(sendMessage).toContain("message.`sender_type` = 'company'")
expect(sendMessage).toContain('"sky_phone:messages:changed"')
expect(sendMessage).toContain('"sky_phone:messages:new"')
})
it('keeps service-line composition text-only in every shipped locale', () => {
expect(messagesApp).toContain(
"() => activeContact.value?.source === 'company'",
)
expect(messagesApp).toContain(
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
)
expect(messagesApp).toContain('v-if="!activeServiceLine"')
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
expect(messagesApp).toContain("'service_line_text_only'")
expect(phoneFallbacks).toContain('service_line_text_only:')
for (const locale of locales) {
expect(locale).toContain('service_line_text_only =')
}
})
})
@@ -107,10 +107,10 @@ describe('company configurator creation contract', () => {
expect(blankRegistration).toBeLessThan(policeRegistration)
})
it('repairs persisted service-line messaging before Companies starts', () => {
it('enables persisted police service-line messaging before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_unsupported_company_message_defaults()',
'local function migrate_police_service_line_messaging()',
),
configuratorServer.indexOf('\n\ndefault_config = {}'),
)
@@ -118,19 +118,22 @@ describe('company configurator creation contract', () => {
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
const messageRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_unsupported_company_message_defaults)',
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
)
expect(migration).toContain(
'sky-phone:configurator:unsupported-company-message-defaults:v1',
'sky-phone:configurator:service-line-messaging:v2',
)
expect(migration).toContain('line.CanMessage == true')
expect(migration).toContain('line.CanMessage = false')
expect(migration).toContain('line.CanMessage ~= true')
expect(migration).toContain('line.CanMessage = true')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(messageRegistration).toBeGreaterThan(policeRegistration)
expect(companiesServer).not.toContain(
'enables messaging without a virtual service-line message router',
)
})
})
+2
View File
@@ -2712,6 +2712,8 @@ const defaultLocales: LocaleTree = {
recipient_not_found: 'That number is unavailable.',
blocked: 'This contact has blocked calls and messages from your SIM.',
messaging_unavailable: 'This company contact does not accept messages.',
service_line_text_only:
'Company service lines currently accept text messages only.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
':opened="activeCanMessage && attachmentPicker !== null"',
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
+57 -12
View File
@@ -215,6 +215,9 @@ const activeContact = computed(() =>
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeServiceLine = computed(
() => activeContact.value?.source === 'company',
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
@@ -255,12 +258,15 @@ const inboxMenuItems = computed(() => [
},
])
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
() =>
emojiOpen.value ||
(!activeServiceLine.value && attachmentPicker.value !== null),
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
Boolean(draft.value.trim()) ||
(!activeServiceLine.value &&
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
)
function contactName(number: string): string {
return (
@@ -417,6 +423,8 @@ function errorText(error?: string): string {
'gif_provider_failed',
'self_message',
'recipient_not_found',
'messaging_unavailable',
'service_line_text_only',
'no_sim',
'rate_limited',
'blocked',
@@ -614,6 +622,7 @@ async function callActiveContact(): Promise<void> {
}
function toggleAttachmentMenu(): void {
if (activeServiceLine.value) return
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
@@ -646,7 +655,7 @@ function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber) return
if (!messages.activeNumber || activeServiceLine.value) return
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
@@ -707,7 +716,14 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
if (
!messages.activeNumber ||
!activeCanMessage.value ||
activeServiceLine.value ||
sending.value
) {
return
}
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -722,7 +738,7 @@ async function sendAttachment(
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || sending.value) return
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -885,7 +901,12 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
if (
!activeCanMessage.value ||
activeServiceLine.value ||
recording.value ||
recordingStarting.value
) {
return
}
emojiOpen.value = false
@@ -1041,6 +1062,27 @@ watch(
},
)
watch(activeServiceLine, (serviceLine) => {
if (!serviceLine) return
const discarded = Boolean(
shareDraft.value ||
pendingAttachments.value.length ||
recording.value ||
recordingStarting.value,
)
attachmentMenuOpen.value = false
attachmentPicker.value = null
shareDraft.value = null
pendingAttachments.value = []
if (recording.value) {
discardRecording = true
cancelVoiceRecording()
} else if (recordingStarting.value) {
cleanupRecorder()
}
if (discarded) showToast(errorText('service_line_text_only'))
})
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
@@ -1614,7 +1656,7 @@ onBeforeUnmount(() => {
</SkyScrollArea>
<section
v-if="activeCanMessage && attachmentMenuOpen"
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1659,7 +1701,9 @@ onBeforeUnmount(() => {
<SkySheet
class="messages-media-picker-sheet"
:opened="activeCanMessage && attachmentPicker !== null"
:opened="
activeCanMessage && !activeServiceLine && attachmentPicker !== null
"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
@@ -1794,7 +1838,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="activeCanMessage && shareDraft && !recording"
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1810,7 +1854,7 @@ onBeforeUnmount(() => {
</div>
<section
v-if="activeCanMessage && recording"
v-if="activeCanMessage && !activeServiceLine && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1898,6 +1942,7 @@ onBeforeUnmount(() => {
<div class="messages-sky-composer-row">
<SkyGlass
v-if="!activeServiceLine"
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
@@ -1934,7 +1979,7 @@ onBeforeUnmount(() => {
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else
v-else-if="!activeServiceLine"
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
+2 -2
View File
@@ -1978,7 +1978,7 @@ const contacts = [
},
{
canCall: true,
canMessage: false,
canMessage: true,
companyId: 'police',
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
id: 'company:police',
@@ -4187,7 +4187,7 @@ const companyProfiles = [
availability: 'available',
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
canCall: true,
canMessage: false,
canMessage: true,
categoryId: 'public_services',
categoryName: 'Public Services',
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
+1 -1
View File
@@ -1190,7 +1190,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = false,
CanMessage = true,
Routing = "round_robin",
MinimumGrade = 0,
},
+1
View File
@@ -695,6 +695,7 @@ Locales["de"] = {
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
},
+1
View File
@@ -695,6 +695,7 @@ Locales["en"] = {
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
service_line_text_only = "Company service lines currently accept text messages only.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
+1
View File
@@ -695,6 +695,7 @@ Locales["es"] = {
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
},
+232 -10
View File
@@ -337,11 +337,6 @@ local function validate_configuration(configuration)
tostring(line.Routing)
)
end
if line.CanMessage then
return nil, ("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(
company_id
)
end
line.Number = number
validated_definitions[company_id] = definition
validated_definition_ids[#validated_definition_ids + 1] = company_id
@@ -1193,7 +1188,7 @@ end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
@@ -1895,6 +1890,175 @@ local function notification_payload(kind, area, row)
}
end
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
if not Config.Companies.Enabled or type(data) ~= "table"
or data.messageType ~= "text" or not valid_uuid(data.id)
then
return { success = false, error = "invalid_request" }
end
local device, device_error = current_device(source, false)
if not device then
return device_error
end
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
if not service_line or not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
local body = valid_text(
data.body,
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
false
)
if not body then
return { success = false, error = "invalid_message" }
end
local request_id = uuid()
local created_event_id = uuid()
local status_event_id = uuid()
local mutation_token = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
`company_activity_revision`)
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
WHERE sim.`id` = ? AND NOT EXISTS (
SELECT 1 FROM `sky_phone_company_requests` existing
WHERE existing.`company_id` = profile.`company_id`
AND existing.`customer_sim_id` = sim.`id`
AND existing.`channel` = 'service_line'
AND existing.`status` NOT IN ('completed', 'cancelled')
)
]],
params = {
request_id,
body,
service_line.companyId,
device.sim_id,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { created_event_id, request_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { status_event_id, service_line.companyId, device.sim_id },
},
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
`company_activity_revision` = `company_activity_revision` + 1,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line'
AND `status` NOT IN ('completed', 'cancelled')
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { mutation_token, service_line.companyId, device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
FROM `sky_phone_company_requests`
WHERE `mutation_token` = ? LIMIT 1
]],
params = { data.id, body, mutation_token },
},
{
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
WHERE sim.`id` = ?
]],
params = {
data.id,
service_line.number,
body,
data.id,
device.sim_id,
},
},
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local routed = Bridge.Database.Query([[
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
FROM `sky_phone_sms_messages` sms
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
LEFT JOIN `sky_phone_company_request_events` created
ON created.`id` = ? AND created.`request_id` = request.`id`
WHERE sms.`id` = ? LIMIT 1
]], { created_event_id, data.id })
if not routed[1] then
Bridge.Debug(
"error",
"[sky_phone] Service-line message %s committed without a complete route.",
tostring(data.id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(routed[1].request_id)
if not row then
return { success = false, error = "request_failed" }
end
emit_request_change(row, true, true, source)
if routed[1].created_event_id then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
elseif row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("newMessage", "work", row)
)
end
return {
success = true,
data = {
messageId = data.id,
requestId = row.id,
},
}
end
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
if not allowed then
@@ -2160,6 +2324,11 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
then
return { success = false, error = "invalid_status" }
end
local service_line = access.row.channel == "service_line"
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
return { success = false, error = "messaging_unavailable" }
end
local message_id = uuid()
local mutation_token = uuid()
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
@@ -2199,6 +2368,37 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
}
end
if service_line then
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
]],
params = { service_line.number, message_id },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'company'
]],
params = { service_line.number, message_id },
}
end
end
if new_status ~= access.row.status then
statements[#statements + 1] = {
query = [[
@@ -2213,16 +2413,32 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local inserted = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
{ message_id }
)
local inserted = Bridge.Database.Query([[
SELECT message.`id`, sms.`id` AS `sms_id`
FROM `sky_phone_company_request_messages` message
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
WHERE message.`id` = ? LIMIT 1
]], { message_id })
if not inserted[1] then
return { success = false, error = "revision_conflict" }
end
if service_line and not inserted[1].sms_id then
Bridge.Debug(
"error",
"[sky_phone] Company request message %s committed without its service-line SMS.",
tostring(message_id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if access.audience == "customer" then
if service_line then
TriggerClientEvent("sky_phone:messages:changed", source, {
phoneNumber = service_line.number,
})
end
local notification = notification_payload("newMessage", "work", row)
if row.assigned_identifier then
notify_identifier(
@@ -2232,6 +2448,12 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
notification
)
end
elseif service_line then
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
phoneNumber = service_line.number,
sender = service_line.name,
voice = false,
})
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"newMessage",
+2
View File
@@ -2710,6 +2710,7 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
@@ -2729,6 +2730,7 @@ local schema = {
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
+47 -7
View File
@@ -97,6 +97,14 @@ local function current_device(source)
return device
end
local function resolve_message_recipient(value)
local service_line = SkyPhoneCompanies.GetServiceLine(value)
if service_line then
return service_line.number, service_line
end
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
@@ -366,7 +374,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -407,11 +415,7 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
local number = resolve_message_recipient(data.phoneNumbers[index])
if not number then
return { success = false, error = "invalid_number" }
end
@@ -483,7 +487,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number, service_line = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -536,6 +540,42 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
if service_line then
if not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
if message_type ~= "text" then
return { success = false, error = "service_line_text_only" }
end
local id = uuid()
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
id = id,
body = body,
messageType = message_type,
phoneNumber = number,
})
if not routed or not routed.success then
return routed or { success = false, error = "request_failed" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
`read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
if not rows[1] then
Bridge.Debug(
"error",
"[sky_phone] Routed service-line SMS %s could not be read back.",
tostring(id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
return { success = true, data = message }
end
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
+14 -20
View File
@@ -1289,8 +1289,8 @@ local function migrate_police_request_defaults()
)
end
local function migrate_unsupported_company_message_defaults()
local migration_name = "sky-phone:configurator:unsupported-company-message-defaults:v1"
local function migrate_police_service_line_messaging()
local migration_name = "sky-phone:configurator:service-line-messaging:v2"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
@@ -1301,22 +1301,17 @@ local function migrate_unsupported_company_message_defaults()
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local definitions = config_payload.Companies
local police = config_payload.Companies
and config_payload.Companies.Definitions
local migrated_companies = {}
if type(definitions) == "table" then
for company_id, definition in pairs(definitions) do
local line = type(definition) == "table" and definition.ServiceLine or nil
if type(line) == "table" and line.CanMessage == true then
line.CanMessage = false
migrated_companies[#migrated_companies + 1] = tostring(company_id)
end
end
and config_payload.Companies.Definitions.police
local line = type(police) == "table" and police.ServiceLine or nil
local migrated = type(line) == "table" and line.CanMessage ~= true
if migrated then
line.CanMessage = true
end
table.sort(migrated_companies)
local statements = {}
if #migrated_companies > 0 then
if migrated then
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
@@ -1334,13 +1329,13 @@ local function migrate_unsupported_company_message_defaults()
params = {
migration_name,
"sky-phone",
json.encode({ companies = migrated_companies }),
json.encode({ police = migrated }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate unsupported Phone Configurator service-line messaging defaults.")
error("[sky_phone] Could not enable Phone Configurator police service-line messaging.")
end
if #migrated_companies == 0 then
if not migrated then
return
end
@@ -1350,8 +1345,7 @@ local function migrate_unsupported_company_message_defaults()
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Disabled unsupported Phone Configurator service-line messaging for: %s",
table.concat(migrated_companies, ", "),
"[sky_phone] Enabled Phone Configurator police service-line messaging.",
{ always = true }
)
end
@@ -1378,7 +1372,7 @@ apply_stored_row(read_stored_row())
apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
Bridge.Database.AfterMigration("sky_phone", migrate_unsupported_company_message_defaults)
Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
+1 -1
View File
@@ -1175,7 +1175,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = false,
CanMessage = true,
Routing = "round_robin",
MinimumGrade = 0,
},