mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
92179352fb
commit
0646656884
5 changed files with 86 additions and 11 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
20
tests/tools/test_send_message_target_parse.py
Normal file
20
tests/tools/test_send_message_target_parse.py
Normal 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
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue