/** * Channel Manager — orchestrates multi-channel messaging. * * Responsibilities: * - Manages channel providers (WhatsApp, Telegram, etc.) * - Resolves sender role (admin vs customer) based on mode * - Routes inbound messages to the agent with appropriate system prompt * - Routes agent responses back to channels * - Manages parallel agent instances for customer conversations (business mode) * * Modes: * - channel: Just talk to me. Only self-chat (fromMe=true) triggers the agent. * All other messages are ignored — it's the user's personal WhatsApp. * - business: Bloby has its own number. Numbers in the admins array get the main * system prompt. Everyone else gets the customer support prompt. * - assistant: Personal assistant in conversations. Self-chat = admin channel. * Others' messages stored for context. Only responds when the owner * triggers with @botname in someone else's chat. */ import fs from 'fs'; import path from 'path'; import { loadConfig, saveConfig } from '../../shared/config.js'; import { WORKSPACE_DIR } from '../../shared/paths.js'; import { log } from '../../shared/logger.js'; import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js'; import { WhatsAppChannel } from './whatsapp.js'; import { AlexaChannel } from './alexa.js'; import { TelegramChannel, type TelegramInbound } from './telegram.js'; import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js'; import type { AgentAttachment } from '../bloby-agent.js'; import { saveAttachment, MAX_ATTACHMENTS_PER_MESSAGE, MAX_TOTAL_ATTACHMENT_BYTES, type SavedFile } from '../file-saver.js'; import type { WAMessageKey } from '@whiskeysockets/baileys'; const MAX_CONCURRENT_AGENTS = 5; const MAX_BUFFER_MESSAGES = 30; const DEBOUNCE_MS = 4000; // 4s — wait for the user to finish typing /** Persist channel-inbound attachments to disk so harnesses that consume file * paths (Codex's `localImage`) can see them. Per-file failures are logged and that * attachment is dropped — one oversize/corrupt file can't abort the whole message, * and text-only delivery still goes through. Bounded by MAX_ATTACHMENTS_PER_MESSAGE * (count) and MAX_TOTAL_ATTACHMENT_BYTES (decoded bytes) so a single message can't * flood the disk; saveAttachment itself caps each file's size. */ function saveInboundAttachments(attachments?: AgentAttachment[]): { saved: SavedFile[]; accepted: AgentAttachment[] } { if (!attachments?.length) return { saved: [], accepted: [] }; const capped = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE); if (attachments.length > capped.length) { log.warn(`[channels] Dropping ${attachments.length - capped.length} inbound attachment(s) over the per-message cap (${MAX_ATTACHMENTS_PER_MESSAGE})`); } const saved: SavedFile[] = []; // The raw attachments that actually saved within budget — handed to the harness so the // model inlines exactly what got persisted + shown in chat (no over-cap divergence). const accepted: AgentAttachment[] = []; let totalBytes = 0; for (const att of capped) { // Estimate decoded size from the base64 length (×3/4) before writing so a burst of // mid-size files can't blow the per-message byte budget in aggregate. totalBytes += Math.floor((att.data?.length || 0) * 0.75); if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { log.warn('[channels] Per-message attachment byte budget exceeded — dropping remaining inbound attachments'); break; } try { saved.push(saveAttachment(att)); accepted.push(att); } catch (err: any) { log.warn(`[channels] Failed to save inbound attachment: ${err.message}`); } } return { saved, accepted }; } interface ChannelManagerOpts { broadcastBloby: (type: string, data: any) => void; workerApi: (path: string, method?: string, body?: any) => Promise; restartBackend: () => void; getModel: () => string; /** Fired after a channel turn ends — the supervisor uses it to flush a queued self-update. */ onTurnComplete?: () => void; } interface ActiveAgentQuery { sender: string; channel: ChannelType; } interface BufferedMessage { role: 'user' | 'assistant'; content: string; } /** Cap a rolling per-customer history buffer AND keep the window starting on a * user message. A blind length-splice can leave the window assistant-first, * which the Anthropic Messages API rejects outright once the pi harness sends * the buffer as structured chat history — every reply in that thread then * silently fails (audit C-7). Trimming at the source fixes it for every * provider flavor at once. */ function trimCustomerBuffer(buf: BufferedMessage[]): void { if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES); while (buf.length > 0 && buf[0].role !== 'user') buf.shift(); } interface DebounceEntry { messages: string[]; attachments: InboundMessageAttachment[]; timer: ReturnType; channel: ChannelType; sender: string; senderName?: string; fromMe: boolean; isSelfChat: boolean; chatJid: string; isGroup: boolean; /** Latest inbound message key in the batch (used for reactions/quotes on the freshest message). */ inboundKey?: WAMessageKey; } /** Per-conversation accumulator for streaming bot text → WhatsApp. */ export interface WaStreamState { chunkBuf: string; /** True once the CURRENT turn has consumed its routing target (via `bot:response` or * `bot:error`). Reset on `bot:turn-complete`. Guards the turn-complete safety-net drain so a * normal turn — which already consumed on `bot:response` — never double-drains and eats the * NEXT queued message's target (the root cause of chat↔WhatsApp bleed and DM-answered-in-group). */ consumedThisTurn?: boolean; } /** Agent-turn events that carry per-turn content. Broadcast only for dashboard surfaces * ('workspace' / 'chat'); suppressed for WhatsApp/Alexa turns so their replies don't * bleed into the chat-bubble UI. Non-turn events (bot:idle, bot:error, channel:*) are * always broadcast. */ const CHAT_TURN_EVENTS = new Set([ 'bot:token', 'bot:response', 'bot:tool', 'bot:task-created', 'bot:task-progress', 'bot:task-done', ]); export class ChannelManager { private providers = new Map(); private opts: ChannelManagerOpts; private activeAgents = new Map(); private messageQueue: InboundMessage[] = []; private statusListeners: ((status: ChannelStatus) => void)[] = []; /** In-memory conversation history per customer (keyed by "channel:phone") */ private customerBuffers = new Map(); /** Debounce buffers per sender (keyed by "channel:sender") */ private debounceBuffers = new Map(); /** * Per-conversation FIFO of routing targets. One entry pushed for every user message, * one consumed for every `bot:response`. This is the supervisor-enforced anti-bleed * mechanism — the agent's reply is pinned to whatever surface triggered it, regardless * of any mid-turn inbound from another channel. */ private routingQueues = new Map(); constructor(opts: ChannelManagerOpts) { this.opts = opts; } /** Initialize channels based on config. Idempotent — calling repeatedly * initializes any newly-enabled channel without disturbing ones already up. */ async init(): Promise { const config = loadConfig(); const channelConfigs = config.channels; if (channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) { log.info('[channels] Initializing WhatsApp channel...'); const whatsapp = new WhatsAppChannel( (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => { const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name })); this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey); }, (status) => this.handleStatusChange(status), (audioBase64) => this.transcribeAudio(audioBase64), ); this.providers.set('whatsapp', whatsapp); // Auto-connect if credentials exist (previously linked) if (whatsapp.hasCredentials()) { try { await whatsapp.connect(); } catch (err: any) { log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`); } } } else if (!channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) { log.info('[channels] WhatsApp not enabled — skipping'); } if (channelConfigs?.alexa?.enabled && !this.providers.has('alexa')) { log.info('[channels] Initializing Alexa channel...'); const alexa = new AlexaChannel(); this.providers.set('alexa', alexa); await alexa.connect(); } // Telegram — only when a BYO @BotFather bot token has been supplied (via the connect // page). The provider long-polls Telegram directly; no relay is involved. if (channelConfigs?.telegram?.enabled && channelConfigs.telegram.botToken && !this.providers.has('telegram')) { log.info('[channels] Initializing Telegram channel...'); const telegram = new TelegramChannel( (msg) => this.handleTelegramMessage(msg), (status) => this.handleStatusChange(status), (audioBase64) => this.transcribeAudio(audioBase64), ); this.providers.set('telegram', telegram); try { await telegram.connect(); } catch (err: any) { log.warn(`[channels] Telegram connect failed: ${err.message}`); } } } /** Map a Telegram inbound onto the shared channel pipeline. * * Telegram has no "self-chat" like WhatsApp. Instead the bot OWNER (the human who created the * bot) is the "self" identity: fromMe=true and (in 1:1) isSelfChat=true. This makes channel mode * respond only to the owner's DMs and makes assistant-mode `@botname` triggers work — reusing the * exact mode/debounce/role gating in handleInboundMessage with no special-casing. */ private handleTelegramMessage(msg: TelegramInbound) { let ownerUserId = loadConfig().channels?.telegram?.ownerUserId; // Trust-on-first-use: a BYO bot starts with no owner recorded, so adopt the first person to // DM it as the owner. (Cleared on token change so a new bot re-adopts.) Guarantees channel // mode never silently ignores its owner. if (!ownerUserId && !msg.isGroup && msg.fromUserId) { ownerUserId = msg.fromUserId; const cfg = loadConfig(); if (cfg.channels?.telegram) { cfg.channels.telegram.ownerUserId = ownerUserId; saveConfig(cfg); log.info(`[channels] Telegram owner adopted (trust-on-first-use): userId=${ownerUserId}`); } } const isOwner = !!ownerUserId && msg.fromUserId === String(ownerUserId); const attachments = msg.attachments?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name })); // Sanitize the attacker-controlled display name so it can't fake a `[Telegram | … | admin]` // context tag or inject newlines into the agent's context. const safeName = msg.senderName ? msg.senderName.replace(/[\[\]|\r\n]/g, ' ').slice(0, 64).trim() || undefined : undefined; this.handleInboundMessage( 'telegram', msg.fromUserId, safeName, msg.text, isOwner, // fromMe — the owner is treated as "me" isOwner && !msg.isGroup, // isSelfChat — owner's 1:1 DM is the personal channel msg.chatId, // chatJid (reply target) msg.isGroup, attachments, undefined, // inboundKey — Baileys-only (reactions not in Telegram v1) ); } /** Start WhatsApp connection (triggers QR flow if no credentials) */ async connectWhatsApp(): Promise { let provider = this.providers.get('whatsapp'); // Already linked and online — don't tear down a live socket to start a new one if (provider?.getStatus().connected) return; if (!provider) { const whatsapp = new WhatsAppChannel( (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => { const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name })); this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey); }, (status) => this.handleStatusChange(status), (audioBase64) => this.transcribeAudio(audioBase64), ); this.providers.set('whatsapp', whatsapp); provider = whatsapp; } await provider.connect(); } /** Disconnect a specific channel */ async disconnectChannel(type: ChannelType): Promise { const provider = this.providers.get(type); if (provider) { await provider.disconnect(); this.providers.delete(type); } } /** Disconnect all channels */ async disconnectAll(): Promise { for (const [, provider] of this.providers) { await provider.disconnect(); } this.providers.clear(); } /** Send a message via a specific channel, processing any custom UI tags */ async sendMessage(channel: ChannelType, to: string, text: string): Promise { const provider = this.providers.get(channel); if (!provider) throw new Error(`Channel ${channel} not available`); // Process custom tags for channel delivery let processed = text; // — send the image natively, remove tag from text const imgRegex = //g; const images: { src: string; alt: string }[] = []; let imgMatch; while ((imgMatch = imgRegex.exec(text)) !== null) { images.push({ src: imgMatch[1], alt: imgMatch[2] || '' }); } processed = processed.replace(imgRegex, '').trim(); // — flatten to plain title + content processed = processed.replace( /([\s\S]*?)<\/BlobyText>/g, (_match, title, content) => `📄 *${title}*\n\n${content.trim()}`, ); // Send text (if any remains after stripping tags) if (processed) { await provider.sendMessage(to, processed); } // Send images natively via WhatsApp / Telegram (both expose sendImage) if (images.length > 0 && (provider instanceof WhatsAppChannel || provider instanceof TelegramChannel)) { for (const img of images) { try { const resolved = this.resolveMediaFile(img.src); if (resolved) { await provider.sendImage(to, resolved.buffer, img.alt || undefined, resolved.mimetype); } else { log.warn(`[channels] Image file not found in any location: ${img.src}`); } } catch (err: any) { log.warn(`[channels] Failed to send image via ${channel}: ${err.message}`); } } } } /** * Send arbitrary media (audio, image, video, document) via a channel. * Accepts a file path (absolute, workspace-relative, or /api/files/... URL). */ async sendMedia( channel: ChannelType, to: string, media: { type: 'audio' | 'image' | 'video' | 'document'; path: string; mimetype?: string; fileName?: string; voiceNote?: boolean }, caption?: string, ): Promise { const provider = this.providers.get(channel); if (!provider) throw new Error(`Channel ${channel} not available`); const resolved = this.resolveMediaFile(media.path); if (!resolved) throw new Error(`Media file not found: ${media.path}`); const mimetype = media.mimetype || resolved.mimetype; // Telegram supports images via the provider's sendImage (other media types not wired in v1). if (provider instanceof TelegramChannel) { if (media.type === 'image') { await provider.sendImage(to, resolved.buffer, caption, mimetype); } else { throw new Error(`Telegram supports image media only (got ${media.type})`); } return; } if (!(provider instanceof WhatsAppChannel)) { throw new Error(`Channel ${channel} does not support media`); } switch (media.type) { case 'image': await provider.sendImage(to, resolved.buffer, caption, mimetype); break; case 'audio': await provider.sendAudio(to, resolved.buffer, { mimetype, voiceNote: media.voiceNote }); if (caption) await provider.sendMessage(to, caption); break; case 'video': await provider.sendVideo(to, resolved.buffer, caption, mimetype); break; case 'document': await provider.sendDocument(to, resolved.buffer, media.fileName || path.basename(resolved.absPath), mimetype, caption); break; default: throw new Error(`Unsupported media type: ${(media as any).type}`); } } /** Resolve a media path (absolute, /api/files/..., or workspace-relative) to buffer + inferred mimetype. */ private resolveMediaFile(src: string): { buffer: Buffer; mimetype: string; absPath: string } | null { let absPath: string | undefined; if (path.isAbsolute(src) && fs.existsSync(src)) { absPath = src; } else { const relPath = src.replace(/^\/api\/files\//, '').replace(/^\/+/, ''); const candidates = [ path.join(WORKSPACE_DIR, 'files', relPath), path.join(WORKSPACE_DIR, relPath), path.join(WORKSPACE_DIR, 'client', 'public', relPath), ]; absPath = candidates.find((p) => fs.existsSync(p)); } if (!absPath) return null; const buffer = fs.readFileSync(absPath); const ext = path.extname(absPath).slice(1).toLowerCase(); const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg; codecs=opus', wav: 'audio/wav', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain', }; return { buffer, mimetype: mimeMap[ext] || 'application/octet-stream', absPath }; } /** Show "typing..." indicator in a chat */ startTyping(channel: ChannelType, jid: string): void { const provider = this.providers.get(channel); if (provider && 'startTyping' in provider) { (provider as WhatsAppChannel).startTyping(jid); } } /** Get status of all channels */ getStatuses(): ChannelStatus[] { return Array.from(this.providers.values()).map((p) => p.getStatus()); } /** Get status of a specific channel */ getStatus(type: ChannelType): ChannelStatus | null { return this.providers.get(type)?.getStatus() || null; } /** Get QR code SVG for a channel */ getQrCode(type: ChannelType): string | null { return this.providers.get(type)?.getQrCode() || null; } /** Register a listener for status changes (used for WS broadcasting) */ onStatusChange(listener: (status: ChannelStatus) => void) { this.statusListeners.push(listener); } /** Request a pairing code for phone-number-based linking (mobile alternative to QR) */ async requestWhatsAppPairingCode(phoneNumber: string): Promise { const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined; if (!provider) throw new Error('WhatsApp not initialized — call connect first'); return provider.requestPairingCode(phoneNumber); } /** Delete WhatsApp credentials and disconnect */ async logoutWhatsApp(): Promise { const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined; if (provider) { await provider.disconnect(); await provider.deleteCredentials(); this.providers.delete('whatsapp'); } } // ── Internal ── /** Format a bot reply with the agent's name prefix (for admin & assistant messages, NOT customer) */ private formatBotReply(text: string, botName: string): string { return `🤖 *${botName}:*\n\n${text}`; } /** Human-facing channel label used in the `[Channel | ...]` context tag the agent sees. */ private channelLabel(channel: ChannelType): string { return channel === 'telegram' ? 'Telegram' : channel === 'alexa' ? 'Alexa' : 'WhatsApp'; } /** Allocate per-conv state for WhatsApp text streaming. Both the orchestrator * (chat UI websocket) and the manager's own admin handler create one of these * and feed each agent stream event through `routeWaStreamEvent` below. */ createWaStreamState(): WaStreamState { return { chunkBuf: '' }; } /** Push a user message into a live conversation and pin where the assistant's reply must go. * * THIS IS THE SINGLE SOURCE OF TRUTH for routing — every caller (chat-WS, channel inbound * handlers, scheduler, etc.) MUST push via this method, never via the raw harness pushMessage. * * Each call enqueues exactly one routing target onto a per-conversation FIFO. Each * `bot:response` consumes exactly one entry. Concurrent inbounds from different surfaces * during the same turn cannot bleed into each other's replies — the SDK queues inputs in * order, and the FIFO mirrors that ordering. Turns that end without a response (error / * empty turn / aborted) drain the head entry in `routeWaStreamEvent` so the queue stays * in sync. */ pushWithRouting( convId: string, target: RoutingTarget, content: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[], ): void { let q = this.routingQueues.get(convId); if (!q) { q = []; this.routingQueues.set(convId, q); } q.push({ ...target, pushedAt: Date.now() }); pushMessage(convId, content, attachments, savedFiles); } /** Peek the head of the routing queue without consuming. Used during intermediate streaming * flushes (bot:tool) so chunks land on the correct surface before the final response. */ private peekRoute(convId: string): RoutingTarget | undefined { return this.routingQueues.get(convId)?.[0]; } /** Consume one entry from the routing queue. Called on `bot:response`, and as a safety net * on `bot:turn-complete`/`bot:error` if no response fired (so the head doesn't bleed into * the next turn). */ private consumeRoute(convId: string): RoutingTarget | undefined { const q = this.routingQueues.get(convId); if (!q || q.length === 0) return undefined; const target = q.shift(); if (q.length === 0) this.routingQueues.delete(convId); if (target?.pushedAt && Date.now() - target.pushedAt > 30_000) { log.warn(`[channels] Stale route popped: surface=${target.surface}, age=${Math.round((Date.now() - target.pushedAt) / 1000)}s, to=${target.waSendTo || 'none'}, queueDepth=${q?.length ?? 0}`); } return target; } /** Return the surface of the current turn's routing target without consuming it. * Used by broadcast guards to suppress chat-bubble events for non-dashboard turns. */ peekCurrentSurface(convId: string): RoutingTarget['surface'] | undefined { return this.routingQueues.get(convId)?.[0]?.surface; } /** Drop all pending routes for a conversation — used when the live conversation ends. * Accepts undefined for ergonomics in callers that hold a possibly-undefined convId. */ clearRoutes(convId: string | undefined): void { if (!convId) return; const q = this.routingQueues.get(convId); if (q && q.length > 0) { log.warn(`[channels] Discarding ${q.length} pending route(s) for ended conversation ${convId}`); } this.routingQueues.delete(convId); } /** Send a reaction emoji on the inbound message that triggered a turn. Used to acknowledge * long-running work without spamming text. Pass `''` to remove a previous reaction. */ async reactToInbound(target: RoutingTarget, emoji: string): Promise { if (!target.inboundKey || !target.waSendTo) return; const provider = this.providers.get('whatsapp'); if (provider instanceof WhatsAppChannel) { await provider.sendReaction(target.waSendTo, target.inboundKey as WAMessageKey, emoji); } } /** Direct reaction API (for the /api/channels/whatsapp/react endpoint). */ async reactToMessage(channel: ChannelType, chatJid: string, key: WAMessageKey, emoji: string): Promise { const provider = this.providers.get(channel); if (!(provider instanceof WhatsAppChannel)) { throw new Error(`Channel ${channel} does not support reactions`); } await provider.sendReaction(chatJid, key, emoji); } /** Centralized WhatsApp routing for streaming agent events. * * Every event passes through here exactly once (only one onMessage callback is registered * per live conversation, regardless of who started it). The routing decision is pure: it * consults the per-conversation FIFO populated by `pushWithRouting`. No fallback, no * implicit channel inference — the trigger surface owns the reply. * * Also keeps assistant-mode context buffers up to date. */ routeWaStreamEvent( state: WaStreamState, type: string, eventData: any, botName: string, ): void { // Synthetic turns (background-task continuation turns the harness injects // with no matching user push — currently only the pi harness tags these) // are INVISIBLE to channel routing: they enqueued no routing target, so // consuming/flushing/draining here would steal a concurrently-queued // channel message's route and cross-wire replies. They are dashboard- // broadcast + DB-persist only. consumedThisTurn/chunkBuf are deliberately // untouched — synthetic tokens never enter the chunk buffer, and the // surrounding real turns' accounting stays intact. if (eventData?.synthetic) return; const convId = eventData?.conversationId as string | undefined; if (type === 'bot:token' && eventData?.token) { state.chunkBuf += eventData.token; return; } if (type === 'bot:tool' && state.chunkBuf.trim() && convId) { // Agent paused for a tool call — flush streamed text so the user sees progress // before the tool result lands. Peek (don't consume) — the final bot:response // is what closes out the turn. const head = this.peekRoute(convId); if (head?.surface === 'alexa') { // For Alexa, send the preamble as a Progressive Response so the user // hears the agent's actual "I'll do X..." line. Final bot:response is // still what closes the turn with the agent's last words. const alexa = this.providers.get('alexa') as AlexaChannel | undefined; alexa?.tryFlushProgressive(convId, state.chunkBuf.trim()); state.chunkBuf = ''; } else { this.sendStreamChunk(head, state.chunkBuf.trim(), botName); state.chunkBuf = ''; } return; } if (type === 'bot:response' && eventData?.content && convId) { const target = this.consumeRoute(convId); state.consumedThisTurn = true; const remaining = state.chunkBuf.trim(); state.chunkBuf = ''; if (target?.surface === 'alexa') { // Resolve the HTTP handler's promise with the full reply text. const alexa = this.providers.get('alexa') as AlexaChannel | undefined; if (!alexa || !alexa.resolveHead(convId, eventData.content)) { log.warn(`[channels] bot:response for alexa surface but no pending resolver (conv=${convId})`); } } else if (remaining) { this.sendStreamChunk(target, remaining, botName); } // Append the assistant's reply into the per-chat context buffer so the next // trigger in that chat sees it as conversation history. if (target?.assistantBufferKey) { const buf = this.customerBuffers.get(target.assistantBufferKey); if (buf) { buf.push({ role: 'assistant', content: eventData.content }); trimCustomerBuffer(buf); } } return; } // Turn errored without a usable reply — drain THIS turn's route so it can't bleed into the // next turn. Guarded by `consumedThisTurn`: if `bot:response` already fired this turn it // consumed the target, so we must NOT drain again. if (type === 'bot:error' && convId) { if (!state.consumedThisTurn) { const dropped = this.consumeRoute(convId); if (dropped) { log.warn(`[channels] bot:error without bot:response — dropping pending route (surface=${dropped.surface}, to=${dropped.waSendTo || 'none'})`); if (dropped.surface === 'alexa') { const alexa = this.providers.get('alexa') as AlexaChannel | undefined; alexa?.rejectHead(convId, type); } } state.consumedThisTurn = true; } state.chunkBuf = ''; return; } // Turn finished. Drain the head ONLY if this turn never consumed its route — i.e. a true // empty turn (no `bot:response`, no `bot:error`; see harness claude.ts which always emits // `bot:turn-complete` after every result). A normal turn already consumed on `bot:response`, // so draining here would eat the NEXT queued message's target and every later reply would // land on the wrong surface (chat↔WhatsApp bleed, DM answered in a group). Reset the per-turn // flag afterwards so the next turn starts clean. Turns are strictly sequential per conversation // (single input queue, one `result` at a time), so this per-conversation flag is race-free. if (type === 'bot:turn-complete' && convId) { if (!state.consumedThisTurn) { const head = this.peekRoute(convId); if (head) { const dropped = this.consumeRoute(convId); log.warn(`[channels] turn-complete without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`); if (dropped?.surface === 'alexa') { const alexa = this.providers.get('alexa') as AlexaChannel | undefined; alexa?.rejectHead(convId, type); } } } state.consumedThisTurn = false; state.chunkBuf = ''; } } /** Deliver a streamed chunk to the WhatsApp side of a routing target. * No-op when the target has no `waSendTo` (e.g., chat-UI turn with WA disconnected). */ private sendStreamChunk(target: RoutingTarget | undefined, text: string, botName: string): void { // Telegram: the bot is its own contact, so no "🤖 Bot:" prefix — send the agent's text as-is. if (target?.surface === 'telegram') { if (!target.telegramChatId) return; this.sendMessage('telegram', target.telegramChatId, text).catch((err) => log.warn(`[channels] Telegram send failed (${target.telegramChatId}): ${err.message}`), ); return; } if (!target?.waSendTo) return; // Prefix only when the trigger came from WhatsApp AND it isn't the user's own self-chat — // the user doesn't need to see "🤖 Bot:" before their own bot's reply in their own chat. const usePrefix = target.surface === 'whatsapp' && !target.isSelfChat; const body = usePrefix ? this.formatBotReply(text, botName) : text; this.sendMessage('whatsapp', target.waSendTo, body).catch((err) => log.warn(`[channels] WA send failed (${target.waSendTo}): ${err.message}`), ); } private handleStatusChange(status: ChannelStatus) { for (const listener of this.statusListeners) { listener(status); } } /** Get the channel config, re-reading from disk each time */ private getChannelConfig(channel: ChannelType): ChannelConfig | undefined { const config = loadConfig(); // Only ever called for the ChannelConfig-shaped providers (whatsapp/telegram), // both of which carry `mode`. Alexa's config has a different shape (no `mode`) // and is handled via its own handleAlexaInbound path, so narrowing the stored // channel union to ChannelConfig here is safe. return config.channels?.[channel] as ChannelConfig | undefined; } /** Robust "is this the account owner's own self-chat?" check. * * Keys purely on JID equality (`isSelfChat` — the chat resolves to the owner's OWN number, * computed in whatsapp.ts from `ownPhoneJid` + LID translation). This is authoritative and is * UNAFFECTED by Baileys' `fromMe` decode regressions (e.g. the rc11→rc13 protocolMessage * `fromMe=false` bug): the chat JID of a self-message is still the owner's own number even when * `fromMe` decodes wrong. So we deliberately do NOT also require `fromMe` (which the old gate * did — that's what silently dropped self-messages under rc11). * * We also deliberately do NOT treat `fromMe` alone as self-chat: `fromMe` is true for the owner * messaging a CONTACT from a linked device too, so a `fromMe`-based OR would misroute those into * the admin brain. Only own-number JID equality is safe and false-positive-free. */ private isOwnerSelfChat(isSelfChat: boolean, isGroup: boolean): boolean { return !isGroup && isSelfChat; } /** Handle an incoming message from any channel — debounces rapid messages from the same sender. * * Per-mode behavior is decided here. To add a new mode: extend the gating block below * (filter inbound) and the routing block in flushDebounce (route to admin/customer/assistant). */ private async handleInboundMessage( channel: ChannelType, sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean, chatJid: string, isGroup: boolean, attachments?: InboundMessageAttachment[], inboundKey?: WAMessageKey, ) { const channelConfig = this.getChannelConfig(channel); if (!channelConfig) return; const mode = channelConfig.mode || 'channel'; // ── Group gating ── // Channel mode is self-chat only — groups never apply. // Other modes: opt-in via channelConfig.allowGroups (default false). if (isGroup) { if (mode === 'channel') return; if (!channelConfig.allowGroups) return; } // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions. const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup); // ── Channel mode: ONLY respond to self-chat ── if (mode === 'channel') { if (!selfChat) return; } // ── Business mode: filter outgoing to others (your messages to customers, not self-chat) ── if (mode === 'business' && fromMe && !selfChat) return; // ── Assistant mode ── // Self-chat: falls through to debounce (processed as admin) // Others' messages or untriggered messages: store for context, don't invoke // Triggered messages: fall through to debounce → agent (owner always; others only if opted-in) if (mode === 'assistant' && !selfChat) { // Store every message for context (both mine and theirs) — keyed by the chat (group or 1:1) this.storeAssistantContext(channel, chatJid, senderName, text, fromMe); // Trigger must be present. const botName = loadConfig().username || 'bloby'; const triggerPattern = new RegExp(`(?:^|\\n)\\s*@${botName}[:\\s]`, 'i'); if (!triggerPattern.test(text)) return; // Who may drive the agent? By default ONLY the account owner (fromMe). When the channel is // explicitly configured with `allowOthersToTrigger`, anyone who tags the bot can — a // deliberately dangerous shared-control mode (the triggerer gets an agent with Bash, file // access, etc.; see the WhatsApp SKILL.md disclaimer). const allowOthers = channelConfig.allowOthersToTrigger === true; if (!fromMe && !allowOthers) return; // Falls through to debounce → flushDebounce → handleAssistantMessage } // Debounce: accumulate rapid messages from the same chat // (key by chatJid so a group's rapid messages collapse into one turn) const debounceKey = `${channel}:${chatJid}`; const existing = this.debounceBuffers.get(debounceKey); if (existing) { // Another message in the same chat — reset timer, append text clearTimeout(existing.timer); existing.messages.push(text); if (attachments?.length) existing.attachments.push(...attachments); existing.senderName = senderName || existing.senderName; if (inboundKey) existing.inboundKey = inboundKey; // track the freshest message for reactions existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS); log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender} in ${chatJid}`); return; } // First message in this chat's debounce window const entry: DebounceEntry = { messages: [text], attachments: attachments ? [...attachments] : [], channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, inboundKey, timer: setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS), }; this.debounceBuffers.set(debounceKey, entry); } /** Flush debounced messages — combine and route to the appropriate handler */ private async flushDebounce(key: string) { const entry = this.debounceBuffers.get(key); if (!entry) return; this.debounceBuffers.delete(key); const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments, inboundKey } = entry; const combinedText = messages.join('\n'); const channelConfig = this.getChannelConfig(channel); if (!channelConfig) return; const mode = channelConfig.mode || 'channel'; // Reply identifier — strip JID suffix to get a stable id (phone for 1:1, group hash for groups) const chatId = chatJid.replace(/@.*/, ''); // Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions (matches the // gate in handleInboundMessage so a self-message can't pass one check and fail the other). const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup); // Route based on mode and role if (mode === 'channel' || (mode === 'business' && selfChat) || (mode === 'assistant' && selfChat)) { // Admin (self-chat in any mode) const message: InboundMessage = { channel, sender: chatId, senderName, role: 'admin', text: combinedText, rawSender: chatJid, attachments: attachments.length > 0 ? attachments : undefined, inboundKey, isGroup, }; const modeLabel = mode === 'channel' ? 'Channel mode | self-chat' : mode === 'assistant' ? 'Assistant mode | self-chat | admin' : 'Business mode | self-chat | admin'; log.info(`[channels] ${modeLabel} | "${combinedText.slice(0, 60)}"`); await this.handleAdminMessage(message); return; } // Assistant mode — triggered message in someone else's chat (or a group) → route through admin (shared brain) if (mode === 'assistant') { const bufferKey = `${channel}:${chatId}`; const buffer = this.customerBuffers.get(bufferKey) || []; // Strip trigger prefix const cfgBotName = loadConfig().username || 'bloby'; const triggerRegex = new RegExp(`@${cfgBotName}[:\\s]+`, 'i'); const triggerMatch = combinedText.match(triggerRegex); let cleanText = combinedText; if (triggerMatch && triggerMatch.index !== undefined) { cleanText = combinedText.slice(triggerMatch.index + triggerMatch[0].length).trim(); } // Load skill context (SCRIPT.md + contact memory) const scriptPrompt = this.loadActiveScript(channelConfig); let contactMemory = ''; try { const customerDataDir = this.getSkillCustomerDataDir(channelConfig); if (customerDataDir) { const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${chatId}.md`); if (fs.existsSync(memoryPath)) { contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim(); } } } catch {} // Build enriched text: skill instructions + conversation context + command const chatLabel = isGroup ? `group ${chatId}` : (senderName || chatId); let enrichedText = ''; if (scriptPrompt) enrichedText += `# Assistant Skill Instructions\n${scriptPrompt}\n\n---\n`; if (contactMemory) enrichedText += `# Contact Memory (${chatId})\n${contactMemory}\n\n---\n`; if (buffer.length > 0) { enrichedText += `# Recent conversation with ${chatLabel}\n`; enrichedText += buffer.map((m) => m.content).join('\n'); enrichedText += '\n\n---\n'; } enrichedText += cleanText; const message: InboundMessage = { channel, sender: chatId, senderName, role: 'assistant', text: enrichedText, displayText: cleanText, rawSender: chatJid, attachments: attachments.length > 0 ? attachments : undefined, inboundKey, isGroup, }; log.info(`[channels] Assistant mode | triggered in ${isGroup ? 'group ' : 'chat with '}${chatId} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`); await this.handleAdminMessage(message); return; } // Business mode — incoming message. Role is resolved against the actual sender JID (not the chat JID). const role = this.resolveBusinessRole(channelConfig, sender, channel); const message: InboundMessage = { channel, sender: sender.replace(/@.*/, ''), senderName, role, text: combinedText, rawSender: chatJid, attachments: attachments.length > 0 ? attachments : undefined, inboundKey, isGroup, }; log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${combinedText.slice(0, 60)}"`); if (role === 'admin') { await this.handleAdminMessage(message); } else { await this.handleCustomerMessage(message, channelConfig); } } /** Resolve role in business mode — check admins array */ private resolveBusinessRole(config: ChannelConfig, sender: string, channel: ChannelType): SenderRole { if (config.admins?.length) { // Telegram admins are EXACT numeric user ids — no suffix/country-code fuzz (that's // phone-number tolerance and would wrongly grant admin on a numeric-id suffix collision). if (channel === 'telegram') { const senderId = sender.replace(/@.*/, '').trim(); return config.admins.some((a) => String(a).trim() === senderId) ? 'admin' : 'customer'; } const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, ''); for (const admin of config.admins) { const adminPhone = admin.replace(/[^0-9]/g, ''); if (senderPhone === adminPhone || senderPhone.endsWith(adminPhone) || adminPhone.endsWith(senderPhone)) { return 'admin'; } } } return 'customer'; } /** Handle message from an admin (or assistant trigger) — mirrors to chat conversation, uses main system prompt */ private async handleAdminMessage(msg: InboundMessage) { const { workerApi, broadcastBloby, getModel } = this.opts; const model = getModel(); // Get or create conversation (shared with chat for mirroring) let convId: string | undefined; try { const ctx = await workerApi('/api/context/current'); if (ctx.conversationId) { convId = ctx.conversationId; } else { const conv = await workerApi('/api/conversations', 'POST', { title: this.channelLabel(msg.channel), model }); convId = conv.id; await workerApi('/api/context/set', 'POST', { conversationId: convId }); } } catch (err: any) { log.warn(`[channels] Failed to get/create conversation: ${err.message}`); return; } // Mirrors handleAlexaInbound's guard: never proceed with an undefined convId // (e.g. the conversation API returned no id) — that would push the turn into a // broken conversation key and silently drop the reply. if (!convId) { log.warn('[channels] No conversation id resolved — dropping inbound message'); return; } // Use display text for DB/chat (hides enriched agent context from the UI). // Prepend the channel tag so the UI can detect the source for icons. const rawDisplay = msg.displayText || msg.text; const earlyRoleTag = msg.senderName && msg.role === 'assistant' ? `${msg.role} | ${msg.senderName}` : msg.role; const channelTag = `[${this.channelLabel(msg.channel)} | ${msg.sender} | ${earlyRoleTag}]\n`; const displayContent = channelTag + rawDisplay; // Convert inbound attachments to agent format and persist them to disk BEFORE the // user-message persist/broadcast — so the StoredAttachment array (filePath-based, served // at /api/files/) can ride along in meta.attachments + chat:sync. Without this // the agent sees the media but the chat shows nothing (live or after refresh). An image // keeps an auto-generated name; a file uses the channel-provided filename. (Mirrors the // PWA path in supervisor/index.ts.) const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({ type: att.type, name: att.type === 'image' ? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}` : (att.name || `${msg.channel}_file`), mediaType: att.mediaType, data: att.data, })); // Save to disk so providers that consume file paths (Codex → localImage) can see the // attachment. Claude consumes raw base64 from `agentAttachments` directly, but the // on-disk copy is what the chat UI renders (filePath → /api/files/). const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments); const storedAtts = savedFiles.map((f) => ({ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath })); // Save user message to DB try { const userMeta: any = { model, channel: msg.channel }; if (storedAtts.length) userMeta.attachments = JSON.stringify(storedAtts); await workerApi(`/api/conversations/${convId}/messages`, 'POST', { role: 'user', content: displayContent, meta: userMeta, }); } catch (err: any) { log.warn(`[channels] DB persist error: ${err.message}`); } // Broadcast to chat clients (mirroring) broadcastBloby('chat:sync', { conversationId: convId, message: { role: 'user', content: displayContent, timestamp: new Date().toISOString(), attachments: storedAtts.length ? storedAtts : undefined, }, }); // Fetch names and recent messages let botName = 'Bloby', humanName = 'Human'; let recentMessages: RecentMessage[] = []; try { const [status, recentRaw] = await Promise.all([ workerApi('/api/onboard/status'), workerApi(`/api/conversations/${convId}/messages/recent?limit=20`), ]); botName = status.agentName || 'Bloby'; humanName = status.userName || 'Human'; if (Array.isArray(recentRaw)) { const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant'); if (filtered.length > 0) { recentMessages = filtered.slice(0, -1).map((m: any) => ({ role: m.role as 'user' | 'assistant', content: m.content, })); } } } catch {} // Channel context — same tag we already prepended to the stored display content const channelContext = channelTag; // Show "typing..." in the correct chat this.startTyping(msg.channel, msg.rawSender); // Per-conversation streaming state for WhatsApp routing const waState = this.createWaStreamState(); // Start a live conversation if one doesn't exist (shared with chat UI) if (!hasConversation(convId)) { log.info(`[channels] Starting live conversation for admin: ${convId}`); await startConversation(convId, model, (type, eventData) => { // Snapshot the surface BEFORE routeWaStreamEvent consumes the routing target // on bot:response — used to decide whether to mirror turn events to chat clients. const triggerSurface = this.peekCurrentSurface(convId); const isDashboardTurn = !triggerSurface || triggerSurface === 'chat' || triggerSurface === 'workspace'; // WhatsApp routing — purely queue-driven, no fallback mirror jid. The // routing target carries the destination; whatever surface triggered the // turn owns the reply. this.routeWaStreamEvent(waState, type, eventData, botName); // Persist the assistant's reply to the conversation's DB and mirror to // the chat-bubble UI via chat:sync (bot:response is suppressed for non- // dashboard turns, so the chat would otherwise stay empty until refresh). if (type === 'bot:response' && eventData.content) { workerApi(`/api/conversations/${convId}/messages`, 'POST', { role: 'assistant', content: eventData.content, meta: { model }, }).catch(() => {}); broadcastBloby('chat:sync', { conversationId: convId, message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString() }, }); } // Handle turn completion — restart backend if file tools were used, // and tell every chat client the agent is idle so the typing dots // stop. This callback owns the live conversation whenever a WhatsApp // self-chat arrives before the dashboard does, so without this signal // the dashboard's typing indicator would stay on forever. if (type === 'bot:turn-complete') { if (eventData.usedFileTools) this.opts.restartBackend(); this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn broadcastBloby('bot:idle', { conversationId: convId }); return; } // Live conversation ended — drop any pending routes so a future // conversation under the same convId starts clean. if (type === 'bot:conversation-ended') { this.clearRoutes(convId); this.opts.onTurnComplete?.(); // flush a queued self-update if this turn ended by exception return; } // Suppress turn events (bot:token / bot:response / bot:tool) only for // non-dashboard surfaces (WhatsApp/Alexa). Dashboard turns (chat / // workspace) — Mac via /bloby/ws is `surface: 'chat'`, the tablet // workspace chat is `surface: 'workspace'` — MUST receive bot:response // so the Morphy continuation resolves and TTS plays, and so the // workspace stops the "Thinking" spinner. if (CHAT_TURN_EVENTS.has(type) && !isDashboardTurn) return; broadcastBloby(type, eventData); }, { botName, humanName }, recentMessages); } // Push into the live conversation with a pinned WhatsApp routing target. // The agent's reply for THIS specific input will go to msg.rawSender — no other // surface can hijack it via the FIFO ordering of the shared conversation. const channelContent = channelContext + msg.text; const isTelegram = msg.channel === 'telegram'; const target: RoutingTarget = { surface: isTelegram ? 'telegram' : 'whatsapp', waSendTo: isTelegram ? undefined : msg.rawSender, telegramChatId: isTelegram ? msg.rawSender : undefined, isGroup: msg.isGroup, // Self-chat in 1:1: don't prefix "🤖 Bot:" — it's the user's own chat with themselves. isSelfChat: msg.role === 'admin' && !msg.isGroup, assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined, inboundKey: msg.inboundKey, }; this.pushWithRouting(convId, target, channelContent, acceptedAttachments, savedFiles); } /** Synchronously handle an Alexa utterance: push into the shared conversation, * await the agent's reply, return it. Used by /api/channels/alexa/handle. * * Returns the final reply text. Throws on timeout / aborted turn so the HTTP * handler can fall back to "I'll get back to you in chat" (or HA announce). */ async handleAlexaInbound(opts: { text: string; alexaUserId: string; alexaSessionId?: string; deviceId?: string; locale?: string; /** Alexa Directive Service base URL — passed through from the relay. */ apiEndpoint?: string; /** Alexa apiAccessToken for the current request — required to fire Progressive Response. */ apiAccessToken?: string; /** Original Alexa requestId — required to fire Progressive Response. */ requestId?: string; timeoutMs?: number; }): Promise { const { text, alexaUserId, alexaSessionId, deviceId, locale, apiEndpoint, apiAccessToken, requestId, timeoutMs = 25_000 } = opts; const { workerApi, broadcastBloby, getModel } = this.opts; const model = getModel(); const alexa = this.providers.get('alexa') as AlexaChannel | undefined; if (!alexa) throw new Error('alexa-channel-not-initialized'); // Get or create the shared conversation (same one chat + WhatsApp use). let convId: string | undefined; try { const ctx = await workerApi('/api/context/current'); if (ctx.conversationId) { convId = ctx.conversationId; } else { const conv = await workerApi('/api/conversations', 'POST', { title: 'Alexa', model }); convId = conv.id; await workerApi('/api/context/set', 'POST', { conversationId: convId }); } } catch (err: any) { log.warn(`[channels/alexa] Failed to get/create conversation: ${err.message}`); throw err; } if (!convId) throw new Error('no-conversation'); // Build the channel tag early so we can prepend it to the stored content // (lets the UI detect the source for the channel icon). const alexaDeviceTag = deviceId ? ` | device=${deviceId.slice(-8)}` : ''; const alexaSessionTag = alexaSessionId ? ` | session=${alexaSessionId.slice(-6)}` : ''; const alexaLocaleTag = locale ? ` | ${locale}` : ''; const alexaChannelTag = `[Alexa | user=${alexaUserId.slice(-8)}${alexaDeviceTag}${alexaSessionTag}${alexaLocaleTag}]\n`; const taggedText = alexaChannelTag + text; // Persist + mirror to dashboard so the user sees the Alexa utterance in chat. workerApi(`/api/conversations/${convId}/messages`, 'POST', { role: 'user', content: taggedText, meta: { model, channel: 'alexa' }, }).catch((err: any) => log.warn(`[channels/alexa] DB persist error: ${err.message}`)); broadcastBloby('chat:sync', { conversationId: convId, message: { role: 'user', content: taggedText, timestamp: new Date().toISOString() }, }); // Make sure a live conversation exists (mirrors handleAdminMessage's setup). if (!hasConversation(convId)) { log.info(`[channels/alexa] Starting live conversation: ${convId}`); let botName = 'Bloby', humanName = 'Human'; let recentMessages: RecentMessage[] = []; try { const [status, recentRaw] = await Promise.all([ workerApi('/api/onboard/status'), workerApi(`/api/conversations/${convId}/messages/recent?limit=20`), ]); botName = status.agentName || 'Bloby'; humanName = status.userName || 'Human'; if (Array.isArray(recentRaw)) { const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant'); if (filtered.length > 0) { recentMessages = filtered.slice(0, -1).map((m: any) => ({ role: m.role as 'user' | 'assistant', content: m.content, })); } } } catch {} const waState = this.createWaStreamState(); await startConversation(convId, model, (type, eventData) => { // Snapshot the surface BEFORE routeWaStreamEvent consumes it on bot:response — // dashboard turns (chat / workspace / Mac) still need bot:response broadcast. const triggerSurface = this.peekCurrentSurface(convId); const isDashboardTurn = !triggerSurface || triggerSurface === 'chat' || triggerSurface === 'workspace'; // Same routing as WhatsApp — alexa surface branch lives inside this method. this.routeWaStreamEvent(waState, type, eventData, botName); if (type === 'bot:response' && eventData.content) { workerApi(`/api/conversations/${convId}/messages`, 'POST', { role: 'assistant', content: eventData.content, meta: { model }, }).catch(() => {}); // Mirror the assistant reply to the chat-bubble UI (bot:response is // suppressed for non-dashboard turns, so the chat would otherwise stay // empty until refresh). broadcastBloby('chat:sync', { conversationId: convId, message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString() }, }); } if (type === 'bot:turn-complete') { if (eventData.usedFileTools) this.opts.restartBackend(); this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn broadcastBloby('bot:idle', { conversationId: convId }); return; } if (type === 'bot:conversation-ended') { this.clearRoutes(convId); this.opts.onTurnComplete?.(); // flush a queued self-update if this turn ended by exception return; } // Dashboard turns (chat / workspace / Mac) need bot:response broadcast so // the Morphy continuation resolves and the workspace stops "Thinking". // Alexa/WhatsApp turns keep them suppressed (routing FIFO handles delivery). if (CHAT_TURN_EVENTS.has(type) && !isDashboardTurn) return; broadcastBloby(type, eventData); }, { botName, humanName }, recentMessages); } // Reserve the resolver slot FIRST — the agent may respond very quickly and // we don't want the bot:response to arrive before the resolver is in the FIFO. // Also pass the Alexa Directive Service credentials so the channel can fire // Progressive Response on every preamble chunk the agent emits before tool calls. const creds = (apiEndpoint && apiAccessToken && requestId) ? { apiEndpoint, apiAccessToken, requestId } : null; const pending = alexa.reservePending(convId, creds, timeoutMs); const target: RoutingTarget = { surface: 'alexa', isSelfChat: false, isGroup: false, }; this.pushWithRouting(convId, target, taggedText); return pending; // resolves with the reply text (or rejects on timeout) } /** Handle message from a customer — runs support agent in parallel with conversation context */ private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) { const agentKey = `${msg.channel}:${msg.sender}`; // Check concurrent limit if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) { log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`); this.messageQueue.push(msg); return; } const { workerApi, getModel } = this.opts; const model = getModel(); // Load the active skill's SCRIPT.md as the customer-facing system prompt const scriptPrompt = this.loadActiveScript(channelConfig); // Fetch agent name let botName = 'Bloby', humanName = 'Human'; try { const status = await workerApi('/api/onboard/status'); botName = status.agentName || 'Bloby'; humanName = status.userName || 'Human'; } catch {} // Get or create conversation buffer for this customer let buffer = this.customerBuffers.get(agentKey); if (!buffer) { buffer = []; this.customerBuffers.set(agentKey, buffer); } // Add the new user message to the buffer buffer.push({ role: 'user', content: msg.text }); // Trim buffer to max size (user-first — see trimCustomerBuffer) trimCustomerBuffer(buffer); // Build recent messages for context (everything except the last one, which is the current message) const recentMessages: RecentMessage[] = buffer.length > 1 ? buffer.slice(0, -1).map((m) => ({ role: m.role, content: m.content })) : []; // Load long-term memory from the skill's customer_data directory let customerMemory = ''; try { const customerDataDir = this.getSkillCustomerDataDir(channelConfig); if (customerDataDir) { const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${msg.sender}.md`); if (fs.existsSync(memoryPath)) { customerMemory = fs.readFileSync(memoryPath, 'utf-8').trim(); } } } catch {} const channelContext = `[${this.channelLabel(msg.channel)} | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`; // Convert inbound attachments to agent format (image → auto-name; file → channel filename) const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({ type: att.type, name: att.type === 'image' ? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}` : (att.name || `${msg.channel}_file`), mediaType: att.mediaType, data: att.data, })); const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments); // Stable convId per customer (not per message) const convId = `channel-${agentKey}`; this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel }); // Show "typing..." while the agent processes this.startTyping(msg.channel, msg.rawSender); // Build an enriched script prompt with customer memory if available let enrichedScript = scriptPrompt; if (customerMemory && enrichedScript) { enrichedScript += `\n\n---\n# Customer History (${msg.sender})\n\n${customerMemory}`; } // Track text chunks for WhatsApp — send intermediate chunks when agent pauses for tool use let waChunkBuf = ''; startBlobyAgentQuery( convId, channelContext + msg.text, model, (type, eventData) => { // Accumulate text tokens if (type === 'bot:token' && eventData.token) { waChunkBuf += eventData.token; } // Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message if (type === 'bot:tool' && waChunkBuf.trim()) { this.sendMessage(msg.channel, msg.rawSender, waChunkBuf.trim()).catch((err) => { log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`); }); waChunkBuf = ''; } if (type === 'bot:response' && eventData.content) { // Add full response to the conversation buffer buffer!.push({ role: 'assistant', content: eventData.content }); trimCustomerBuffer(buffer!); // Send remaining text after the last tool use (or full response if no tools were used) const remaining = waChunkBuf.trim(); if (remaining) { this.sendMessage(msg.channel, msg.rawSender, remaining).catch((err) => { log.warn(`[channels] Failed to send customer reply: ${err.message}`); }); waChunkBuf = ''; } } if (type === 'bot:done') { this.activeAgents.delete(agentKey); if (eventData.usedFileTools) this.opts.restartBackend(); this.opts.onTurnComplete?.(); // flush a queued self-update after a channel turn this.processQueue(); } }, acceptedAttachments, savedFiles, { botName, humanName }, recentMessages, enrichedScript, ); } /** Store a message in the assistant context buffer (for conversation history when triggered). * Keyed by the chat (not the sender) so groups accumulate one shared buffer per group. */ private storeAssistantContext( channel: ChannelType, chatJid: string, senderName: string | undefined, text: string, fromMe: boolean, ) { const chatId = chatJid.replace(/@.*/, ''); const bufferKey = `${channel}:${chatId}`; let buffer = this.customerBuffers.get(bufferKey); if (!buffer) { buffer = []; this.customerBuffers.set(bufferKey, buffer); } const label = fromMe ? 'me' : (senderName || chatId); buffer.push({ role: 'user', content: `[${label}]: ${text}` }); trimCustomerBuffer(buffer); log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`); } /** Transcribe audio via the existing whisper endpoint */ private async transcribeAudio(audioBase64: string): Promise { try { const result = await this.opts.workerApi('/api/whisper/transcribe', 'POST', { audio: audioBase64 }); if (result.error) { log.warn(`[channels] Whisper error: ${result.error}`); return null; } return result.transcript || null; } catch (err: any) { log.warn(`[channels] Whisper transcription failed: ${err.message}`); return null; } } /** Read customer_data directory from a skill's skill.json */ private getSkillCustomerDataDir(channelConfig: ChannelConfig): string | undefined { const skillName = channelConfig.skill; if (!skillName) return undefined; try { const skillJsonPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'skill.json'); if (fs.existsSync(skillJsonPath)) { const skillJson = JSON.parse(fs.readFileSync(skillJsonPath, 'utf-8')); if (skillJson.customer_data) return skillJson.customer_data; } } catch {} return undefined; } /** Load SCRIPT.md from the active skill configured for this channel */ private loadActiveScript(channelConfig: ChannelConfig): string | undefined { const skillName = channelConfig.skill; if (!skillName) { log.warn('[channels] No active skill configured — customer will get no script'); return undefined; } const scriptPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'SCRIPT.md'); try { if (fs.existsSync(scriptPath)) { const content = fs.readFileSync(scriptPath, 'utf-8').trim(); if (content) { log.info(`[channels] Loaded SCRIPT.md from skill: ${skillName}`); return content; } } log.warn(`[channels] SCRIPT.md not found in skill: ${skillName}`); } catch (err: any) { log.warn(`[channels] Failed to load SCRIPT.md from ${skillName}: ${err.message}`); } return undefined; } /** Process queued messages when an agent slot frees up */ private processQueue() { while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) { const queued = this.messageQueue.shift()!; const config = this.getChannelConfig(queued.channel); if (!config) continue; log.info(`[channels] Processing queued message from ${queued.sender}`); this.handleCustomerMessage(queued, config); } } }