/** * Unified outbound delivery — the one place agent→human messages leave the system. * * Three delivery targets, one contract: * deliverMac(content) → `mac:push` frame to connected Mac apps (notch card + TTS) * deliverChat(content, opts) → chat timeline + live chat:sync + web-push notification * deliverChannel(ch, to, ...) → WhatsApp/Telegram via the channel manager * * Invariants: * - Chat is the source of truth: every deliverer persists what it sent into the current * shared conversation (meta.proactive = true), so the timeline records ALL agent→human * messages regardless of surface. Persist failures are reported, never silent. * - Results are honest: `delivered` reflects what we actually observed (a counted Mac * recipient, a provider send that didn't throw), never a blind success. * * extractOutboundTags() is the shared parser for / blocks in agent * output. Both turn pipelines call it (scheduler pulse/cron AND interactive turns), so the * tags behave identically everywhere — the pre-refactor split (scheduler-only parsing) is * what made proactive pushes silently dead on interactive turns. */ import { log } from '../shared/logger.js'; import type { ChannelManager } from './channels/manager.js'; export interface OutboundMessageTag { content: string; title?: string; priority?: string; } export interface ExtractedOutbound { macPushes: string[]; messages: OutboundMessageTag[]; /** The input with all / blocks removed — what should be persisted/broadcast as the reply. */ strippedText: string; } /** Pull and blocks out of agent output. Returns the blocks plus the * text with them removed, so raw wrapper tags never reach the DB or a chat bubble. */ export function extractOutboundTags(text: string): ExtractedOutbound { const macPushes: string[] = []; const messages: OutboundMessageTag[] = []; if (!text || (!text.includes('') && !text.includes('([\s\S]*?)<\/mac_push>/g, (_m, inner) => { const content = String(inner).trim(); if (content) macPushes.push(content); return ''; }); stripped = stripped.replace(/]*))?>([\s\S]*?)<\/Message>/g, (_m, attrs, inner) => { const content = String(inner).trim(); if (content) { const a = String(attrs || ''); messages.push({ content, title: a.match(/title="([^"]*)"/)?.[1], priority: a.match(/priority="([^"]*)"/)?.[1], }); } return ''; }); return { macPushes, messages, strippedText: stripped.trim() }; } export interface DeliveryResult { ok: boolean; delivered: boolean; /** Mac only: how many identified Mac clients received the frame. */ clients?: number; /** Whether the message was recorded in the chat timeline. */ persisted: boolean; reason?: string; } export interface OutboundDeps { workerApi: (apiPath: string, method?: string, body?: any, timeoutMs?: number) => Promise; broadcastBloby: (type: string, data?: any) => void; /** Send a frame to Mac-identified sockets only (see /bloby/ws?client=mac); returns recipient count. */ sendToMacClients: (type: string, data: any) => number; channelManager: ChannelManager; getModel: () => string; } export type Outbound = ReturnType; export function createOutbound(deps: OutboundDeps) { /** Current shared conversation id, creating one if the user has none yet (same pattern * the scheduler used before this module absorbed it). */ async function resolveConversation(): Promise { const ctx = await deps.workerApi('/api/context/current'); if (ctx?.conversationId) return ctx.conversationId; const conv = await deps.workerApi('/api/conversations', 'POST', { title: 'Chat', model: deps.getModel() }); if (!conv?.id) return undefined; await deps.workerApi('/api/context/set', 'POST', { conversationId: conv.id }); return conv.id; } async function getBotName(): Promise { try { const status = await deps.workerApi('/api/onboard/status'); return status?.agentName || 'Morphy'; } catch { return 'Morphy'; } } /** Record an outbound message in the chat timeline + sync live clients. * Returns false (and warns) on failure — callers surface it in their result. */ async function persistToTimeline(content: string, meta: Record): Promise { try { const convId = await resolveConversation(); if (!convId) return false; // 15s timeout: an in-process INSERT is sub-ms; a hang must not stall the send path. await deps.workerApi(`/api/conversations/${convId}/messages`, 'POST', { role: 'assistant', content, meta, }, 15000); deps.broadcastBloby('chat:sync', { conversationId: convId, message: { role: 'assistant', content, timestamp: new Date().toISOString(), meta }, }); return true; } catch (err: any) { log.warn(`[outbound] timeline persist failed: ${err.message}`); return false; } } /** Push content to the Mac notch. `delivered` counts only identified Mac sockets — * when none are connected we still broadcast (Mac builds predating the ?client=mac * marker are untagged) but report the truth: nothing confirmed. */ async function deliverMac(content: string, opts: { persist?: boolean } = {}): Promise { const clients = deps.sendToMacClients('mac:push', { content }); if (clients === 0) deps.broadcastBloby('mac:push', { content }); const persisted = opts.persist === false ? false : await persistToTimeline(content, { channel: 'mac', proactive: true }); const delivered = clients > 0; log.info(`[outbound] mac push ${delivered ? `delivered to ${clients} client(s)` : 'NOT confirmed — no identified Mac connected (legacy broadcast sent)'}`); return { ok: true, delivered, clients, persisted, reason: delivered ? undefined : 'mac-offline' }; } /** Message the human via the chat surface: persist to the timeline, sync live clients, * and fire a web-push notification for closed tabs / locked devices. */ async function deliverChat(content: string, opts: { title?: string; tag?: string } = {}): Promise { const persisted = await persistToTimeline(content, { proactive: true }); try { const r = await deps.workerApi('/api/push/send', 'POST', { title: opts.title || await getBotName(), body: content.slice(0, 200), tag: opts.tag || 'morphy-proactive', url: '/', }); log.info(`[outbound] chat message persisted=${persisted}, push ${r?.sent ?? 0}/${r?.total ?? 0}`); } catch (err: any) { log.warn(`[outbound] push send failed: ${err.message}`); } return { ok: persisted, delivered: persisted, persisted, reason: persisted ? undefined : 'persist-failed' }; } /** Send via WhatsApp/Telegram, then record it in the timeline. Provider failures throw * (the providers no longer swallow errors) — callers translate to an honest error reply. */ async function deliverChannel( channel: 'whatsapp' | 'telegram', to: string, text?: string, media?: { type: 'audio' | 'image' | 'video' | 'document'; path: string; mimetype?: string; fileName?: string; voiceNote?: boolean }, ): Promise { if (media) { await deps.channelManager.sendMedia(channel, to, media, text); } else { await deps.channelManager.sendMessage(channel, to, text || ''); } const record = text || `[sent ${media!.type}: ${media!.path}]`; const persisted = await persistToTimeline(record, { channel, to, proactive: true }); return { ok: true, delivered: true, persisted }; } return { deliverMac, deliverChat, deliverChannel }; }