From 5204a99193f24ba1c9a46440416450e3d53b7c9a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 08:35:45 +0000 Subject: [PATCH] fix(alerts): speak links as domain + path, no http:// on calls Alert calls read links aloud scheme and all. alertSpeech now runs spokenLinks first: https://threatcrush.com/incidents/42?t=x becomes threatcrush.com/incidents/42. Query strings, fragments and a trailing slash are dropped. Texts keep the full link. Co-Authored-By: Claude Opus 5.5 --- .../alerts/__tests__/spoken-durations.test.ts | 23 ++++++++++++++++++- src/lib/alerts/call.ts | 17 +++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib/alerts/__tests__/spoken-durations.test.ts b/src/lib/alerts/__tests__/spoken-durations.test.ts index 9a46260d..fb5f8e08 100644 --- a/src/lib/alerts/__tests__/spoken-durations.test.ts +++ b/src/lib/alerts/__tests__/spoken-durations.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it } from "vitest"; -import { alertSpeech, spokenDurations } from "../call"; +import { alertSpeech, spokenDurations, spokenLinks } from "../call"; + +describe("spokenLinks", () => { + it("reads links as domain and path, no scheme", () => { + expect(spokenLinks("see https://threatcrush.com/incidents/42")).toBe("see threatcrush.com/incidents/42"); + expect(spokenLinks("HTTP://dev2.profullstack.com/ down")).toBe("dev2.profullstack.com down"); + expect(spokenLinks("open https://threatcrush.com/i/42?token=abc#top.")).toBe("open threatcrush.com/i/42."); + expect(spokenLinks("(http://a.com/x), and https://b.io")).toBe("(a.com/x), and b.io"); + }); + + it("leaves bare domains and other text alone", () => { + for (const t of ["threatcrush.com/incidents/42", "From 23.95.228.174", "httpd restarted"]) { + expect(spokenLinks(t)).toBe(t); + } + }); + + it("is what the call speaks", () => { + const said = alertSpeech("CRITICAL ssh login. Details: https://threatcrush.com/incidents/42"); + expect(said).not.toMatch(/https?:/); + expect(said).toContain("Details: threatcrush.com/incidents/42."); + }); +}); describe("spokenDurations", () => { it("reads duration shorthand as words, singular and plural", () => { diff --git a/src/lib/alerts/call.ts b/src/lib/alerts/call.ts index 696a2718..f8400eca 100644 --- a/src/lib/alerts/call.ts +++ b/src/lib/alerts/call.ts @@ -34,9 +34,24 @@ export function spokenDurations(text: string): string { }); } +/** + * Links read as domain + path: nobody wants "h t t p colon slash slash" on a + * phone call. "https://threatcrush.com/incidents/42?x=1#a" -> + * "threatcrush.com/incidents/42". Query strings and fragments are dropped + * (tokens read aloud are noise), as is a trailing slash. Texts keep the full + * link; only the spoken copy changes. + */ +export function spokenLinks(text: string): string { + return text.replace(/\bhttps?:\/\/(\S+)/gi, (_all, rest: string) => { + const trailing = rest.match(/[.,;:!?)]+$/)?.[0] ?? ""; + const link = rest.slice(0, rest.length - trailing.length).replace(/[?#].*$/, "").replace(/\/+$/, ""); + return link + trailing; + }); +} + /** What the call says: the alert, then once more, then goodbye. */ export function alertSpeech(message: string): string { - const text = spokenDurations(message).replace(/[^\p{L}\p{N}\s'.,:;!?%$@#&()+=\/-]/gu, " ").replace(/\s+/g, " ").trim(); + const text = spokenDurations(spokenLinks(message)).replace(/[^\p{L}\p{N}\s'.,:;!?%$@#&()+=\/-]/gu, " ").replace(/\s+/g, " ").trim(); const said = /[.!?]$/.test(text) ? text : `${text}.`; return `This is an alert from SMS Hub. ${said} Again: ${said} Goodbye.`; }