fix(photon): support E.164 and DM GUID targets for home channel

Allow PHOTON_HOME_CHANNEL to accept a bare E.164 phone number or a
`any;-;+1...` DM chat GUID in addition to a Spectrum space id. Inbound
DM spaces are cached so replies resolve without a second SDK lookup,
and `photon` is added to _PHONE_PLATFORMS so send_message treats E.164
strings as explicit targets rather than falling through to channel-name
resolution.
This commit is contained in:
underthestars-zhy 2026-06-08 18:22:10 -07:00 committed by Teknium
parent 92179352fb
commit 0646656884
5 changed files with 86 additions and 11 deletions

View file

@ -63,8 +63,8 @@ optional_env:
prompt: "Group mention patterns"
password: false
- name: PHOTON_HOME_CHANNEL
description: "Default Spectrum space id for cron / notification delivery"
prompt: "Home space id"
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
prompt: "Home Photon target"
password: false
- name: PHOTON_HOME_CHANNEL_NAME
description: "Human label for the home channel"

View file

@ -55,6 +55,9 @@ const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
// single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
const MAX_INLINE_ATTACHMENT_BYTES =
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
const E164_RE = /^\+\d{6,}$/;
const MAX_KNOWN_SPACES = 2048;
if (!projectId || !projectSecret || !sharedToken) {
console.error(
@ -91,6 +94,34 @@ const app = await Spectrum({
// At most one Python consumer is attached at a time (the gateway adapter).
let consumerRes = null;
let consumerWaiters = [];
const knownSpaces = new Map();
function rememberKnownSpace(id, space) {
if (!id || typeof id !== "string" || !space) return;
if (knownSpaces.has(id)) knownSpaces.delete(id);
knownSpaces.set(id, space);
if (knownSpaces.size > MAX_KNOWN_SPACES) {
const oldest = knownSpaces.keys().next().value;
if (oldest) knownSpaces.delete(oldest);
}
}
function phoneTargetFromSpaceId(spaceId) {
if (typeof spaceId !== "string") return null;
if (E164_RE.test(spaceId)) return spaceId;
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
return dmGuid ? dmGuid[1] : null;
}
function rememberInboundSpace(space, message) {
const msgSpace = message?.space || {};
const ids = [space?.id, msgSpace.id];
for (const id of ids) {
rememberKnownSpace(id, space);
const phone = phoneTargetFromSpaceId(id);
if (phone) rememberKnownSpace(phone, space);
}
}
function waitForConsumer() {
if (consumerRes) return Promise.resolve();
@ -215,6 +246,7 @@ async function normalizeEvent(space, message) {
if (message && message.direction && message.direction !== "inbound") {
continue;
}
rememberInboundSpace(space, message);
const event = await normalizeEvent(space, message);
if (!event) continue;
await deliver(JSON.stringify(event));
@ -303,17 +335,26 @@ function handleInbound(req, res) {
}
async function resolveSpace(spaceId) {
const cached = knownSpaces.get(spaceId);
if (cached) return cached;
const phoneTarget = phoneTargetFromSpaceId(spaceId);
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
// space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can
// pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of
// an opaque inbound space id. Real inbound space ids never match this shape,
// so this only kicks in for phone-addressed sends.
if (typeof spaceId === "string" && /^\+\d{6,}$/.test(spaceId) && imessage) {
// an opaque inbound space id. Photon also represents DM chat ids as
// `any;-;+1...`; normalize those through the same path so replies to inbound
// DMs still resolve after Python stores the inbound `space.id`.
if (phoneTarget && imessage) {
try {
const im = imessage(app);
if (typeof im.user === "function" && typeof im.space === "function") {
const user = await im.user(spaceId);
return await im.space(user);
const user = await im.user(phoneTarget);
const space = await im.space(user);
rememberKnownSpace(spaceId, space);
rememberKnownSpace(phoneTarget, space);
rememberKnownSpace(space?.id, space);
return space;
}
} catch (e) {
console.error(
@ -327,16 +368,25 @@ async function resolveSpace(spaceId) {
// narrowed helpers; we fall back through a few accessor shapes to
// tolerate small SDK API drift.
if (typeof app.space === "function") {
return await app.space(spaceId);
const space = await app.space(spaceId);
rememberKnownSpace(spaceId, space);
rememberKnownSpace(space?.id, space);
return space;
}
if (app.spaces && typeof app.spaces.get === "function") {
return await app.spaces.get(spaceId);
const space = await app.spaces.get(spaceId);
rememberKnownSpace(spaceId, space);
rememberKnownSpace(space?.id, space);
return space;
}
if (imessage) {
const im = imessage(app);
if (typeof im.space === "function") {
try {
return await im.space({ id: spaceId });
const space = await im.space({ id: spaceId });
rememberKnownSpace(spaceId, space);
rememberKnownSpace(space?.id, space);
return space;
} catch {
/* fall through */
}

View file

@ -0,0 +1,20 @@
"""Parser-only tests for send_message targets.
These stay separate from ``test_send_message_tool.py`` because that module
skips wholesale when optional Telegram dependencies are not installed.
"""
from tools.send_message_tool import _parse_target_ref
def test_photon_e164_target_is_explicit() -> None:
chat_id, thread_id, is_explicit = _parse_target_ref("photon", "+15551234567")
assert chat_id == "+15551234567"
assert thread_id is None
assert is_explicit is True
def test_e164_target_still_requires_phone_platform() -> None:
assert _parse_target_ref("matrix", "+15551234567")[2] is False

View file

@ -1199,6 +1199,11 @@ class TestParseTargetRefE164:
assert chat_id == "+15551234567"
assert is_explicit is True
def test_photon_e164_is_explicit(self):
chat_id, _, is_explicit = _parse_target_ref("photon", "+15551234567")
assert chat_id == "+15551234567"
assert is_explicit is True
def test_signal_bare_digits_still_work(self):
"""Bare digit strings continue to match the generic numeric branch."""
chat_id, _, is_explicit = _parse_target_ref("signal", "15551234567")

View file

@ -38,7 +38,7 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
# below and falls through to channel-name resolution, which has no way to
# resolve a raw phone number. Keeping the '+' preserves the E.164 form that
# downstream adapters (signal, etc.) expect.
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
_PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"})
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
# Email addresses — a valid email like "user@domain.com" should be treated as
# an explicit target for the email platform, not fall through to channel-name