diff --git a/.env.example b/.env.example index c16dd29f..42d1dac7 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,12 @@ GOOGLE_CONNECTOR_UID= # optional click-to-message shortcut in the workspace. LINQ_CONNECTOR= LINQ_PHONE_NUMBER= +# Optional Blooio iMessage provider (https://api.blooio.com). When Linq is +# unset, sign-in codes are sent through Blooio. Point a v4 webhook at +# https:///webhooks/blooio and store its signing secret here. +BLOOIO_API_KEY= +BLOOIO_WEBHOOK_SECRET= +BLOOIO_FROM_NUMBER= # Optional per-user Link wallet connection. Register the exact callback URL # https:///api/auth/callback/link with Stripe. LINK_CLIENT_ID= diff --git a/README.md b/README.md index dc7f2c3c..7767bde6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,27 @@ but cannot receive them. +### Blooio iMessage + +[Blooio](https://blooio.com) is an optional iMessage provider. Leave it unset to +keep the Linq line from the deploy button. When `LINQ_CONNECTOR` is unset and +`BLOOIO_API_KEY` is set, sign-in codes and the assistant's iMessage thread use +Blooio's [v4 API](https://api.blooio.com). + +1. Create an API key in the Blooio dashboard. +2. Set `BLOOIO_API_KEY`. Set `BLOOIO_FROM_NUMBER` when the key can send from + more than one number; otherwise OpenInstinct uses the key's active number. +3. Create a webhook for `https:///webhooks/blooio` and set + `BLOOIO_WEBHOOK_SECRET` to the signing secret returned at creation. + Unsigned deliveries are rejected. +4. Redeploy and sign in with your phone number. After the code verifies, text + the Blooio number from that phone. OpenInstinct ignores messages from + numbers that are not linked to a verified account. + +Scheduled reminders created in a Blooio thread are delivered back to that +thread. Linq remains the path used for sign-in when both providers are +configured. + ## Google Workspace connection OpenInstinct can use a user's Gmail, Calendar, and read-only Contacts through a diff --git a/agent/channels/blooio.ts b/agent/channels/blooio.ts new file mode 100644 index 00000000..829844eb --- /dev/null +++ b/agent/channels/blooio.ts @@ -0,0 +1,111 @@ +import { defineChannel, POST } from "eve/channels"; +import { z } from "zod"; +import { markBlooioChatRead } from "@shared/blooio/api"; +import { + acceptBlooioWebhook, + blooioInboundAuth, + blooioUserContent, + claimBlooioDelivery, +} from "@agent/lib/blooio/inbound"; +import { + deliverBlooioAction, + deliverBlooioAuthorization, + finishBlooioTurn, + releaseBlooioTurn, + showBlooioTyping, +} from "@agent/lib/blooio/deliver"; + +const receiveTargetSchema = z.object({ + conversationId: z.string().startsWith("blooio:"), +}); + +export default defineChannel({ + audience({ caller }) { + return caller.type === "principal" && caller.principal.kind === "user" + ? "private" + : "unknown"; + }, + events: { + "action.result"(event, channel, session) { + return deliverBlooioAction(event, channel, session); + }, + async "authorization.required"(event, channel, session) { + await deliverBlooioAuthorization(event, channel, session); + }, + "message.completed"(event, _channel, session) { + return finishBlooioTurn(event, session); + }, + "session.completed"(_event, _channel, session) { + return finishBlooioTurn({}, session); + }, + "turn.cancelled"(_event, _channel, session) { + return releaseBlooioTurn( + session, + "Scheduled result reporting was cancelled." + ); + }, + "turn.failed"(event, _channel, session) { + return releaseBlooioTurn(session, event.message); + }, + "turn.started"(_event, channel, session) { + return showBlooioTyping(channel, session); + }, + }, + async fetchFile(url) { + if (!url.startsWith("https://")) return null; + const response = await fetch(url); + if (!response.ok) return null; + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.byteLength > 10_000_000) return null; + const mediaType = response.headers + .get("content-type") + ?.split(";")[0] + ?.trim(); + return mediaType ? { bytes, mediaType } : bytes; + }, + async receive(input, { from }) { + const target = receiveTargetSchema.parse(input.target); + return from(target.conversationId.slice("blooio:".length)).send( + input.message, + { auth: input.auth } + ); + }, + routes: [ + POST("/webhooks/blooio", async (request, { from, waitUntil }) => { + const accepted = await acceptBlooioWebhook(request); + if (!accepted.ok) return accepted.response; + const message = accepted.message; + if (!message) return new Response("ok"); + const auth = await blooioInboundAuth(message); + if (!claimBlooioDelivery(message.messageId)) return new Response("ok"); + if (!auth) { + console.warn("[blooio] ignoring message from an unlinked handle", { + chatId: message.chatId, + }); + return new Response("ok"); + } + // Blooio retries deliveries that take longer than five seconds. Accept + // the webhook first and run the agent turn after the response. + waitUntil( + (async () => { + try { + await markBlooioChatRead(message.chatId); + } catch { + // A missed read receipt should not drop the inbound turn. + } + try { + await from(message.chatId).send(blooioUserContent(message), { + auth, + }); + } catch (error) { + console.warn("[blooio] inbound delivery failed", { + error, + messageId: message.messageId, + }); + } + })() + ); + return new Response("ok"); + }), + ], +}); diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index fb639489..42ad5387 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -9,7 +9,7 @@ import { vercelOidc } from "eve/channels/auth"; import { z } from "zod"; import { resolveLinqReplyTarget } from "@agent/lib/reply-targets"; import { scopeFromPrincipal } from "@agent/lib/principal-scope"; -import { getAuth } from "@db/services/auth"; +import { findVerifiedAuthUserIdByPhoneNumber } from "@db/services/auth/verified-phone"; import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; import { accessScopeForUser } from "@shared/identity/access-scope"; @@ -30,10 +30,6 @@ import { scheduledReportFromSession, } from "@agent/lib/schedules/report-lifecycle"; -const verifiedPhoneUserSchema = z.object({ - id: z.string().min(1), - phoneNumberVerified: z.literal(true), -}); const unavailableReplyTargetSchema = z.object({ status: z.union([z.literal(400), z.literal(404)]), }); @@ -403,14 +399,3 @@ async function sendLinqMessage({ await post(outgoing); } } - -async function findVerifiedAuthUserIdByPhoneNumber(phoneNumber: string) { - const auth = await getAuth(); - const context = await auth.$context; - const user = await context.adapter.findOne({ - model: "user", - where: [{ field: "phoneNumber", value: phoneNumber }], - }); - const parsed = verifiedPhoneUserSchema.safeParse(user); - return parsed.success ? parsed.data.id : undefined; -} diff --git a/agent/instructions/content/role/interactive.md b/agent/instructions/content/role/interactive.md index 41486d95..7d2be764 100644 --- a/agent/instructions/content/role/interactive.md +++ b/agent/instructions/content/role/interactive.md @@ -49,11 +49,11 @@ The main conversation is the control plane. Coordinate the user's work there and # Coordination -- On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq renders reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible. +- On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq and Blooio render reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible. - Use `schedules-create` to create a one-time reminder, recurring job, monitor, or scheduled follow-up. Use `kind: "calendar"` with the user's IANA timezone for wall-clock recurrence, `kind: "interval"` for elapsed intervals, and `kind: "once"` for one future instant. Summarize the exact task in `prompt`. Use `schedules-list` before changing an ambiguous schedule and `schedules-update` to edit, pause, resume, or delete it. - When the user answers a question previously sent for a scheduled task, call `schedules-answer` with the internal run ID retained in conversation context and their answer. The parked background run continues from the exact point where it asked. -- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` can add or remove any supported Tapback on the current user message: `thumbs_up`, `thumbs_down`, `heart`, `laugh`, `exclamation` (emphasis), or `question`. -- Eve and Linq own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation. +- Use the full native Linq or Blooio iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` can add or remove any supported Tapback on the current user message: `thumbs_up`, `thumbs_down`, `heart`, `laugh`, `exclamation` (emphasis), or `question`. +- Eve, Linq, and Blooio own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation. - After a `send_message`, `share_contact`, or `react_to_message` call, never repeat or summarize it in assistant text. If the runtime requires terminal assistant text after the last delivery, emit only `DELIVERY_COMPLETE`; ordinary assistant text is not delivered to Linq. - The worker's structured result is coordinator-facing only. Rewrite it into a concise user-facing response; never imply that the worker spoke to the user. - Start a background worker without a separate preamble. Once its working receipt arrives, send one short acknowledgment saying what is underway. Treat the receipt as acceptance, not completion. diff --git a/agent/lib/blooio/deliver.ts b/agent/lib/blooio/deliver.ts new file mode 100644 index 00000000..e3f0f191 --- /dev/null +++ b/agent/lib/blooio/deliver.ts @@ -0,0 +1,291 @@ +import type { SessionContext } from "eve/context"; +import type { ChannelContinuationOps } from "eve/channels"; +import { z } from "zod"; +import { + BlooioApiError, + sendBlooioChatMessage, + sendBlooioReaction, + setBlooioTyping, +} from "@shared/blooio/api"; +import { resolveBlooioReplyTarget } from "@agent/lib/reply-targets"; +import { + extractImageArtifactMarkdownReferences, + stripImageArtifactMarkdownReferences, +} from "@agent/lib/linq-image-artifact/markdown"; +import { sendMessageToolResultSchema } from "@shared/chat/message-delivery"; +import { reactToMessageToolResultSchema } from "@shared/chat/reaction"; +import { + finalizeScheduledReportDelivery, + releaseScheduledReportDelivery, + scheduledReportFromSession, +} from "@agent/lib/schedules/report-lifecycle"; + +const unavailableReplySchema = z.object({ + status: z.union([z.literal(404), z.literal(409)]), +}); + +export async function deliverBlooioAction( + event: { readonly result?: unknown; readonly status?: string }, + channel: ChannelContinuationOps, + session: SessionContext +) { + const reaction = reactToMessageToolResultSchema.safeParse(event.result); + if (event.status === "completed" && reaction.success) { + const chatId = requireBlooioChatId(channel, session); + const messageId = currentBlooioMessageId(session); + if (!messageId) { + throw new Error("react_to_message requires a current Blooio message."); + } + await sendBlooioReaction({ + chatId, + messageId, + operation: reaction.data.output.operation, + type: reaction.data.output.type, + }); + await stopTyping(chatId); + await finalizeScheduledReportDelivery(session); + return; + } + + const message = sendMessageToolResultSchema.safeParse(event.result); + if ( + event.status !== "completed" || + !message.success || + message.data.toolName !== "send_message" + ) { + return; + } + const chatId = requireBlooioChatId(channel, session); + const report = scheduledReportFromSession(session); + const replyTarget = resolveBlooioReplyTarget( + message.data.output.replyTo, + session.session.auth + ); + const replyTo = + replyTarget?.conversationId === `blooio:${chatId}` + ? replyTarget.messageId + : undefined; + const idempotencyKey = report + ? `scheduled-report:${report.runId}:${String(report.sequence)}` + : undefined; + + if (message.data.output.kind === "link") { + await sendBlooioMessage({ + chatId, + idempotencyKey, + message: { rich_link: { url: message.data.output.url } }, + replyTo, + }); + await stopTyping(chatId); + await finalizeScheduledReportDelivery(session); + return; + } + + const attachments = message.data.output.attachments?.map( + (attachment) => attachment.url + ); + const requestedText = message.data.output.text; + if (!requestedText) { + if (attachments?.length) { + await sendBlooioMessage({ + chatId, + idempotencyKey, + message: { attachments }, + replyTo, + }); + } + await stopTyping(chatId); + await finalizeScheduledReportDelivery(session); + return; + } + + const references = extractImageArtifactMarkdownReferences(requestedText); + const stripped = + references.length === 0 + ? requestedText + : stripImageArtifactMarkdownReferences(requestedText); + const failure = + references.length === 0 + ? "" + : references.length === 1 + ? "I couldn't attach the image." + : `I couldn't attach ${String(references.length)} images.`; + const text = [stripped, failure].filter(Boolean).join("\n\n"); + if (!text && !attachments?.length) { + await stopTyping(chatId); + await finalizeScheduledReportDelivery(session); + return; + } + await sendBlooioMessage({ + chatId, + idempotencyKey, + message: { + attachments, + text: text || undefined, + }, + replyTo, + }); + await stopTyping(chatId); + await finalizeScheduledReportDelivery(session); +} + +export async function showBlooioTyping( + channel: ChannelContinuationOps, + session: SessionContext +) { + const chatId = blooioChatId(channel, session); + if (!chatId) return; + try { + await setBlooioTyping(chatId, "started"); + } catch (error) { + console.warn("[blooio] typing indicator failed", { error }); + } +} + +export async function deliverBlooioAuthorization( + event: { + readonly attemptId?: string; + readonly authorization?: { + readonly displayName?: string; + readonly instructions?: string; + readonly url?: string; + readonly userCode?: string; + }; + readonly candidateId?: string; + readonly name: string; + readonly turnId: string; + }, + channel: ChannelContinuationOps, + session: SessionContext +) { + if (event.candidateId !== undefined) return; + const chatId = blooioChatId(channel, session); + if (!chatId) { + throw new Error( + "Authorization delivery requires an existing Blooio conversation." + ); + } + const displayName = event.authorization?.displayName ?? event.name; + const idempotencyKey = `authorization:${session.session.id}:${event.attemptId ?? `${event.turnId}:${event.name}`}`; + await sendBlooioChatMessage( + chatId, + { + text: [ + event.authorization?.instructions ?? + `Connect ${displayName} to continue.`, + event.authorization?.userCode + ? `Code: ${event.authorization.userCode}` + : undefined, + ] + .filter(Boolean) + .join("\n\n"), + }, + `${idempotencyKey}:prompt` + ); + if (event.authorization?.url) { + await sendBlooioChatMessage( + chatId, + { rich_link: { url: event.authorization.url } }, + `${idempotencyKey}:link` + ); + } +} + +export async function finishBlooioTurn( + event: { readonly finishReason?: string }, + session: SessionContext +) { + if (event.finishReason === "tool-calls") return; + await finalizeScheduledReportDelivery( + session, + scheduledReportFromSession(session) ? "suppressed" : "delivered" + ); +} + +export function releaseBlooioTurn(session: SessionContext, message: string) { + return releaseScheduledReportDelivery(session, message); +} + +async function sendBlooioMessage({ + chatId, + idempotencyKey, + message, + replyTo, +}: { + readonly chatId: string; + readonly idempotencyKey?: string; + readonly message: { + readonly attachments?: readonly string[]; + readonly rich_link?: { readonly url: string }; + readonly text?: string; + }; + readonly replyTo?: string; +}) { + const payload = { + attachments: message.attachments, + rich_link: message.rich_link, + text: message.text, + }; + try { + await sendBlooioChatMessage( + chatId, + replyTo ? { ...payload, reply_to: replyTo } : payload, + idempotencyKey + ); + } catch (error) { + if (!replyTo || !unavailableReplySchema.safeParse(error).success) + throw error; + console.warn("[blooio] reply target is unavailable", { replyTo }); + await sendBlooioChatMessage( + chatId, + payload, + idempotencyKey ? `${idempotencyKey}:fallback` : undefined + ); + } +} + +function requireBlooioChatId( + channel: ChannelContinuationOps, + session: SessionContext +) { + const chatId = blooioChatId(channel, session); + if (!chatId) { + throw new Error("send_message requires an active Blooio conversation."); + } + return chatId; +} + +function blooioChatId( + channel: ChannelContinuationOps, + session: SessionContext +) { + const token = channel.continuation?.token; + if (token?.startsWith("chat_")) return token; + const caller = session.session.auth.current ?? session.session.auth.initiator; + if (caller?.attributes.conversationChannel !== "blooio") return undefined; + const conversationId = z + .string() + .startsWith("blooio:") + .safeParse(caller.attributes.conversationId); + return conversationId.success + ? conversationId.data.slice("blooio:".length) + : undefined; +} + +function currentBlooioMessageId(session: SessionContext) { + const caller = session.session.auth.current; + const messageId = z + .string() + .min(1) + .safeParse(caller?.attributes.blooioMessageId); + return messageId.success ? messageId.data : undefined; +} + +async function stopTyping(chatId: string) { + try { + await setBlooioTyping(chatId, "stopped"); + } catch (error) { + if (error instanceof BlooioApiError) return; + console.warn("[blooio] clearing typing indicator failed", { error }); + } +} diff --git a/agent/lib/blooio/inbound.ts b/agent/lib/blooio/inbound.ts new file mode 100644 index 00000000..1a51ef93 --- /dev/null +++ b/agent/lib/blooio/inbound.ts @@ -0,0 +1,168 @@ +import type { UserContent } from "ai"; +import { z } from "zod"; +import { env } from "@shared/environment"; +import { normalizeAuthPhoneNumber } from "@shared/identity/phone-number"; +import { accessScopeForUser } from "@shared/identity/access-scope"; +import { findVerifiedAuthUserIdByPhoneNumber } from "@db/services/auth/verified-phone"; +import { verifyBlooioSignature } from "./signature"; + +const attachmentSchema = z.object({ + media_type: z.string().nullable().optional(), + url: z.string().nullable().optional(), +}); + +const envelopeSchema = z.object({ + data: z + .object({ + attachments: z.array(attachmentSchema).optional(), + channel_address: z.string().nullable().optional(), + chat_id: z.string().optional(), + contact: z + .object({ identifier: z.string().optional() }) + .nullable() + .optional(), + direction: z.string().optional(), + formatted_text: z.string().optional(), + message_id: z.string().optional(), + sender: z.string().nullable().optional(), + text: z.string().nullable().optional(), + }) + .optional(), + type: z.string(), +}); + +export interface BlooioInboundMessage { + readonly attachments: readonly { + readonly mediaType?: string; + readonly url: string; + }[]; + readonly chatId: string; + readonly messageId: string; + readonly phoneNumber: string; + readonly text: string; +} + +const seenMessageIds = new Set(); +const maximumSeenMessages = 1000; + +export async function acceptBlooioWebhook( + request: Request +): Promise< + | { readonly message?: BlooioInboundMessage; readonly ok: true } + | { readonly ok: false; readonly response: Response } +> { + if (!env.BLOOIO_API_KEY) { + return { ok: false, response: new Response("Not found", { status: 404 }) }; + } + if (!env.BLOOIO_WEBHOOK_SECRET) { + return { + ok: false, + response: new Response("Blooio webhook secret is not configured", { + status: 503, + }), + }; + } + const rawBody = await request.text(); + if ( + !verifyBlooioSignature( + env.BLOOIO_WEBHOOK_SECRET, + rawBody, + request.headers.get("x-blooio-signature") + ) + ) { + return { + ok: false, + response: new Response("Invalid Blooio webhook", { status: 401 }), + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return { + ok: false, + response: new Response("Invalid JSON", { status: 400 }), + }; + } + const envelope = envelopeSchema.safeParse(parsed); + if (!envelope.success) { + return { + ok: false, + response: new Response("Invalid JSON", { status: 400 }), + }; + } + if (envelope.data.type !== "message.received") return { ok: true }; + const data = envelope.data.data; + if (data?.direction !== "inbound") return { ok: true }; + const chatId = data.chat_id; + const messageId = data.message_id; + const phoneNumber = normalizeAuthPhoneNumber( + data.sender ?? data.contact?.identifier ?? "" + ); + if (!chatId || !messageId || !phoneNumber) return { ok: true }; + if (data.channel_address && data.sender === data.channel_address) { + return { ok: true }; + } + const attachments = (data.attachments ?? []).flatMap((attachment) => + attachment.url?.startsWith("https://") + ? [ + { + mediaType: attachment.media_type ?? undefined, + url: attachment.url, + }, + ] + : [] + ); + const text = (data.formatted_text ?? data.text ?? "").trim(); + if (!text && attachments.length === 0) return { ok: true }; + return { + message: { attachments, chatId, messageId, phoneNumber, text }, + ok: true, + }; +} + +export function blooioUserContent( + message: BlooioInboundMessage +): string | UserContent { + if (message.attachments.length === 0) return message.text; + return [ + ...(message.text ? [{ text: message.text, type: "text" as const }] : []), + ...message.attachments.map((attachment) => ({ + data: new URL(attachment.url), + mediaType: attachment.mediaType ?? "application/octet-stream", + type: "file" as const, + })), + ]; +} + +export async function blooioInboundAuth(message: BlooioInboundMessage) { + const verifiedUserId = await findVerifiedAuthUserIdByPhoneNumber( + message.phoneNumber + ); + if (!verifiedUserId) return undefined; + const principalId = `better-auth:${verifiedUserId}`; + const scope = accessScopeForUser(principalId); + return { + attributes: { + blooioChatId: message.chatId, + blooioMessageId: message.messageId, + conversationChannel: "blooio", + conversationId: `blooio:${message.chatId}`, + phoneNumber: message.phoneNumber, + workspaceId: scope.workspaceId, + }, + authenticator: "blooio", + principalId, + principalType: "user" as const, + }; +} + +export function claimBlooioDelivery(messageId: string) { + if (seenMessageIds.has(messageId)) return false; + seenMessageIds.add(messageId); + if (seenMessageIds.size > maximumSeenMessages) { + const oldest = seenMessageIds.values().next().value; + if (oldest) seenMessageIds.delete(oldest); + } + return true; +} diff --git a/agent/lib/blooio/signature.ts b/agent/lib/blooio/signature.ts new file mode 100644 index 00000000..90b1c5fc --- /dev/null +++ b/agent/lib/blooio/signature.ts @@ -0,0 +1,54 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export function blooioSignatureHeader( + secret: string, + rawBody: string, + timestampSeconds = Math.floor(Date.now() / 1000) +) { + const signature = createHmac("sha256", secret) + .update(`${String(timestampSeconds)}.${rawBody}`) + .digest("hex"); + return `t=${String(timestampSeconds)},v1=${signature}`; +} + +export function verifyBlooioSignature( + secret: string, + rawBody: string, + header: string | null, + toleranceSeconds = 300 +) { + if (!header) return false; + const parts = new Map( + header.split(",").flatMap((part) => { + const index = part.indexOf("="); + if (index <= 0) return []; + return [ + [part.slice(0, index).trim(), part.slice(index + 1).trim()] as const, + ]; + }) + ); + const timestamp = Number(parts.get("t")); + const provided = parts.get("v1")?.toLowerCase(); + if ( + !Number.isFinite(timestamp) || + !provided || + !/^[a-f0-9]+$/.test(provided) + ) { + return false; + } + if ( + toleranceSeconds > 0 && + Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds + ) { + return false; + } + const expected = createHmac("sha256", secret) + .update(`${String(timestamp)}.${rawBody}`) + .digest("hex"); + const expectedBytes = Buffer.from(expected); + const providedBytes = Buffer.from(provided); + return ( + expectedBytes.length === providedBytes.length && + timingSafeEqual(expectedBytes, providedBytes) + ); +} diff --git a/agent/lib/reply-targets.ts b/agent/lib/reply-targets.ts index a2305856..53363d94 100644 --- a/agent/lib/reply-targets.ts +++ b/agent/lib/reply-targets.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { scheduledReportIdentity } from "@agent/lib/schedules/identity"; import type { ReplyReference } from "@shared/chat/message-delivery"; -const linqReplyTargetSchema = z.strictObject({ - conversationId: z.string().startsWith("linq:"), +const imessageReplyTargetSchema = z.strictObject({ + conversationId: z.string().min(1), messageId: z.string().min(1), }); -type LinqReplyTarget = z.infer; +type ImessageReplyTarget = z.infer; -const backgroundReplyTargets = defineState>( +const backgroundReplyTargets = defineState>( "open-instinct.background-reply-targets", () => ({}) ); @@ -21,7 +21,7 @@ export function registerBackgroundReplyTarget( taskId: string, auth: SessionAuth ) { - const target = currentLinqReplyTarget(auth); + const target = currentLinqReplyTarget(auth) ?? currentBlooioReplyTarget(auth); if (!target) return; backgroundReplyTargets.update((current) => @@ -37,14 +37,31 @@ export function registerBackgroundReplyTarget( export function resolveLinqReplyTarget( reference: ReplyReference | undefined, auth: SessionAuth +) { + return resolveImessageReplyTarget(reference, auth, "linq"); +} + +export function resolveBlooioReplyTarget( + reference: ReplyReference | undefined, + auth: SessionAuth +) { + return resolveImessageReplyTarget(reference, auth, "blooio"); +} + +function resolveImessageReplyTarget( + reference: ReplyReference | undefined, + auth: SessionAuth, + channel: "blooio" | "linq" ) { if (!reference) return undefined; - const conversationId = currentLinqConversationId(auth); + const conversationId = currentConversationId(auth, channel); if (!conversationId) return undefined; if (reference.kind === "current") { - return currentLinqReplyTarget(auth); + return channel === "linq" + ? currentLinqReplyTarget(auth) + : currentBlooioReplyTarget(auth); } if (reference.kind === "task") { @@ -59,25 +76,43 @@ export function resolveLinqReplyTarget( return { conversationId, messageId: report.replyAnchorMessageId, - } satisfies LinqReplyTarget; + } satisfies ImessageReplyTarget; } -function currentLinqConversationId(auth: SessionAuth) { +function currentConversationId(auth: SessionAuth, channel: "blooio" | "linq") { const caller = auth.current ?? auth.initiator; - if (caller?.attributes.conversationChannel !== "linq") return undefined; + if (caller?.attributes.conversationChannel !== channel) return undefined; const parsed = z .string() - .startsWith("linq:") + .startsWith(`${channel}:`) .safeParse(caller.attributes.conversationId); return parsed.success ? parsed.data : undefined; } function currentLinqReplyTarget(auth: SessionAuth) { + return currentReplyTarget(auth, "linq", "linqMessageId"); +} + +function currentBlooioReplyTarget(auth: SessionAuth) { + return currentReplyTarget(auth, "blooio", "blooioMessageId"); +} + +function currentReplyTarget( + auth: SessionAuth, + channel: "blooio" | "linq", + messageAttribute: "blooioMessageId" | "linqMessageId" +) { const caller = auth.current; - if (caller?.attributes.conversationChannel !== "linq") return undefined; - const parsed = linqReplyTargetSchema.safeParse({ + if (caller?.attributes.conversationChannel !== channel) return undefined; + const parsed = imessageReplyTargetSchema.safeParse({ conversationId: caller.attributes.conversationId, - messageId: caller.attributes.linqMessageId, + messageId: caller.attributes[messageAttribute], }); - return parsed.success ? parsed.data : undefined; + if ( + !parsed.success || + !parsed.data.conversationId.startsWith(`${channel}:`) + ) { + return undefined; + } + return parsed.data; } diff --git a/agent/lib/schedules/identity.ts b/agent/lib/schedules/identity.ts index 377e10b4..31c5496e 100644 --- a/agent/lib/schedules/identity.ts +++ b/agent/lib/schedules/identity.ts @@ -2,6 +2,7 @@ import type { SessionContext } from "eve/context"; import { z } from "zod"; const scheduledReportIdentitySchema = z.object({ + blooioReplyAnchorMessageId: z.string().min(1).optional(), linqReplyAnchorMessageId: z.string().min(1).optional(), scheduleId: z.uuid(), scheduledReportLeaseToken: z.uuid(), @@ -39,7 +40,9 @@ export function scheduledReportIdentity( const identity = scheduledReportIdentitySchema.safeParse(caller.attributes); return identity.success ? { - replyAnchorMessageId: identity.data.linqReplyAnchorMessageId, + replyAnchorMessageId: + identity.data.linqReplyAnchorMessageId ?? + identity.data.blooioReplyAnchorMessageId, scheduleId: identity.data.scheduleId, leaseToken: identity.data.scheduledReportLeaseToken, runId: identity.data.scheduledRunId, diff --git a/agent/lib/schedules/report.ts b/agent/lib/schedules/report.ts index e050a0bb..7fd752e9 100644 --- a/agent/lib/schedules/report.ts +++ b/agent/lib/schedules/report.ts @@ -5,6 +5,7 @@ import { finalizeScheduledReport, releaseScheduledReport, } from "@db/services/scheduled-agent-jobs"; +import blooio from "../../channels/blooio"; import linq from "../../channels/linq"; type ClaimedScheduledReport = NonNullable< @@ -55,6 +56,18 @@ export async function dispatchScheduledReport( }); return; } + if (claimed.job.conversationChannel === "blooio") { + const session = await delivery + .to(blooio, { conversationId: claimed.job.conversationId }) + .send(prompt, options); + console.info("[scheduled-run] report session accepted", { + channel: claimed.job.conversationChannel, + reportSequence: claimed.run.reportSequence, + runId: claimed.run.id, + sessionId: session.id, + }); + return; + } if (!delivery.attachSession) { throw new Error("Eve debug reports require an active session handle."); } @@ -125,14 +138,19 @@ function scheduledReportAttributes( ["scheduledRunId", claimed.run.id], ["workspaceId", claimed.job.workspaceId], ]); - if ( - claimed.job.conversationChannel === "linq" && - claimed.job.replyAnchorMessageId - ) { - attributes.set( - "linqReplyAnchorMessageId", - claimed.job.replyAnchorMessageId - ); + if (claimed.job.replyAnchorMessageId) { + if (claimed.job.conversationChannel === "linq") { + attributes.set( + "linqReplyAnchorMessageId", + claimed.job.replyAnchorMessageId + ); + } + if (claimed.job.conversationChannel === "blooio") { + attributes.set( + "blooioReplyAnchorMessageId", + claimed.job.replyAnchorMessageId + ); + } } if (claimed.run.workerSessionId) { attributes.set("scheduledRunSessionId", claimed.run.workerSessionId); diff --git a/agent/lib/schedules/tools.ts b/agent/lib/schedules/tools.ts index 4a45527f..0c3cefb1 100644 --- a/agent/lib/schedules/tools.ts +++ b/agent/lib/schedules/tools.ts @@ -12,12 +12,15 @@ export function scheduleOwner(context: ToolContext) { throw new Error("An authenticated user is required to manage schedules."); } const conversationChannel = z - .enum(["eve", "linq"]) + .enum(["blooio", "eve", "linq"]) .parse(auth.attributes.conversationChannel); const conversationId = conversationChannel === "eve" ? context.session.id - : z.string().startsWith("linq:").parse(auth.attributes.conversationId); + : z + .string() + .startsWith(`${conversationChannel}:`) + .parse(auth.attributes.conversationId); return { conversation: { conversationChannel, conversationId }, scope: scopeFromPrincipal(auth), @@ -26,8 +29,16 @@ export function scheduleOwner(context: ToolContext) { export function scheduleReplyAnchor(context: ToolContext) { const auth = context.session.auth.current; - if (auth?.attributes.conversationChannel !== "linq") return undefined; - const messageId = z.string().min(1).safeParse(auth.attributes.linqMessageId); + const channel = auth?.attributes.conversationChannel; + if (channel !== "linq" && channel !== "blooio") return undefined; + const messageId = z + .string() + .min(1) + .safeParse( + channel === "linq" + ? auth?.attributes.linqMessageId + : auth?.attributes.blooioMessageId + ); return messageId.success ? messageId.data : undefined; } diff --git a/agent/schedules/dynamic.ts b/agent/schedules/dynamic.ts index 6cee4d1b..3a7357e1 100644 --- a/agent/schedules/dynamic.ts +++ b/agent/schedules/dynamic.ts @@ -100,7 +100,8 @@ function dispatchRecoverableReport( to: ScheduleToFn, report: Awaited>[number] ) { - return report.conversationChannel === "linq" + return report.conversationChannel === "linq" || + report.conversationChannel === "blooio" ? dispatchScheduledReport({ to }, report.runId) : postScheduledReport(report.runId); } diff --git a/agent/tools/messaging.ts b/agent/tools/messaging.ts index 7dede9d6..6abaa9d4 100644 --- a/agent/tools/messaging.ts +++ b/agent/tools/messaging.ts @@ -5,6 +5,10 @@ import { resolveModeValue } from "../lib/mode"; import { sendNativeLinqMessage } from "@agent/lib/linq/transport"; import { scopeFromPrincipal } from "@agent/lib/principal-scope"; import { readLinqOnboardingPhoneNumber } from "@db/services/auth/linq"; +import { + readBlooioSendingNumber, + sendBlooioChatMessage, +} from "@shared/blooio/api"; import { getInstallationSecrets } from "@db/services/installation-secrets"; import { openInstinctContactUrl } from "@shared/chat/contact-card"; import { env } from "@shared/environment"; @@ -52,12 +56,13 @@ function defineShareContact() { throw new Error("Contact sharing requires an authenticated user."); const { userId } = scopeFromPrincipal(caller); const channel = caller.attributes.conversationChannel; - if (channel !== "linq" && channel !== "eve") { + if (channel !== "linq" && channel !== "blooio" && channel !== "eve") { throw new Error( - "Contact sharing requires an active Linq or browser conversation." + "Contact sharing requires an active Linq, Blooio, or browser conversation." ); } let chatId: string | undefined; + let blooioChatId: string | undefined; if (channel === "linq") { // Linq uses linq:, with optional :dm/:group on older threads. const threadId = z @@ -76,16 +81,28 @@ function defineShareContact() { ); } } + if (channel === "blooio") { + const threadId = z + .string() + .startsWith("blooio:") + .safeParse(caller.attributes.conversationId); + blooioChatId = threadId.success + ? threadId.data.slice("blooio:".length) + : undefined; + if (!threadId.success || !blooioChatId?.startsWith("chat_")) { + throw new Error( + "Contact sharing requires the current authenticated Blooio conversation." + ); + } + } let delivery = contactDelivery.get(); if (delivery?.userId !== userId) { - const phone = - env.LINQ_PHONE_NUMBER ?? - (env.LINQ_CONNECTOR - ? await readLinqOnboardingPhoneNumber(env.LINQ_CONNECTOR) - : undefined); + const phone = await contactPhoneNumber(channel); if (!phone) throw new Error( - "OpenInstinct's Linq number is unavailable. Nothing was sent." + channel === "blooio" || !env.LINQ_CONNECTOR + ? "OpenInstinct's Blooio number is unavailable. Nothing was sent." + : "OpenInstinct's Linq number is unavailable. Nothing was sent." ); const { betterAuthSecret } = await getInstallationSecrets(); delivery = { @@ -107,6 +124,17 @@ function defineShareContact() { contactDelivery.update(() => delivery); } if (delivery.sent) return null; + if (blooioChatId) { + const { text: introduction, attachments } = delivery.message; + const attachmentUrls = (attachments ?? []).map(({ url }) => url); + await sendBlooioChatMessage( + blooioChatId, + attachmentUrls.length > 0 + ? { attachments: attachmentUrls, text: introduction } + : { text: introduction }, + `openinstinct-contact:${context.session.id}` + ); + } if (chatId) { const { text: introduction, attachments } = delivery.message; await sendNativeLinqMessage( @@ -139,17 +167,35 @@ function defineShareContact() { }); } +async function contactPhoneNumber(channel: string) { + if (channel === "blooio" || (channel === "eve" && !env.LINQ_CONNECTOR)) { + return readBlooioSendingNumber(); + } + return ( + env.LINQ_PHONE_NUMBER ?? + (env.LINQ_CONNECTOR + ? await readLinqOnboardingPhoneNumber(env.LINQ_CONNECTOR) + : undefined) + ); +} + export default defineDynamic({ events: { "turn.started": (_event, context) => { - const isLinq = context.channel.kind === "channel:linq"; + const caller = + context.session.auth.current ?? context.session.auth.initiator; + const isNativeImessage = + context.channel.kind === "channel:linq" || + context.channel.kind === "channel:blooio" || + caller?.attributes.conversationChannel === "linq" || + caller?.attributes.conversationChannel === "blooio"; const send_message = defineSendMessage(); const react_to_message = defineTool({ - description: isLinq + description: isNativeImessage ? "Add or remove a native iMessage Tapback on the user's current message. Use this instead of send_message when a reaction fully communicates a lightweight acknowledgement and words would add nothing. Supports thumbs_up, thumbs_down, heart, laugh, exclamation (emphasis), and question." : "Acknowledge the user's current message with one compact reaction displayed in the conversation. Use this instead of send_message when the reaction fully communicates the response and words would add nothing. Supports thumbs_up, thumbs_down, heart, laugh, exclamation (emphasis), and question.", - inputSchema: isLinq + inputSchema: isNativeImessage ? reactToMessageOutputSchema : addReactionToMessageOutputSchema, execute(reaction) { diff --git a/app/(authenticated)/(workspace)/_tests/workspace-linq.test.ts b/app/(authenticated)/(workspace)/_tests/workspace-linq.test.ts index a51df987..ff3473b8 100644 --- a/app/(authenticated)/(workspace)/_tests/workspace-linq.test.ts +++ b/app/(authenticated)/(workspace)/_tests/workspace-linq.test.ts @@ -13,7 +13,7 @@ describe("workspace Linq channel", () => { }) ); - expect(html).toContain("Set up Linq to enable iMessage."); + expect(html).toContain("Set up Linq or Blooio to enable iMessage."); expect(html).not.toContain("+12052611117"); expect(html).not.toContain("sms:"); }); @@ -44,6 +44,22 @@ describe("workspace Linq channel", () => { expect(html).not.toContain("sms:"); }); + it("links a Blooio number when Linq is not configured", () => { + const html = renderToStaticMarkup( + createElement(ChannelsSection, { + blooioConfigured: true, + blooioPhoneNumber: "+12025550199", + browserReady: true, + linqConfigured: false, + linqPhoneNumber: undefined, + }) + ); + + expect(html).toContain("sms:+12025550199"); + expect(html).toContain("iMessage opens +12025550199."); + expect(html).not.toContain("Set up Linq or Blooio"); + }); + it("does not advertise a phone-number override without its connector", () => { const html = renderToStaticMarkup( createElement(ChannelsSection, { @@ -53,7 +69,7 @@ describe("workspace Linq channel", () => { }) ); - expect(html).toContain("Set up Linq to enable iMessage."); + expect(html).toContain("Set up Linq or Blooio to enable iMessage."); expect(html).not.toContain("sms:"); }); }); diff --git a/app/(authenticated)/(workspace)/page.tsx b/app/(authenticated)/(workspace)/page.tsx index 064ebbcc..cc675583 100644 --- a/app/(authenticated)/(workspace)/page.tsx +++ b/app/(authenticated)/(workspace)/page.tsx @@ -53,6 +53,8 @@ export default async function Page({ searchParams }: PageProps<"/">) { @@ -179,10 +181,14 @@ async function readGoogleWorkspaceConnection( } export function ChannelsSection({ + blooioConfigured = false, + blooioPhoneNumber, browserReady, linqConfigured, linqPhoneNumber, }: { + readonly blooioConfigured?: boolean; + readonly blooioPhoneNumber?: string; readonly browserReady: boolean; readonly linqConfigured: boolean; readonly linqPhoneNumber?: string; @@ -205,11 +211,15 @@ export function ChannelsSection({ WebChat )} - {linqConfigured && linqPhoneNumber ? ( + {(linqConfigured && linqPhoneNumber) || + (blooioConfigured && blooioPhoneNumber) ? ( + ) : null} + + + ); +} + function FirstTimeLinqSetup({ phoneNumber, }: { @@ -202,7 +237,9 @@ export function phoneOtpErrorMessage(error: { readonly code?: string; readonly message?: string; }) { - return error.code?.startsWith("LINQ_") && error.message + return (error.code?.startsWith("LINQ_") || + error.code?.startsWith("BLOOIO_")) && + error.message ? error.message : "Unable to send a code. Please try again."; } diff --git a/app/sign-in/_tests/phone-auth-form.test.ts b/app/sign-in/_tests/phone-auth-form.test.ts index 6b141ad1..f22e6499 100644 --- a/app/sign-in/_tests/phone-auth-form.test.ts +++ b/app/sign-in/_tests/phone-auth-form.test.ts @@ -30,6 +30,15 @@ describe("phone OTP errors", () => { ).toBe("Send a message to the Linq phone number, then try again."); }); + it("shows actionable Blooio errors", () => { + expect( + phoneOtpErrorMessage({ + code: "BLOOIO_DELIVERY_FAILED", + message: "Blooio could not send a sign-in code.", + }) + ).toBe("Blooio could not send a sign-in code."); + }); + it("does not expose unrelated server errors", () => { expect( phoneOtpErrorMessage({ @@ -75,6 +84,20 @@ describe("phone OTP errors", () => { expect(error).not.toContain("button"); }); + it("tells Blooio sign-in that the code arrives by iMessage", () => { + const html = renderForm( + createElement(PhoneOtpAuthForm, { + blooioPhoneNumber: "+12025550123", + callbackUrl: "/", + provider: "blooio", + }) + ); + + expect(html).toContain("Code arrives by iMessage"); + expect(html).toContain('href="sms:+12025550123"'); + expect(html).not.toContain("First time signing in?"); + }); + it("does not show Linq setup during local sign-in", () => { const html = renderForm( createElement(LocalPhoneAuthForm, { callbackUrl: "/" }) diff --git a/app/sign-in/page.tsx b/app/sign-in/page.tsx index f783789e..967c0507 100644 --- a/app/sign-in/page.tsx +++ b/app/sign-in/page.tsx @@ -22,6 +22,7 @@ export default async function SignInPage({ ? requestedCallback : "/"; const linqConfigured = env.LINQ_CONNECTOR !== undefined; + const blooioConfigured = env.BLOOIO_API_KEY !== undefined; const linqPhoneNumber = localPhoneAuthBypassEnabled || !env.LINQ_CONNECTOR ? undefined @@ -39,17 +40,23 @@ export default async function SignInPage({ Enter your phone number to sign in.

- {!localPhoneAuthBypassEnabled && !linqConfigured ? ( + {!localPhoneAuthBypassEnabled && + !linqConfigured && + !blooioConfigured ? (

iMessage sign-in is not configured for this deployment. Attach a - Linq connector through Vercel Connect. + Linq connector through Vercel Connect, or set a Blooio API key.

) : localPhoneAuthBypassEnabled ? ( ) : ( )} diff --git a/db/migrations/0015_blooio_channel.sql b/db/migrations/0015_blooio_channel.sql new file mode 100644 index 00000000..17b6e4d0 --- /dev/null +++ b/db/migrations/0015_blooio_channel.sql @@ -0,0 +1,2 @@ +ALTER TABLE "scheduled_agent_jobs" DROP CONSTRAINT "scheduled_agent_jobs_conversation_channel_check";--> statement-breakpoint +ALTER TABLE "scheduled_agent_jobs" ADD CONSTRAINT "scheduled_agent_jobs_conversation_channel_check" CHECK ("scheduled_agent_jobs"."conversation_channel" IN ('blooio', 'eve', 'linq')); \ No newline at end of file diff --git a/db/migrations/meta/0015_snapshot.json b/db/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..6ed3e951 --- /dev/null +++ b/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,2176 @@ +{ + "id": "34deff0c-22ab-43cd-a3b7-68e258468945", + "prevId": "6917d635-ae89-4aad-a91b-5eb74cdf6c0c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_issuer_accountId_uidx": { + "name": "account_issuer_accountId_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_link_userId_uidx": { + "name": "account_link_userId_uidx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account\".\"providerId\" = 'link'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "phoneNumber": { + "name": "phoneNumber", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phoneNumberVerified": { + "name": "phoneNumberVerified", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_phoneNumber_unique": { + "name": "user_phoneNumber_unique", + "nullsNotDistinct": false, + "columns": [ + "phoneNumber" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_image_artifacts": { + "name": "browser_image_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_session_id": { + "name": "root_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_session_id": { + "name": "browser_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_pathname": { + "name": "storage_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_image_artifacts_workspace_idempotency_uidx": { + "name": "browser_image_artifacts_workspace_idempotency_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_image_artifacts_workspace_created_idx": { + "name": "browser_image_artifacts_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_image_artifacts_membership_fkey": { + "name": "browser_image_artifacts_membership_fkey", + "tableFrom": "browser_image_artifacts", + "tableTo": "workspace_memberships", + "columnsFrom": [ + "workspace_id", + "created_by_user_id" + ], + "columnsTo": [ + "workspace_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_image_artifacts_status_check": { + "name": "browser_image_artifacts_status_check", + "value": "\"browser_image_artifacts\".\"status\" IN ('pending', 'ready')" + }, + "browser_image_artifacts_source_kind_check": { + "name": "browser_image_artifacts_source_kind_check", + "value": "\"browser_image_artifacts\".\"source_kind\" IN ('element', 'full_page', 'image_resource', 'viewport')" + }, + "browser_image_artifacts_ready_fields_check": { + "name": "browser_image_artifacts_ready_fields_check", + "value": "\"browser_image_artifacts\".\"status\" = 'pending' OR (\"browser_image_artifacts\".\"filename\" IS NOT NULL AND \"browser_image_artifacts\".\"media_type\" IS NOT NULL AND \"browser_image_artifacts\".\"byte_size\" > 0 AND \"browser_image_artifacts\".\"content_hash\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.browser_sessions": { + "name": "browser_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_sessions_workspace_idx": { + "name": "browser_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_sessions_worker_idx": { + "name": "browser_sessions_worker_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "worker_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_sessions_membership_fkey": { + "name": "browser_sessions_membership_fkey", + "tableFrom": "browser_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": [ + "workspace_id", + "created_by_user_id" + ], + "columnsTo": [ + "workspace_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_domains": { + "name": "browser_trace_domains", + "schema": "", + "columns": { + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_domains_domain_idx": { + "name": "browser_trace_domains_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_domains_trace_fkey": { + "name": "browser_trace_domains_trace_fkey", + "tableFrom": "browser_trace_domains", + "tableTo": "browser_traces", + "columnsFrom": [ + "trace_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "browser_trace_domains_pkey": { + "name": "browser_trace_domains_pkey", + "columns": [ + "trace_session_id", + "domain" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_events": { + "name": "browser_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_events_trace_idx": { + "name": "browser_trace_events_trace_idx", + "columns": [ + { + "expression": "trace_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_events_trace_fkey": { + "name": "browser_trace_events_trace_fkey", + "tableFrom": "browser_trace_events", + "tableTo": "browser_traces", + "columnsFrom": [ + "trace_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_traces": { + "name": "browser_traces", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task": { + "name": "task", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_message": { + "name": "result_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_traces_workspace_started_idx": { + "name": "browser_traces_workspace_started_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_traces_membership_fkey": { + "name": "browser_traces_membership_fkey", + "tableFrom": "browser_traces", + "tableTo": "workspace_memberships", + "columnsFrom": [ + "workspace_id", + "created_by_user_id" + ], + "columnsTo": [ + "workspace_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_traces_status_check": { + "name": "browser_traces_status_check", + "value": "\"browser_traces\".\"status\" IN ('running', 'success', 'failure', 'error', 'cancelled')" + }, + "browser_traces_duration_ms_check": { + "name": "browser_traces_duration_ms_check", + "value": "\"browser_traces\".\"duration_ms\" IS NULL OR \"browser_traces\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(16, 8)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_workspace_updated_idx": { + "name": "chats_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_workspace_id_fkey": { + "name": "chats_workspace_id_fkey", + "tableFrom": "chats", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chats_input_tokens_check": { + "name": "chats_input_tokens_check", + "value": "\"chats\".\"input_tokens\" >= 0" + }, + "chats_output_tokens_check": { + "name": "chats_output_tokens_check", + "value": "\"chats\".\"output_tokens\" >= 0" + }, + "chats_cost_usd_check": { + "name": "chats_cost_usd_check", + "value": "\"chats\".\"cost_usd\" IS NULL OR \"chats\".\"cost_usd\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_jobs": { + "name": "scheduled_agent_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_channel": { + "name": "conversation_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_anchor_message_id": { + "name": "reply_anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timing": { + "name": "timing", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "missed_run_policy": { + "name": "missed_run_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'run_latest'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_jobs_due_idx": { + "name": "scheduled_agent_jobs_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_jobs_owner_idx": { + "name": "scheduled_agent_jobs_owner_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_jobs_membership_fkey": { + "name": "scheduled_agent_jobs_membership_fkey", + "tableFrom": "scheduled_agent_jobs", + "tableTo": "workspace_memberships", + "columnsFrom": [ + "workspace_id", + "created_by_user_id" + ], + "columnsTo": [ + "workspace_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_jobs_conversation_channel_check": { + "name": "scheduled_agent_jobs_conversation_channel_check", + "value": "\"scheduled_agent_jobs\".\"conversation_channel\" IN ('blooio', 'eve', 'linq')" + }, + "scheduled_agent_jobs_conversation_id_check": { + "name": "scheduled_agent_jobs_conversation_id_check", + "value": "\"scheduled_agent_jobs\".\"conversation_id\" <> ''" + }, + "scheduled_agent_jobs_missed_run_policy_check": { + "name": "scheduled_agent_jobs_missed_run_policy_check", + "value": "\"scheduled_agent_jobs\".\"missed_run_policy\" IN ('skip', 'run_latest', 'catch_up')" + }, + "scheduled_agent_jobs_status_check": { + "name": "scheduled_agent_jobs_status_check", + "value": "\"scheduled_agent_jobs\".\"status\" IN ('active', 'paused', 'completed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_runs": { + "name": "scheduled_agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deferred_completion_turn_id": { + "name": "deferred_completion_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_input_requests": { + "name": "pending_input_requests", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_ready'" + }, + "report_sequence": { + "name": "report_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "report_lease_token": { + "name": "report_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report_lease_expires_at": { + "name": "report_lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_runs_occurrence_idx": { + "name": "scheduled_agent_runs_occurrence_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_ready_idx": { + "name": "scheduled_agent_runs_ready_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_report_idx": { + "name": "scheduled_agent_runs_report_idx", + "columns": [ + { + "expression": "report_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk": { + "name": "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk", + "tableFrom": "scheduled_agent_runs", + "tableTo": "scheduled_agent_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_runs_status_check": { + "name": "scheduled_agent_runs_status_check", + "value": "\"scheduled_agent_runs\".\"status\" IN ('queued', 'running', 'waiting_for_input', 'completed', 'dead_letter')" + }, + "scheduled_agent_runs_report_status_check": { + "name": "scheduled_agent_runs_report_status_check", + "value": "\"scheduled_agent_runs\".\"report_status\" IN ('not_ready', 'not_needed', 'pending', 'queued', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_sessions_workspace_idx": { + "name": "agent_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_membership_fkey": { + "name": "agent_sessions_membership_fkey", + "tableFrom": "agent_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": [ + "workspace_id", + "created_by_user_id" + ], + "columnsTo": [ + "workspace_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_secrets": { + "name": "encrypted_secrets", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_secrets_workspace_id_fkey": { + "name": "encrypted_secrets_workspace_id_fkey", + "tableFrom": "encrypted_secrets", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_secrets_pkey": { + "name": "encrypted_secrets_pkey", + "columns": [ + "workspace_id", + "namespace", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "encrypted_secrets_namespace_check": { + "name": "encrypted_secrets_namespace_check", + "value": "\"encrypted_secrets\".\"namespace\" = 'vault'" + } + }, + "isRLSEnabled": false + }, + "public.vault_items": { + "name": "vault_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vault_items_workspace_updated_idx": { + "name": "vault_items_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_items_workspace_id_fkey": { + "name": "vault_items_workspace_id_fkey", + "tableFrom": "vault_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "vault_items_kind_check": { + "name": "vault_items_kind_check", + "value": "\"vault_items\".\"kind\" IN ('login', 'payment', 'address', 'contact', 'phone', 'identity', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_workspace_id_fkey": { + "name": "settings_workspace_id_fkey", + "tableFrom": "settings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_pkey": { + "name": "settings_pkey", + "columns": [ + "workspace_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_key_check": { + "name": "settings_key_check", + "value": "\"settings\".\"key\" = 'gateway_model'" + } + }, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_workspace_id_fkey": { + "name": "user_profiles_workspace_id_fkey", + "tableFrom": "user_profiles", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_profiles_country_code_check": { + "name": "user_profiles_country_code_check", + "value": "\"user_profiles\".\"country_code\" IS NULL OR char_length(\"user_profiles\".\"country_code\") = 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_memberships": { + "name": "workspace_memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_memberships_workspace_id_fkey": { + "name": "workspace_memberships_workspace_id_fkey", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_memberships_pkey": { + "name": "workspace_memberships_pkey", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_memberships_role_check": { + "name": "workspace_memberships_role_check", + "value": "\"workspace_memberships\".\"role\" = 'owner'" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workstreams": { + "name": "workstreams", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_operation_id": { + "name": "last_operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workstreams_recent_idx": { + "name": "workstreams_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workstreams_workspace_id_workspaces_id_fk": { + "name": "workstreams_workspace_id_workspaces_id_fk", + "tableFrom": "workstreams", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workstreams_workspace_id_scope_key_id_pk": { + "name": "workstreams_workspace_id_scope_key_id_pk", + "columns": [ + "workspace_id", + "scope_key", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workstreams_revision_check": { + "name": "workstreams_revision_check", + "value": "\"workstreams\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 61cbfdad..ce8d0ec5 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1790807210118, "tag": "0014_chief_tigra", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1791231445659, + "tag": "0015_blooio_channel", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/db/schema/schedules.ts b/db/schema/schedules.ts index 3c6c78cd..05ec22ea 100644 --- a/db/schema/schedules.ts +++ b/db/schema/schedules.ts @@ -22,7 +22,7 @@ export const scheduledAgentJobs = pgTable( createdByUserId: text("created_by_user_id").notNull(), prompt: text("prompt").notNull(), conversationChannel: text("conversation_channel", { - enum: ["eve", "linq"], + enum: ["blooio", "eve", "linq"], }).notNull(), conversationId: text("conversation_id").notNull(), replyAnchorMessageId: text("reply_anchor_message_id"), @@ -75,7 +75,7 @@ export const scheduledAgentJobs = pgTable( }).onDelete("cascade"), check( "scheduled_agent_jobs_conversation_channel_check", - sql`${table.conversationChannel} IN ('eve', 'linq')` + sql`${table.conversationChannel} IN ('blooio', 'eve', 'linq')` ), check( "scheduled_agent_jobs_conversation_id_check", diff --git a/db/services/auth/blooio.ts b/db/services/auth/blooio.ts new file mode 100644 index 00000000..a4e943de --- /dev/null +++ b/db/services/auth/blooio.ts @@ -0,0 +1,45 @@ +import { type BlooioApiError, sendBlooioText } from "@shared/blooio/api"; +import { env } from "@shared/environment"; + +export function blooioOtpFailure(error: BlooioApiError) { + if (error.status === 429) { + return { + code: "BLOOIO_CONVERSATION_LIMITED", + message: + "Blooio is temporarily limiting messages to this number. Wait a moment, then request another code.", + }; + } + if (error.status === 404) { + return { + code: "BLOOIO_SENDER_UNAVAILABLE", + message: + "No Blooio number is available to send a sign-in code. Check BLOOIO_API_KEY and BLOOIO_FROM_NUMBER, then try again.", + }; + } + return { + code: "BLOOIO_DELIVERY_FAILED", + message: + "Blooio could not send a sign-in code. Check the API key and sending number, then try again.", + }; +} + +export async function sendBlooioSignInCode({ + idempotencyKey, + message, + to, +}: { + readonly idempotencyKey: string; + readonly message: string; + readonly to: string; +}) { + const apiKey = env.BLOOIO_API_KEY; + if (!apiKey) { + throw new Error("BLOOIO_API_KEY is not configured."); + } + await sendBlooioText({ + from: env.BLOOIO_FROM_NUMBER, + idempotencyKey, + message, + to, + }); +} diff --git a/db/services/auth/index.ts b/db/services/auth/index.ts index a3180751..08849550 100644 --- a/db/services/auth/index.ts +++ b/db/services/auth/index.ts @@ -9,6 +9,8 @@ import { account, db, session, user, verification } from "@db"; import { betterAuthBaseURL } from "@shared/environment/origin"; import { env, localPhoneAuthBypassEnabled } from "@shared/environment"; import { getInstallationSecrets } from "@db/services/installation-secrets"; +import { BlooioApiError } from "@shared/blooio/api"; +import { blooioOtpFailure, sendBlooioSignInCode } from "./blooio"; import { LinqDeliveryError, linqOtpFailure, sendLinqText } from "./linq"; import { isE164PhoneNumber } from "@shared/identity/phone-number"; @@ -133,14 +135,18 @@ export async function sendPhoneCode({ readonly code: string; readonly to: string; }) { - if (!env.LINQ_CONNECTOR) { + if (!env.LINQ_CONNECTOR && !env.BLOOIO_API_KEY) { throw new APIError("SERVICE_UNAVAILABLE", { - code: "LINQ_NOT_CONFIGURED", + code: "MESSAGING_NOT_CONFIGURED", message: - "iMessage sign-in is not configured. Attach a Linq connector to this deployment.", + "iMessage sign-in is not configured. Attach a Linq connector or set BLOOIO_API_KEY.", }); } + if (!env.LINQ_CONNECTOR) { + return sendBlooioPhoneCode({ code, to }); + } + try { await sendLinqText({ connector: env.LINQ_CONNECTOR, @@ -172,3 +178,34 @@ export async function sendPhoneCode({ }); } } + +async function sendBlooioPhoneCode({ + code, + to, +}: { + readonly code: string; + readonly to: string; +}) { + try { + await sendBlooioSignInCode({ + idempotencyKey: `auth-otp-${createHash("sha256") + .update(`${to}\u0000${code}`) + .digest("hex")}`, + message: `Local Vault Assistant sign-in code: ${code}. Expires in 5 minutes.`, + to, + }); + } catch (error) { + if (error instanceof BlooioApiError) { + const failure = blooioOtpFailure(error); + throw new APIError("BAD_GATEWAY", { + code: failure.code, + message: failure.message, + }); + } + throw new APIError("BAD_GATEWAY", { + code: "BLOOIO_UNAVAILABLE", + message: + "This deployment cannot reach Blooio. Check BLOOIO_API_KEY and try again.", + }); + } +} diff --git a/db/services/auth/verified-phone.ts b/db/services/auth/verified-phone.ts new file mode 100644 index 00000000..5f0ea2bb --- /dev/null +++ b/db/services/auth/verified-phone.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { getAuth } from "@db/services/auth"; + +const verifiedPhoneUserSchema = z.object({ + id: z.string().min(1), + phoneNumberVerified: z.literal(true), +}); + +export async function findVerifiedAuthUserIdByPhoneNumber(phoneNumber: string) { + const auth = await getAuth(); + const context = await auth.$context; + const user = await context.adapter.findOne({ + model: "user", + where: [{ field: "phoneNumber", value: phoneNumber }], + }); + const parsed = verifiedPhoneUserSchema.safeParse(user); + return parsed.success ? parsed.data.id : undefined; +} diff --git a/db/services/scheduled-agent-jobs.ts b/db/services/scheduled-agent-jobs.ts index e32c1e92..24f788ba 100644 --- a/db/services/scheduled-agent-jobs.ts +++ b/db/services/scheduled-agent-jobs.ts @@ -38,7 +38,7 @@ const exhaustedRunOutcome = { } satisfies ScheduledRunOutcome; export interface CreateScheduledAgentJob { - readonly conversationChannel: "eve" | "linq"; + readonly conversationChannel: "blooio" | "eve" | "linq"; readonly conversationId: string; readonly missedRunPolicy: "catch_up" | "run_latest"; readonly prompt: string; diff --git a/proxy.ts b/proxy.ts index 7bab9731..8115a5cb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -8,6 +8,7 @@ export async function proxy(request: NextRequest) { // The contact route verifies its signed URL; Linq downloads without cookies. pathname === "/contacts/openinstinct.vcf" || pathname.startsWith("/api/auth/") || + pathname === "/webhooks/blooio" || pathname === "/eve/v1/health" || pathname.startsWith("/internal/scheduled-run/") || pathname === "/eve/v1/dev/schedules/dynamic" diff --git a/shared/blooio/api.ts b/shared/blooio/api.ts new file mode 100644 index 00000000..331da86b --- /dev/null +++ b/shared/blooio/api.ts @@ -0,0 +1,228 @@ +import { z } from "zod"; +import { env } from "@shared/environment"; +import { isE164PhoneNumber } from "@shared/identity/phone-number"; + +const blooioApiBase = "https://api.blooio.com/v4"; + +const blooioFailureSchema = z.union([ + z + .object({ + error: z.object({ + code: z.string().optional(), + message: z.string().optional(), + }), + }) + .transform((value) => ({ + code: value.error.code, + message: value.error.message, + })), + z + .object({ + error: z.string().optional(), + message: z.string().optional(), + }) + .transform((value) => ({ + code: value.error, + message: value.message, + })), +]); + +type BlooioRequestBody = + | BlooioChatMessage + | { + readonly channel_type: "blooio"; + readonly text: string; + readonly to: string; + } + | { readonly from: string; readonly text: string; readonly to: string } + | { readonly reaction: string } + | { readonly state: "started" | "stopped" }; + +const blooioNumberSchema = z.object({ + is_active: z.boolean().optional(), + phone_number: z.string().optional(), +}); + +export class BlooioApiError extends Error { + readonly code: string | undefined; + readonly status: number; + + constructor({ + code, + message, + status, + }: { + readonly code?: string; + readonly message?: string; + readonly status: number; + }) { + super( + `Blooio request failed with HTTP ${String(status)}${ + message ? ` (${message})` : "" + }.` + ); + this.name = "BlooioApiError"; + this.code = code; + this.status = status; + } +} + +export interface BlooioChatMessage { + readonly attachments?: readonly string[]; + readonly reply_to?: string; + readonly rich_link?: { readonly url: string }; + readonly text?: string; +} + +export async function sendBlooioChatMessage( + chatId: string, + message: BlooioChatMessage, + idempotencyKey?: string +) { + await blooioPost( + `/chats/${encodeURIComponent(chatId)}/messages`, + message, + idempotencyKey + ); +} + +export async function sendBlooioText({ + from, + idempotencyKey, + message, + to, +}: { + readonly from?: string; + readonly idempotencyKey: string; + readonly message: string; + readonly to: string; +}) { + await blooioPost( + "/messages", + from + ? { from, text: message, to } + : { channel_type: "blooio", text: message, to }, + idempotencyKey + ); +} + +const tapbacks = { + exclamation: "emphasize", + heart: "love", + laugh: "laugh", + question: "question", + thumbs_down: "dislike", + thumbs_up: "like", +} as const; + +export async function sendBlooioReaction({ + chatId, + messageId, + operation, + type, +}: { + readonly chatId: string; + readonly messageId: string; + readonly operation: "add" | "remove"; + readonly type: keyof typeof tapbacks; +}) { + const reaction = `${operation === "remove" ? "-" : "+"}${tapbacks[type]}`; + await blooioPost( + `/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/reactions`, + { reaction } + ); +} + +export async function setBlooioTyping( + chatId: string, + state: "started" | "stopped" +) { + await blooioPost(`/chats/${encodeURIComponent(chatId)}/typing`, { state }); +} + +export async function markBlooioChatRead(chatId: string) { + await blooioPost(`/chats/${encodeURIComponent(chatId)}/read`); +} + +export async function readBlooioSendingNumber() { + if (env.BLOOIO_FROM_NUMBER) return env.BLOOIO_FROM_NUMBER; + if (!env.BLOOIO_API_KEY) return undefined; + try { + const body = await blooioGet("/me/numbers"); + const rows = z + .object({ data: z.array(blooioNumberSchema).optional() }) + .safeParse(body).data?.data; + return rows?.find( + (row) => + row.is_active !== false && + row.phone_number !== undefined && + isE164PhoneNumber(row.phone_number) + )?.phone_number; + } catch { + return undefined; + } +} + +async function blooioGet(path: string) { + const response = await blooioFetch("GET", path); + return readResponseJson(response); +} + +async function blooioPost( + path: string, + body?: BlooioRequestBody, + idempotencyKey?: string +) { + await blooioFetch("POST", path, body, idempotencyKey); +} + +async function blooioFetch( + method: "GET" | "POST", + path: string, + body?: BlooioRequestBody, + idempotencyKey?: string +) { + const apiKey = env.BLOOIO_API_KEY; + if (!apiKey) { + throw new BlooioApiError({ + message: "BLOOIO_API_KEY is not configured", + status: 503, + }); + } + const headers = new Headers({ + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + const response = await (method === "POST" && body !== undefined + ? sendBlooioJson(path, headers, body) + : fetch(`${blooioApiBase}${path}`, { headers, method })); + if (response.ok) return response; + const failure = blooioFailureSchema.safeParse( + await readResponseJson(response) + ); + throw new BlooioApiError({ + code: failure.success ? failure.data.code : undefined, + message: failure.success ? failure.data.message : undefined, + status: response.status, + }); +} + +function sendBlooioJson( + path: string, + headers: Headers, + body: BlooioRequestBody +) { + headers.set("Content-Type", "application/json"); + return fetch(`${blooioApiBase}${path}`, { + body: JSON.stringify(body), + headers, + method: "POST", + }); +} + +async function readResponseJson(response: Response) { + const text = await response.text(); + if (text.length === 0) return undefined; + return z.unknown().parse(JSON.parse(text)); +} diff --git a/shared/environment/env.ts b/shared/environment/env.ts index 55630156..2312df5a 100644 --- a/shared/environment/env.ts +++ b/shared/environment/env.ts @@ -84,6 +84,14 @@ export const env = createEnv({ BLOB_READ_WRITE_TOKEN: requiredValue.optional(), BLOB_STORE_ID: requiredValue.optional(), GOOGLE_CONNECTOR_UID: requiredValue.default("google/open-instinct"), + BLOOIO_API_KEY: requiredValue.optional(), + BLOOIO_FROM_NUMBER: requiredValue + .refine( + (value) => isE164PhoneNumber(value), + "BLOOIO_FROM_NUMBER must use E.164 format" + ) + .optional(), + BLOOIO_WEBHOOK_SECRET: requiredValue.optional(), LINQ_CONNECTOR: requiredValue.optional(), LINK_CLIENT_ID: requiredValue.optional(), LINK_CLIENT_SECRET: requiredValue.optional(), diff --git a/shared/environment/environment.test.ts b/shared/environment/environment.test.ts index 71e8913e..bde579b2 100644 --- a/shared/environment/environment.test.ts +++ b/shared/environment/environment.test.ts @@ -174,6 +174,27 @@ describe("environment", () => { expect(env.BLOB_STORE_ID).toBe("store_openinstinct"); }); + it("accepts a Blooio API key and E.164 sending number", async () => { + vi.stubEnv("BLOOIO_API_KEY", "bl_test"); + vi.stubEnv("BLOOIO_FROM_NUMBER", "+12025550123"); + vi.stubEnv("BLOOIO_WEBHOOK_SECRET", "whsec_test"); + + const { env } = await import("@shared/environment"); + + expect(env.BLOOIO_API_KEY).toBe("bl_test"); + expect(env.BLOOIO_FROM_NUMBER).toBe("+12025550123"); + expect(env.BLOOIO_WEBHOOK_SECRET).toBe("whsec_test"); + }); + + it("rejects a Blooio sending number outside E.164 format", async () => { + vi.stubEnv("BLOOIO_API_KEY", "bl_test"); + vi.stubEnv("BLOOIO_FROM_NUMBER", "(202) 555-0123"); + + await expect(import("@shared/environment")).rejects.toThrow( + "Invalid environment variables" + ); + }); + it("rejects a Linq phone number outside E.164 format", async () => { vi.stubEnv("LINQ_CONNECTOR", "linq/open-instinct"); vi.stubEnv("LINQ_PHONE_NUMBER", "(202) 555-0123"); diff --git a/tests/agent/channels/blooio.test.ts b/tests/agent/channels/blooio.test.ts new file mode 100644 index 00000000..e523b989 --- /dev/null +++ b/tests/agent/channels/blooio.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { blooioSignatureHeader } from "@agent/lib/blooio/signature"; + +const capture = vi.hoisted(() => ({ + findOne: + vi.fn<() => Promise<{ id: string; phoneNumberVerified: boolean } | null>>(), +})); + +vi.mock("@db/services/auth", () => ({ + getAuth: async () => ({ + $context: Promise.resolve({ adapter: { findOne: capture.findOne } }), + }), +})); + +const requiredEnvironment = { + BETTER_AUTH_SECRET: "test-auth-secret-0123456789abcdefghijklmnop", + BETTER_AUTH_URL: "https://example.com", + BLOB_READ_WRITE_TOKEN: "vercel_blob_rw_test", + BLOOIO_API_KEY: "bl_test", + BLOOIO_WEBHOOK_SECRET: "whsec_test", + DATABASE_URL: "postgresql://user:password@example.com/database", + KERNEL_API_KEY: "test-kernel-key", + SECRET_ENCRYPTION_KEY: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", +}; + +const inboundPayload = { + data: { + attachments: [], + channel_address: "+15559876543", + chat_id: "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e", + contact: { identifier: "+15551234567" }, + direction: "inbound", + message_id: "msg_xyz789", + sender: "+15551234567", + text: "Book the 7:15 show", + }, + id: "evt_test", + type: "message.received", +}; + +describe("Blooio webhooks", () => { + beforeEach(() => { + vi.resetModules(); + capture.findOne.mockReset(); + for (const [name, value] of Object.entries(requiredEnvironment)) { + vi.stubEnv(name, value); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("rejects a webhook when Blooio is not configured", async () => { + vi.stubEnv("BLOOIO_API_KEY", ""); + const { acceptBlooioWebhook } = await import("@agent/lib/blooio/inbound"); + + const accepted = await acceptBlooioWebhook(signedRequest("{}")); + + expect(accepted.ok).toBe(false); + if (accepted.ok) return; + expect(accepted.response.status).toBe(404); + }); + + it("rejects a webhook with a bad signature", async () => { + const { acceptBlooioWebhook } = await import("@agent/lib/blooio/inbound"); + const request = new Request("https://assistant.example/webhooks/blooio", { + body: JSON.stringify(inboundPayload), + headers: { "x-blooio-signature": "t=1,v1=deadbeef" }, + method: "POST", + }); + + const accepted = await acceptBlooioWebhook(request); + + expect(accepted.ok).toBe(false); + if (accepted.ok) return; + expect(accepted.response.status).toBe(401); + }); + + it("ignores outbound and non-message events", async () => { + const { acceptBlooioWebhook } = await import("@agent/lib/blooio/inbound"); + + const delivered = await acceptBlooioWebhook( + signedRequest( + JSON.stringify({ ...inboundPayload, type: "message.delivered" }) + ) + ); + const outbound = await acceptBlooioWebhook( + signedRequest( + JSON.stringify({ + ...inboundPayload, + data: { ...inboundPayload.data, direction: "outbound" }, + }) + ) + ); + + expect(delivered).toEqual({ ok: true }); + expect(outbound).toEqual({ ok: true }); + }); + + it("scopes a verified sender to that user's workspace", async () => { + capture.findOne.mockResolvedValue({ + id: "user-1", + phoneNumberVerified: true, + }); + const { acceptBlooioWebhook, blooioInboundAuth } = + await import("@agent/lib/blooio/inbound"); + const accepted = await acceptBlooioWebhook( + signedRequest(JSON.stringify(inboundPayload)) + ); + expect(accepted.ok).toBe(true); + if (!accepted.ok || !accepted.message) { + throw new Error("Expected an inbound Blooio message."); + } + + const auth = await blooioInboundAuth(accepted.message); + + expect(capture.findOne).toHaveBeenCalledExactlyOnceWith({ + model: "user", + where: [{ field: "phoneNumber", value: "+15551234567" }], + }); + expect(auth?.principalId).toBe("better-auth:user-1"); + expect(auth?.attributes).toMatchObject({ + blooioChatId: inboundPayload.data.chat_id, + blooioMessageId: inboundPayload.data.message_id, + conversationChannel: "blooio", + conversationId: `blooio:${inboundPayload.data.chat_id}`, + phoneNumber: "+15551234567", + }); + expect(auth?.attributes.workspaceId).toMatch(/^personal:[0-9a-f]{32}$/); + }); + + it("drops a sender that is not a verified user", async () => { + capture.findOne.mockResolvedValue(null); + const { acceptBlooioWebhook, blooioInboundAuth } = + await import("@agent/lib/blooio/inbound"); + const accepted = await acceptBlooioWebhook( + signedRequest(JSON.stringify(inboundPayload)) + ); + if (!accepted.ok || !accepted.message) { + throw new Error("Expected an inbound Blooio message."); + } + + await expect(blooioInboundAuth(accepted.message)).resolves.toBeUndefined(); + }); +}); + +describe("Blooio API client", () => { + beforeEach(() => { + vi.resetModules(); + for (const [name, value] of Object.entries(requiredEnvironment)) { + vi.stubEnv(name, value); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("sends a chat message with an idempotency key", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: "msg_out", chat_id: "chat_1" }), { + status: 201, + }) + ); + vi.stubGlobal("fetch", fetchMock); + const { sendBlooioChatMessage } = await import("@shared/blooio/api"); + + await sendBlooioChatMessage( + "chat_1", + { reply_to: "msg_in", text: "On it." }, + "scheduled-report:run:1" + ); + + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe("https://api.blooio.com/v4/chats/chat_1/messages"); + expect(init?.method).toBe("POST"); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer bl_test"); + expect(headers.get("Idempotency-Key")).toBe("scheduled-report:run:1"); + expect(init?.body).toBe( + JSON.stringify({ reply_to: "msg_in", text: "On it." }) + ); + }); + + it("sends a sign-in code from the configured number", async () => { + vi.stubEnv("BLOOIO_FROM_NUMBER", "+15559876543"); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + const { sendBlooioText } = await import("@shared/blooio/api"); + + await sendBlooioText({ + from: "+15559876543", + idempotencyKey: "otp-1", + message: "code", + to: "+15551234567", + }); + + expect(fetchMock.mock.calls[0]?.[1]?.body).toBe( + JSON.stringify({ + from: "+15559876543", + text: "code", + to: "+15551234567", + }) + ); + }); + + it("surfaces Blooio's error message", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { code: "channel_number_not_found", message: "No line" }, + }), + { status: 404 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + const { BlooioApiError, sendBlooioChatMessage } = + await import("@shared/blooio/api"); + let failure: InstanceType | undefined; + try { + await sendBlooioChatMessage("chat_1", { text: "Hi" }); + } catch (caught) { + if (caught instanceof BlooioApiError) failure = caught; + } + + expect(failure?.status).toBe(404); + expect(failure?.code).toBe("channel_number_not_found"); + expect(failure?.message).toContain("No line"); + }); +}); + +function signedRequest(body: string) { + return new Request("https://assistant.example/webhooks/blooio", { + body, + headers: { + "x-blooio-signature": blooioSignatureHeader("whsec_test", body), + }, + method: "POST", + }); +} diff --git a/tests/turbo-config.test.ts b/tests/turbo-config.test.ts index fc201299..1a15bb4b 100644 --- a/tests/turbo-config.test.ts +++ b/tests/turbo-config.test.ts @@ -8,6 +8,7 @@ const applicationEnvironment = [ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BLOOIO_*", "LINQ_*", "LINK_*", "STRIPE_PUBLISHABLE_KEY", diff --git a/turbo.json b/turbo.json index ae536e03..3e81ecfb 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,7 @@ "EVE_NEXT_*", "*_CONNECTOR_UID", "KERNEL_*", + "BLOOIO_*", "LINQ_*", "LINK_*", "STRIPE_PUBLISHABLE_KEY", @@ -27,6 +28,7 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BLOOIO_*", "LINQ_*", "LINK_*", "STRIPE_PUBLISHABLE_KEY", @@ -49,6 +51,7 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BLOOIO_*", "LINQ_*", "LINK_*", "STRIPE_PUBLISHABLE_KEY", @@ -78,6 +81,7 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BLOOIO_*", "LINQ_*", "LINK_*", "STRIPE_PUBLISHABLE_KEY",