From 614707880a414629a6024617631da6f1e95c1471 Mon Sep 17 00:00:00 2001
From: David Harvey <18369214+davext@users.noreply.github.com>
Date: Mon, 5 Oct 2026 16:23:22 -0400
Subject: [PATCH] Add Blooio as an optional iMessage channel.
Self-hosted installs can send and receive through the Blooio v4 API when a Linq line is not attached, including sign-in codes, Tapbacks, and scheduled replies.
---
.env.example | 6 +
README.md | 21 +
agent/channels/blooio.ts | 111 +
agent/channels/linq.ts | 17 +-
.../instructions/content/role/interactive.md | 6 +-
agent/lib/blooio/deliver.ts | 291 +++
agent/lib/blooio/inbound.ts | 168 ++
agent/lib/blooio/signature.ts | 54 +
agent/lib/reply-targets.ts | 65 +-
agent/lib/schedules/identity.ts | 5 +-
agent/lib/schedules/report.ts | 34 +-
agent/lib/schedules/tools.ts | 19 +-
agent/schedules/dynamic.ts | 3 +-
agent/tools/messaging.ts | 68 +-
.../(workspace)/_tests/workspace-linq.test.ts | 20 +-
app/(authenticated)/(workspace)/page.tsx | 34 +-
app/(authenticated)/chat/history/page.tsx | 3 +-
app/sign-in/_components/otp-form.tsx | 41 +-
app/sign-in/_tests/phone-auth-form.test.ts | 23 +
app/sign-in/page.tsx | 11 +-
db/migrations/0015_blooio_channel.sql | 2 +
db/migrations/meta/0015_snapshot.json | 2176 +++++++++++++++++
db/migrations/meta/_journal.json | 9 +-
db/schema/schedules.ts | 4 +-
db/services/auth/blooio.ts | 45 +
db/services/auth/index.ts | 43 +-
db/services/auth/verified-phone.ts | 18 +
db/services/scheduled-agent-jobs.ts | 2 +-
proxy.ts | 1 +
shared/blooio/api.ts | 228 ++
shared/environment/env.ts | 8 +
shared/environment/environment.test.ts | 21 +
tests/agent/channels/blooio.test.ts | 245 ++
tests/turbo-config.test.ts | 1 +
turbo.json | 4 +
35 files changed, 3729 insertions(+), 78 deletions(-)
create mode 100644 agent/channels/blooio.ts
create mode 100644 agent/lib/blooio/deliver.ts
create mode 100644 agent/lib/blooio/inbound.ts
create mode 100644 agent/lib/blooio/signature.ts
create mode 100644 db/migrations/0015_blooio_channel.sql
create mode 100644 db/migrations/meta/0015_snapshot.json
create mode 100644 db/services/auth/blooio.ts
create mode 100644 db/services/auth/verified-phone.ts
create mode 100644 shared/blooio/api.ts
create mode 100644 tests/agent/channels/blooio.test.ts
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) ? (
+
}
variant="surface"
>
@@ -225,6 +235,8 @@ export function ChannelsSection({
{channelAvailabilityMessage({
+ blooioConfigured,
+ blooioPhoneNumber,
browserReady,
linqConfigured,
linqPhoneNumber,
@@ -235,23 +247,35 @@ export function ChannelsSection({
}
function channelAvailabilityMessage({
+ blooioConfigured = false,
+ blooioPhoneNumber,
browserReady,
linqConfigured,
linqPhoneNumber,
}: {
+ readonly blooioConfigured?: boolean;
+ readonly blooioPhoneNumber?: string;
readonly browserReady: boolean;
readonly linqConfigured: boolean;
readonly linqPhoneNumber?: string;
}) {
+ const imessagePhone =
+ linqConfigured && linqPhoneNumber
+ ? linqPhoneNumber
+ : blooioConfigured
+ ? blooioPhoneNumber
+ : undefined;
return [
browserReady
? "WebChat is ready."
: "KERNEL_API_KEY is required to enable WebChat.",
- linqConfigured && linqPhoneNumber
- ? `iMessage opens ${linqPhoneNumber}.`
+ imessagePhone
+ ? `iMessage opens ${imessagePhone}.`
: linqConfigured
? "Linq is connected. Use its assigned line to start an iMessage."
- : "Set up Linq to enable iMessage.",
+ : blooioConfigured
+ ? "Blooio is connected. Use its sending number to start an iMessage."
+ : "Set up Linq or Blooio to enable iMessage.",
].join(" ");
}
diff --git a/app/(authenticated)/chat/history/page.tsx b/app/(authenticated)/chat/history/page.tsx
index 662cc2aa..7ad64dfc 100644
--- a/app/(authenticated)/chat/history/page.tsx
+++ b/app/(authenticated)/chat/history/page.tsx
@@ -17,7 +17,8 @@ export default async function AllChatsPage() {
const chats = await listChats(scope);
const totalUsage = combineChatUsage(chats.map((chat) => chat.usage));
const imessageSessionId = chats.find(
- (chat) => chat.channel === "channel:linq"
+ (chat) =>
+ chat.channel === "channel:linq" || chat.channel === "channel:blooio"
)?.sessionId;
return (
diff --git a/app/sign-in/_components/otp-form.tsx b/app/sign-in/_components/otp-form.tsx
index 8f8b34e0..5375b956 100644
--- a/app/sign-in/_components/otp-form.tsx
+++ b/app/sign-in/_components/otp-form.tsx
@@ -19,11 +19,15 @@ import { Input } from "@web/components/ui/input";
import { PhoneNumberField } from "./phone-field";
export function PhoneOtpAuthForm({
+ blooioPhoneNumber,
callbackUrl,
linqPhoneNumber,
+ provider = "linq",
}: {
+ readonly blooioPhoneNumber?: string;
readonly callbackUrl: string;
readonly linqPhoneNumber?: string;
+ readonly provider?: "blooio" | "linq";
}) {
const sendOtp = useMutation({
mutationFn: async (phoneNumberValue: string) => {
@@ -57,7 +61,11 @@ export function PhoneOtpAuthForm({
return (
<>
-
+ {provider === "blooio" ? (
+
+ ) : (
+
+ )}
- {!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",