From 2e8a6245bf9e2768591921543b3df38b20cdf3fd Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 25 Jun 2026 13:31:35 +0200 Subject: [PATCH 1/2] feat(delivery): add relaycast target so agents reply over the relay (#254, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `relaycast` as a first-class delivery target alongside slack/telegram, so the existing "reply to origin transport" pattern (hn-monitor/inbox-buddy/joke-bot) covers agent-to-agent relay replies with no per-persona code. Unlike slack/telegram (config-driven via persona inputs), the relaycast reply is EVENT-driven: the `to` address is the inbound message's sender, supplied by the caller via `transports.relaycast = { to, sender? }`. `resolveDeliveryTargets` stays slack/telegram-only; relaycast is added in createDelivery when an address is present, so existing callers are unaffected. The default sender DMs the peer via `POST /v1/dm` with the box's injected RELAY_API_KEY. Base URL is a single source of truth (`DEFAULT_RELAYCAST_URL = https://cast.agentrelay.com`) overridable per-env via `RELAYCAST_URL` > `RELAY_BASE_URL` — easy to change as the gateway cutover settles. - types: `RelaycastRef`, `RelaycastSender`, `RelaycastTarget`, `DeliveryProvider`; widen `MessageRef`/`DeliveryClient.targets`/`onlyTargets` to include relaycast - new `relaycast.ts`: `DEFAULT_RELAYCAST_URL`, `resolveRelaycastUrl`, `defaultRelaycastSender` - tests: URL precedence + relaycast delivery path (6/6 green), typecheck clean Next (separate): runtime `ctx.relay.reply()` + surface inbound sender on the relay event; cloud default `inbox_selectors` to @self; then publish + consume in agents and drop hn-monitor's slack-only relay fallback. Refs AgentWorkforce/workforce#254 Co-Authored-By: Claude Opus 4.8 --- packages/delivery/src/delivery.ts | 51 +++++++++++++++--- packages/delivery/src/index.ts | 6 +++ packages/delivery/src/relaycast.test.ts | 72 +++++++++++++++++++++++++ packages/delivery/src/relaycast.ts | 58 ++++++++++++++++++++ packages/delivery/src/types.ts | 45 +++++++++++++++- 5 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 packages/delivery/src/relaycast.test.ts create mode 100644 packages/delivery/src/relaycast.ts diff --git a/packages/delivery/src/delivery.ts b/packages/delivery/src/delivery.ts index cfddcc2..b569cbf 100644 --- a/packages/delivery/src/delivery.ts +++ b/packages/delivery/src/delivery.ts @@ -7,11 +7,15 @@ import { telegramChat, type DeliveryClient, type DeliveryOptions, + type DeliveryProvider, type DeliveryResult, type DeliveryTransports, + type RelaycastRef, + type RelaycastSender, type SlackRef, type TelegramRef } from './types.js'; +import { defaultRelaycastSender } from './relaycast.js'; const WRITEBACK_TIMEOUT_MS = 45_000; @@ -35,9 +39,12 @@ export function createDelivery( ctx: WorkforceCtx, transports?: DeliveryTransports, /** Override which transports to target (defaults to all configured). */ - onlyTargets?: ReadonlyArray<'slack' | 'telegram'> + onlyTargets?: ReadonlyArray ): DeliveryClient { - const allTargets = resolveDeliveryTargets(ctx); + // Slack/Telegram are config-driven (persona inputs). Relaycast is event-driven + // — it's a target only when the caller supplies a reply address in transports. + const allTargets: DeliveryProvider[] = [...resolveDeliveryTargets(ctx)]; + if (transports?.relaycast?.to) allTargets.push('relaycast'); const targets = onlyTargets ? allTargets.filter((t) => (onlyTargets as readonly string[]).includes(t)) : allTargets; @@ -60,11 +67,21 @@ export function createDelivery( ? telegramClient({ writebackTimeoutMs: 0 }) : undefined); + // Relaycast reply: address from the inbound event, client from the injected + // sender or the default env-backed one (POST /v1/dm with RELAY_API_KEY). + const relaycast = targets.includes('relaycast') && transports?.relaycast?.to + ? { + to: transports.relaycast.to, + sender: transports.relaycast.sender ?? defaultRelaycastSender(ctx) + } + : undefined; + return new DeliveryClientImpl(ctx, targets, { slackBlocking, slackNonBlocking, telegramBlocking, - telegramNonBlocking + telegramNonBlocking, + relaycast }); } @@ -73,17 +90,18 @@ interface DeliveryTransportsInternal { slackNonBlocking?: SlackClient; telegramBlocking?: TelegramClient; telegramNonBlocking?: TelegramClient; + relaycast?: { to: string; sender: RelaycastSender }; } class DeliveryClientImpl implements DeliveryClient { - readonly targets: ReadonlyArray<'slack' | 'telegram'>; + readonly targets: ReadonlyArray; private ctx: WorkforceCtx; private t: DeliveryTransportsInternal; constructor( ctx: WorkforceCtx, - targets: Array<'slack' | 'telegram'>, + targets: Array, transports: DeliveryTransportsInternal ) { this.ctx = ctx; @@ -93,7 +111,7 @@ class DeliveryClientImpl implements DeliveryClient { async send(text: string, opts?: DeliveryOptions): Promise { const nonBlocking = opts?.nonBlocking === true; - const refs: Array = []; + const refs: Array = []; const errors: string[] = []; const tasks: Promise[] = []; @@ -114,6 +132,14 @@ class DeliveryClientImpl implements DeliveryClient { .catch((err) => { errors.push(`telegram: ${String(err)}`); }) ); } + if (target === 'relaycast') { + // Relaycast DMs are a single API call — no separate non-blocking path. + tasks.push( + this.sendRelaycast(text) + .then((ref) => { if (ref) refs.push(ref); }) + .catch((err) => { errors.push(`relaycast: ${String(err)}`); }) + ); + } } await Promise.all(tasks); @@ -213,6 +239,19 @@ class DeliveryClientImpl implements DeliveryClient { }; } + // ── Relaycast (agent-to-agent) ─────────────────────────────────────────── + + private async sendRelaycast(text: string): Promise { + const rc = this.t.relaycast; + if (!rc) return null; + const res = await rc.sender.dm(rc.to, text); + if (!res.ok) { + this.ctx.log?.('warn', 'delivery.relaycast.no-receipt', { to: rc.to }); + return null; + } + return { provider: 'relaycast', to: rc.to, messageId: res.messageId ?? '' }; + } + // ── Telegram ─────────────────────────────────────────────────────────── private async sendTelegram( diff --git a/packages/delivery/src/index.ts b/packages/delivery/src/index.ts index 3836b3c..c81f928 100644 --- a/packages/delivery/src/index.ts +++ b/packages/delivery/src/index.ts @@ -5,11 +5,17 @@ export { telegramChat, type DeliveryClient, type DeliveryOptions, + type DeliveryProvider, type DeliveryResult, type DeliveryTransports, type MessageRef, + type RelaycastRef, + type RelaycastSender, + type RelaycastTarget, type SlackRef, type TelegramRef } from './types.js'; +export { DEFAULT_RELAYCAST_URL, resolveRelaycastUrl, defaultRelaycastSender } from './relaycast.js'; + export { input, list, withTimeout, fetchWithTimeout } from './helpers.js'; diff --git a/packages/delivery/src/relaycast.test.ts b/packages/delivery/src/relaycast.test.ts new file mode 100644 index 0000000..39f4ad9 --- /dev/null +++ b/packages/delivery/src/relaycast.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDelivery } from './delivery.js'; +import { resolveRelaycastUrl, DEFAULT_RELAYCAST_URL } from './relaycast.js'; +import type { WorkforceCtx } from '@agentworkforce/runtime'; +import type { RelaycastSender } from './types.js'; + +function makeCtx(inputs: Record = {}): WorkforceCtx { + return { persona: { inputs, inputSpecs: {} }, log: () => {} } as unknown as WorkforceCtx; +} + +test('DEFAULT_RELAYCAST_URL is cast.agentrelay.com', () => { + assert.equal(DEFAULT_RELAYCAST_URL, 'https://cast.agentrelay.com'); +}); + +test('resolveRelaycastUrl: default, RELAY_BASE_URL, then RELAYCAST_URL precedence (trailing slash trimmed)', () => { + const saved = { u: process.env.RELAYCAST_URL, b: process.env.RELAY_BASE_URL }; + try { + delete process.env.RELAYCAST_URL; + delete process.env.RELAY_BASE_URL; + assert.equal(resolveRelaycastUrl(), 'https://cast.agentrelay.com'); + + process.env.RELAY_BASE_URL = 'https://relay.example.com/'; + assert.equal(resolveRelaycastUrl(), 'https://relay.example.com'); + + process.env.RELAYCAST_URL = 'https://cast.example.com'; + assert.equal(resolveRelaycastUrl(), 'https://cast.example.com'); // RELAYCAST_URL wins + } finally { + saved.u === undefined ? delete process.env.RELAYCAST_URL : (process.env.RELAYCAST_URL = saved.u); + saved.b === undefined ? delete process.env.RELAY_BASE_URL : (process.env.RELAY_BASE_URL = saved.b); + } +}); + +test('relaycast target DMs the inbound sender and returns a RelaycastRef', async () => { + const sent: Array<{ to: string; text: string }> = []; + const sender: RelaycastSender = { + async dm(to, text) { + sent.push({ to, text }); + return { ok: true, messageId: 'm1' }; + } + }; + const delivery = createDelivery(makeCtx(), { relaycast: { to: 'local-tester', sender } }); + + assert.deepEqual([...delivery.targets], ['relaycast']); + const res = await delivery.send('hello over relay'); + assert.equal(res.ok, true); + assert.deepEqual(sent, [{ to: 'local-tester', text: 'hello over relay' }]); + assert.deepEqual(res.refs, [{ provider: 'relaycast', to: 'local-tester', messageId: 'm1' }]); +}); + +test('relaycast is NOT a target unless a reply address is supplied (event-driven, not config)', () => { + assert.equal(createDelivery(makeCtx(), {}).targets.includes('relaycast'), false); + // Even with slack configured, relaycast only appears when transports.relaycast.to is set. + assert.deepEqual([...createDelivery(makeCtx({ SLACK_CHANNEL: 'C1' }), {}).targets], ['slack']); +}); + +test('onlyTargets can scope delivery to relaycast (origin-only reply)', () => { + const sender: RelaycastSender = { async dm() { return { ok: true, messageId: 'x' }; } }; + const delivery = createDelivery( + makeCtx({ SLACK_CHANNEL: 'C1' }), + { relaycast: { to: 'peer', sender } }, + ['relaycast'] + ); + assert.deepEqual([...delivery.targets], ['relaycast']); // slack filtered out +}); + +test('relaycast-only send failure surfaces (matches slack/telegram all-targets-failed contract)', async () => { + const sender: RelaycastSender = { async dm() { return { ok: false }; } }; + const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); + await assert.rejects(() => delivery.send('x'), /Delivery failed to all targets/); +}); diff --git a/packages/delivery/src/relaycast.ts b/packages/delivery/src/relaycast.ts new file mode 100644 index 0000000..0e884e6 --- /dev/null +++ b/packages/delivery/src/relaycast.ts @@ -0,0 +1,58 @@ +import type { WorkforceCtx } from '@agentworkforce/runtime'; +import type { RelaycastSender } from './types.js'; + +/** + * Canonical relaycast gateway — SINGLE SOURCE OF TRUTH. Change this one + * constant to move the default gateway. Per-env override via `RELAYCAST_URL` + * (preferred) or `RELAY_BASE_URL`. + */ +export const DEFAULT_RELAYCAST_URL = 'https://cast.agentrelay.com'; + +/** Resolve the relaycast base URL: RELAYCAST_URL > RELAY_BASE_URL > default. */ +export function resolveRelaycastUrl(): string { + const raw = + process.env.RELAYCAST_URL?.trim() || + process.env.RELAY_BASE_URL?.trim() || + DEFAULT_RELAYCAST_URL; + return raw.replace(/\/+$/, ''); +} + +/** + * Default relaycast sender — DMs a peer agent via `POST /v1/dm` using the box's + * injected `RELAY_API_KEY`. Never throws: returns `{ ok: false }` (logged) when + * the key is missing or the call fails, so a relay reply degrades gracefully + * rather than crashing the handler. + */ +export function defaultRelaycastSender(ctx: WorkforceCtx): RelaycastSender { + const apiKey = process.env.RELAY_API_KEY?.trim(); + const baseUrl = resolveRelaycastUrl(); + return { + async dm(to: string, text: string): Promise<{ ok: boolean; messageId?: string }> { + if (!apiKey) { + ctx.log?.('warn', 'delivery.relaycast.no-api-key', { + reason: 'RELAY_API_KEY not present in the agent box' + }); + return { ok: false }; + } + try { + const res = await fetch(`${baseUrl}/v1/dm`, { + method: 'POST', + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ to, text }) + }); + if (!res.ok) { + ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, status: res.status }); + return { ok: false }; + } + const data = (await res.json().catch(() => null)) as + | { message?: { id?: unknown }; messageId?: unknown; id?: unknown } + | null; + const rawId = data?.message?.id ?? data?.messageId ?? data?.id; + return { ok: true, messageId: rawId != null ? String(rawId) : undefined }; + } catch (err) { + ctx.log?.('warn', 'delivery.relaycast.send-error', { to, error: String(err) }); + return { ok: false }; + } + } + }; +} diff --git a/packages/delivery/src/types.ts b/packages/delivery/src/types.ts index 4b4aec3..5f0a492 100644 --- a/packages/delivery/src/types.ts +++ b/packages/delivery/src/types.ts @@ -21,7 +21,18 @@ export interface TelegramRef { messageId: string; } -export type MessageRef = SlackRef | TelegramRef; +export interface RelaycastRef { + provider: 'relaycast'; + /** The agent the reply was DM'd to (the inbound message's sender). */ + to: string; + /** Delivered relaycast message id, when the send returns one. */ + messageId: string; +} + +export type MessageRef = SlackRef | TelegramRef | RelaycastRef; + +/** A delivery target provider. */ +export type DeliveryProvider = 'slack' | 'telegram' | 'relaycast'; // ── delivery result ───────────────────────────────────────────────────── @@ -49,6 +60,29 @@ export interface DeliveryOptions { nonBlocking?: boolean; } +// ── relaycast (agent-to-agent) transport ────────────────────────────────── + +/** + * Minimal seam for sending a relaycast DM back to a peer agent. The default + * implementation posts `POST /v1/dm` with the box's injected `RELAY_API_KEY`; + * tests inject a mock. Unlike Slack/Telegram (config-driven via persona + * inputs), the relaycast reply address is EVENT-driven — `to` is the inbound + * message's sender, supplied by the caller. + */ +export interface RelaycastSender { + dm(to: string, text: string): Promise<{ ok: boolean; messageId?: string }>; +} + +/** + * Relaycast target config. Present iff the agent is replying to a relay DM: + * `to` is the inbound sender to reply to; `sender` overrides the default + * env-backed client (for tests). + */ +export interface RelaycastTarget { + to: string; + sender?: RelaycastSender; +} + // ── injectable transport seam (for tests) ──────────────────────────────── export interface DeliveryTransports { @@ -56,6 +90,13 @@ export interface DeliveryTransports { slack?: SlackClient; /** Injected Telegram client (used for both blocking and non-blocking paths). */ telegram?: TelegramClient; + /** + * Relaycast reply target. When set, `relaycast` becomes a delivery target + * and `send()`/`publish()` DM the inbound sender over the relay. Event-driven + * (the `to` address comes from the inbound message), so it is NOT discovered + * by `resolveDeliveryTargets(ctx)`. + */ + relaycast?: RelaycastTarget; } // ── delivery client ────────────────────────────────────────────────────── @@ -83,7 +124,7 @@ export interface DeliveryClient { publish(text: string): Promise; /** Which providers are configured. */ - readonly targets: ReadonlyArray<'slack' | 'telegram'>; + readonly targets: ReadonlyArray; } // ── configuration discovery ────────────────────────────────────────────── From 61db70235e876e02d318525d1ae6656919cc44e0 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 25 Jun 2026 13:44:15 +0200 Subject: [PATCH 2/2] fix(delivery): address relaycast-target review feedback (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound the relaycast DM with fetchWithTimeout instead of bare fetch (gemini, coderabbit) — no indefinite hang if the gateway stalls; degrade to {ok:false}. - Authenticate /v1/dm with the AGENT token, not the workspace key (codex P1): WORKFORCE_AGENT_TOKEN > RELAY_AGENT_TOKEN > RELAY_API_KEY fallback. Workspace key alone gets rejected by the agent-scoped /dm endpoint. - Unwrap the relaycast `{ ok, data }` envelope before reading the message id (codex P2) — id lives under data.message.id / data.id; previously parsed top-level only and returned messageId:''. - Treat ok:true with no messageId as a failed delivery (return null), matching slack/telegram missing-receipt handling (coderabbit). - Keep relaycast out of publish()/non-blocking sends — it's a single blocking DM with no draft-ref/threading path (coderabbit); skip + debug-log instead. Tests: +missing-id-is-failure, +publish-skips-relaycast. 8/8 green. Refs AgentWorkforce/workforce#254 --- packages/delivery/src/delivery.ts | 15 ++++-- packages/delivery/src/relaycast.test.ts | 16 ++++++ packages/delivery/src/relaycast.ts | 72 ++++++++++++++++--------- 3 files changed, 76 insertions(+), 27 deletions(-) diff --git a/packages/delivery/src/delivery.ts b/packages/delivery/src/delivery.ts index b569cbf..c494c54 100644 --- a/packages/delivery/src/delivery.ts +++ b/packages/delivery/src/delivery.ts @@ -133,7 +133,13 @@ class DeliveryClientImpl implements DeliveryClient { ); } if (target === 'relaycast') { - // Relaycast DMs are a single API call — no separate non-blocking path. + // Relaycast replies are a single blocking DM — there's no draft-ref / + // server-ordered threading pattern, so they don't participate in + // publish()/non-blocking sends (which promise no receipt wait). + if (nonBlocking) { + this.ctx.log?.('debug', 'delivery.relaycast.skip-nonblocking', { to: this.t.relaycast?.to }); + continue; + } tasks.push( this.sendRelaycast(text) .then((ref) => { if (ref) refs.push(ref); }) @@ -245,11 +251,14 @@ class DeliveryClientImpl implements DeliveryClient { const rc = this.t.relaycast; if (!rc) return null; const res = await rc.sender.dm(rc.to, text); - if (!res.ok) { + // Treat a missing message id as a failed delivery (matches slack/telegram, + // which return null on a missing receipt) — don't report success with an + // unusable ref. + if (!res.ok || !res.messageId) { this.ctx.log?.('warn', 'delivery.relaycast.no-receipt', { to: rc.to }); return null; } - return { provider: 'relaycast', to: rc.to, messageId: res.messageId ?? '' }; + return { provider: 'relaycast', to: rc.to, messageId: res.messageId }; } // ── Telegram ─────────────────────────────────────────────────────────── diff --git a/packages/delivery/src/relaycast.test.ts b/packages/delivery/src/relaycast.test.ts index 39f4ad9..f50a2e1 100644 --- a/packages/delivery/src/relaycast.test.ts +++ b/packages/delivery/src/relaycast.test.ts @@ -70,3 +70,19 @@ test('relaycast-only send failure surfaces (matches slack/telegram all-targets-f const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); await assert.rejects(() => delivery.send('x'), /Delivery failed to all targets/); }); + +test('relaycast ok:true with no messageId is treated as a failed delivery', async () => { + const sender: RelaycastSender = { async dm() { return { ok: true }; } }; // no messageId + const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); + await assert.rejects(() => delivery.send('x'), /Delivery failed to all targets/); +}); + +test('publish()/non-blocking does not invoke the relaycast sender', async () => { + let calls = 0; + const sender: RelaycastSender = { async dm() { calls++; return { ok: true, messageId: 'm' }; } }; + const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); + // relaycast is the only target → nothing delivered in non-blocking mode → throws, + // and crucially the sender is never called (no draft-ref/threading path for relay). + await assert.rejects(() => delivery.publish('x'), /Delivery failed to all targets/); + assert.equal(calls, 0); +}); diff --git a/packages/delivery/src/relaycast.ts b/packages/delivery/src/relaycast.ts index 0e884e6..f99e2a0 100644 --- a/packages/delivery/src/relaycast.ts +++ b/packages/delivery/src/relaycast.ts @@ -1,5 +1,6 @@ import type { WorkforceCtx } from '@agentworkforce/runtime'; import type { RelaycastSender } from './types.js'; +import { fetchWithTimeout } from './helpers.js'; /** * Canonical relaycast gateway — SINGLE SOURCE OF TRUTH. Change this one @@ -17,42 +18,65 @@ export function resolveRelaycastUrl(): string { return raw.replace(/\/+$/, ''); } +/** + * Resolve the token to authenticate relaycast agent actions (DMs). `/v1/dm` is + * secured with the AGENT token, not the workspace key — so prefer the agent + * token and only fall back to the workspace `RELAY_API_KEY` (which lets tests + * and single-identity boxes still work). Mirrors the runtime's agent-token + * resolution order. + */ +function resolveRelayAgentToken(): string | undefined { + return ( + process.env.WORKFORCE_AGENT_TOKEN?.trim() || + process.env.RELAY_AGENT_TOKEN?.trim() || + process.env.RELAY_API_KEY?.trim() || + undefined + ); +} + /** * Default relaycast sender — DMs a peer agent via `POST /v1/dm` using the box's - * injected `RELAY_API_KEY`. Never throws: returns `{ ok: false }` (logged) when - * the key is missing or the call fails, so a relay reply degrades gracefully - * rather than crashing the handler. + * injected agent token. Bounded by `fetchWithTimeout` and never throws: returns + * `{ ok: false }` (logged) on missing token, timeout, or non-2xx, so a relay + * reply degrades gracefully rather than crashing the handler. */ export function defaultRelaycastSender(ctx: WorkforceCtx): RelaycastSender { - const apiKey = process.env.RELAY_API_KEY?.trim(); + const token = resolveRelayAgentToken(); const baseUrl = resolveRelaycastUrl(); return { async dm(to: string, text: string): Promise<{ ok: boolean; messageId?: string }> { - if (!apiKey) { - ctx.log?.('warn', 'delivery.relaycast.no-api-key', { - reason: 'RELAY_API_KEY not present in the agent box' + if (!token) { + ctx.log?.('warn', 'delivery.relaycast.no-token', { + reason: 'no agent token (WORKFORCE_AGENT_TOKEN/RELAY_AGENT_TOKEN/RELAY_API_KEY) in the agent box' }); return { ok: false }; } - try { - const res = await fetch(`${baseUrl}/v1/dm`, { - method: 'POST', - headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, - body: JSON.stringify({ to, text }) - }); - if (!res.ok) { - ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, status: res.status }); - return { ok: false }; - } - const data = (await res.json().catch(() => null)) as - | { message?: { id?: unknown }; messageId?: unknown; id?: unknown } - | null; - const rawId = data?.message?.id ?? data?.messageId ?? data?.id; - return { ok: true, messageId: rawId != null ? String(rawId) : undefined }; - } catch (err) { - ctx.log?.('warn', 'delivery.relaycast.send-error', { to, error: String(err) }); + const res = await fetchWithTimeout(`${baseUrl}/v1/dm`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ to, text }) + }); + if (!res) { + ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, reason: 'timeout or network error' }); + return { ok: false }; + } + if (!res.ok) { + ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, status: res.status }); return { ok: false }; } + // Relaycast REST wraps success as `{ ok, data }`; the /dm message id lives + // under `data.message.id` (or legacy `data.id`). Unwrap before reading. + const json = (await res.json().catch(() => null)) as Record | null; + const data = + json && typeof json.data === 'object' && json.data !== null + ? (json.data as Record) + : json; + const message = + data && typeof data.message === 'object' && data.message !== null + ? (data.message as Record) + : undefined; + const rawId = message?.id ?? data?.messageId ?? data?.id; + return { ok: true, messageId: rawId != null ? String(rawId) : undefined }; } }; }