diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index b63cbba7b471..5ea99ce0a5fb 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -66,6 +66,7 @@ import http from "node:http"; import crypto from "node:crypto"; import { once } from "node:events"; import { patchSpectrumTs } from "./patch-spectrum-mixed-attachments.mjs"; +import { chooseSendFormat } from "./send-format.mjs"; const projectId = process.env.PHOTON_PROJECT_ID; const projectSecret = process.env.PHOTON_PROJECT_SECRET; @@ -894,10 +895,10 @@ const server = http.createServer(async (req, res) => { // iMessage API, which can 500 on messages containing raw URLs. // Plain-text URLs are auto-linked by iMessage, so route markdown // messages that contain URLs through spectrumText while preserving - // spectrumMarkdown for URL-free markdown. - const hasUrl = /https?:\/\/[^\s)'"<>]+/i.test(text); + // spectrumMarkdown for URL-free markdown. The decision lives in + // send-format.mjs so tests can exercise it directly. const builder = - format === "markdown" && !hasUrl + chooseSendFormat(format, text) === "markdown" ? spectrumMarkdown(text) : spectrumText(text); const result = await space.send(builder); diff --git a/plugins/platforms/photon/sidecar/send-format.mjs b/plugins/platforms/photon/sidecar/send-format.mjs new file mode 100644 index 000000000000..68d2047792b7 --- /dev/null +++ b/plugins/platforms/photon/sidecar/send-format.mjs @@ -0,0 +1,27 @@ +// Outbound /send builder selection for the Photon sidecar. +// +// spectrumMarkdown() enables data detection (enableDataDetection) in the +// underlying iMessage API, which can 500 on messages containing raw URLs. +// Plain-text URLs are auto-linked by iMessage anyway, so markdown messages +// that contain a URL are routed through the text builder, while URL-free +// markdown keeps native markdown rendering. +// +// This lives in its own module (rather than inline in index.mjs) so tests can +// execute the real decision logic under node instead of grepping source — +// see tests/plugins/platforms/photon/test_url_send_path.py. + +const URL_RE = /https?:\/\/[^\s)'"<>]+/i; + +/** + * Decide which spectrum-ts builder the /send handler should use. + * + * @param {string} format "markdown" | "text" (already validated by /send) + * @param {string} text the outbound message body + * @returns {"markdown"|"text"} + */ +export function chooseSendFormat(format, text) { + if (format === "markdown" && !URL_RE.test(String(text))) { + return "markdown"; + } + return "text"; +} diff --git a/tests/plugins/platforms/photon/test_url_send_path.py b/tests/plugins/platforms/photon/test_url_send_path.py index f3bfd81b2c16..9cd0d77946c9 100644 --- a/tests/plugins/platforms/photon/test_url_send_path.py +++ b/tests/plugins/platforms/photon/test_url_send_path.py @@ -1,50 +1,87 @@ -"""Regression tests for Photon raw-URL message delivery. +"""Behavior tests for Photon raw-URL outbound routing (issue: markdown 500s). The iMessage markdown builder enables data detection inside spectrum-ts. On some IMAgentKit sends, that path returns a 500 when the message contains a raw -URL. The sidecar should keep markdown rendering for URL-free messages, but use +URL. The sidecar keeps markdown rendering for URL-free messages, but must use plain text for messages containing URLs so iMessage can auto-link them without hitting the data-detection failure path. -""" +The routing decision lives in +``plugins/platforms/photon/sidecar/send-format.mjs`` (imported by index.mjs's +``/send`` handler). These tests *execute* that real module under node and +assert the chosen builder for representative payloads — they do not read the +sidecar source. +""" from __future__ import annotations -import re +import json +import subprocess from pathlib import Path +from typing import Dict, Tuple + +import pytest + +_MODULE = Path("plugins/platforms/photon/sidecar/send-format.mjs").resolve() + +_CASES: Dict[str, Tuple[str, str, str]] = { + # name: (format, text, expected builder) + "markdown_without_url_keeps_markdown": ( + "markdown", "**bold** and `code`", "markdown", + ), + "markdown_with_https_url_falls_back_to_text": ( + "markdown", "see **this**: https://example.com/a?b=1", "text", + ), + "markdown_with_http_url_falls_back_to_text": ( + "markdown", "http://example.com", "text", + ), + "markdown_link_syntax_also_falls_back_to_text": ( + "markdown", "[docs](https://example.com/docs)", "text", + ), + "markdown_with_uppercase_scheme_falls_back_to_text": ( + "markdown", "HTTPS://EXAMPLE.COM is loud", "text", + ), + "markdown_bare_domain_without_scheme_keeps_markdown": ( + # Only scheme'd URLs trip iMessage's data-detection 500; a bare domain + # is ordinary text and must keep markdown rendering. + "markdown", "ask example.com about *this*", "markdown", + ), + "text_format_stays_text": ("text", "plain message", "text"), + "text_format_with_url_stays_text": ( + "text", "https://example.com", "text", + ), +} -SIDECAR = Path("plugins/platforms/photon/sidecar/index.mjs") - - -def _source() -> str: - return SIDECAR.read_text(encoding="utf-8") - - -def test_photon_sidecar_detects_raw_urls_before_builder_selection() -> None: - src = _source() - - assert "hasUrl" in src - assert "https?:\\/\\/" in src - assert re.search(r"const\s+hasUrl\s*=\s*/https\?:", src) - - -def test_markdown_builder_is_used_only_for_url_free_markdown() -> None: - src = _source() - - assert re.search( - r'format\s*===\s*["\']markdown["\']\s*&&\s*!hasUrl', - src, +@pytest.fixture(scope="module") +def verdicts() -> Dict[str, str]: + """Run every case through the real send-format module in one node call.""" + harness = ( + f"import {{ chooseSendFormat }} from {json.dumps(_MODULE.as_uri())};\n" + "const chunks = [];\n" + "process.stdin.on('data', (c) => chunks.push(c));\n" + "process.stdin.on('end', () => {\n" + " const cases = JSON.parse(Buffer.concat(chunks).toString('utf-8'));\n" + " const out = {};\n" + " for (const [name, [format, text]] of Object.entries(cases)) {\n" + " out[name] = chooseSendFormat(format, text);\n" + " }\n" + " process.stdout.write(JSON.stringify(out));\n" + "});\n" ) - assert "spectrumMarkdown(text)" in src - - -def test_text_builder_is_fallback_for_markdown_messages_with_urls() -> None: - src = _source() - - # Builder selection must still have spectrumText as the fallback branch so - # markdown+URL messages avoid spectrumMarkdown's data-detection path. - assert "spectrumText(text)" in src - assert re.search( - r'format\s*===\s*["\']markdown["\']\s*&&\s*!hasUrl[\s\S]*\?[\s\S]*spectrumMarkdown\(text\)[\s\S]*:[\s\S]*spectrumText\(text\)', - src, + payload = {name: [fmt, text] for name, (fmt, text, _) in _CASES.items()} + run = subprocess.run( + ["node", "--input-type=module", "-e", harness], + input=json.dumps(payload), + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +@pytest.mark.parametrize("name", sorted(_CASES)) +def test_send_builder_selection(name: str, verdicts: Dict[str, str]) -> None: + _, _, expected = _CASES[name] + assert verdicts[name] == expected