From fd24c8b272d3b9eed1f2d8acc1d71338588ab041 Mon Sep 17 00:00:00 2001 From: Alec Schitzkat Date: Mon, 24 Aug 2026 01:13:58 +0200 Subject: [PATCH] FIX - route company service-line messages --- ...paniesServiceLineMessages.contract.test.ts | 133 ++++++++++ ...mpanyConfiguratorCreation.contract.test.ts | 15 +- frontend/src/stores/phone.ts | 2 + .../views/apps/MessagesApp.contract.test.ts | 2 +- frontend/src/views/apps/MessagesApp.vue | 69 ++++- frontend/testserver/index.cjs | 4 +- sky_phone/config/config.lua | 2 +- sky_phone/config/locales/de.lua | 1 + sky_phone/config/locales/en.lua | 1 + sky_phone/config/locales/es.lua | 1 + sky_phone/source/server/companies.lua | 242 +++++++++++++++++- sky_phone/source/server/db_migrate.lua | 2 + sky_phone/source/server/messages.lua | 54 +++- .../source/server/phone_configurator.lua | 34 +-- sky_phone/source/shared/config_default.lua | 2 +- 15 files changed, 504 insertions(+), 60 deletions(-) create mode 100644 frontend/src/companiesServiceLineMessages.contract.test.ts diff --git a/frontend/src/companiesServiceLineMessages.contract.test.ts b/frontend/src/companiesServiceLineMessages.contract.test.ts new file mode 100644 index 0000000..e9b372c --- /dev/null +++ b/frontend/src/companiesServiceLineMessages.contract.test.ts @@ -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 =') + } + }) +}) diff --git a/frontend/src/companyConfiguratorCreation.contract.test.ts b/frontend/src/companyConfiguratorCreation.contract.test.ts index 3c5e787..b9461dd 100644 --- a/frontend/src/companyConfiguratorCreation.contract.test.ts +++ b/frontend/src/companyConfiguratorCreation.contract.test.ts @@ -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', + ) }) }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 6def827..67f53f0 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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.', diff --git a/frontend/src/views/apps/MessagesApp.contract.test.ts b/frontend/src/views/apps/MessagesApp.contract.test.ts index 02708a0..ff49e67 100644 --- a/frontend/src/views/apps/MessagesApp.contract.test.ts +++ b/frontend/src/views/apps/MessagesApp.contract.test.ts @@ -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') diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue index 30b54ed..d4682b8 100644 --- a/frontend/src/views/apps/MessagesApp.vue +++ b/frontend/src/views/apps/MessagesApp.vue @@ -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 { } 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 { - 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 { - 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 { - 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(() => {
{ {
{
{ 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 diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 3cb4bbd..8b009d9 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -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() diff --git a/sky_phone/source/shared/config_default.lua b/sky_phone/source/shared/config_default.lua index b25afe4..bfa55c2 100644 --- a/sky_phone/source/shared/config_default.lua +++ b/sky_phone/source/shared/config_default.lua @@ -1175,7 +1175,7 @@ if IsDuplicityVersion() then Number = "911", AutoContact = true, CanCall = true, - CanMessage = false, + CanMessage = true, Routing = "round_robin", MinimumGrade = 0, },