diff --git a/app/api/emails/inbound/route.ts b/app/api/emails/inbound/route.ts new file mode 100644 index 00000000..6a835a88 --- /dev/null +++ b/app/api/emails/inbound/route.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; +import { handleInboundEmail } from "@/lib/emails/inbound/handleInboundEmail"; + +export const POST = async (request: NextRequest) => { + return handleInboundEmail(request); +}; diff --git a/lib/agents/generalAgent/getGeneralAgent.ts b/lib/agents/generalAgent/getGeneralAgent.ts new file mode 100644 index 00000000..1e9064eb --- /dev/null +++ b/lib/agents/generalAgent/getGeneralAgent.ts @@ -0,0 +1,65 @@ +import { stepCountIs, ToolLoopAgent } from "ai"; +import { DEFAULT_MODEL } from "@/lib/const"; +import { RoutingDecision } from "@/lib/chat/types"; +import { extractImageUrlsFromMessages } from "@/lib/messages/extractImageUrlsFromMessages"; +import { buildSystemPromptWithImages } from "@/lib/chat/buildSystemPromptWithImages"; +import { getSystemPrompt } from "@/lib/prompts/getSystemPrompt"; +import { setupToolsForRequest } from "@/lib/chat/setupToolsForRequest"; +import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; + +/** + * Gets the general agent for the chat + * + * @param body - The chat request body + * @returns The general agent + */ +export default async function getGeneralAgent(body: ChatRequestBody): Promise { + const { accountId, messages, artistId, model: bodyModel } = body; + + const accountEmails = await selectAccountEmails({ accountIds: accountId }); + const email = accountEmails[0]?.email || undefined; + + // Fetch artist instruction and knowledge base if artistId is provided + let artistInstruction: string | undefined; + let knowledgeBaseText: string | undefined; + if (artistId) { + const artistAccountInfo = await selectAccountInfo(artistId); + artistInstruction = artistAccountInfo?.instruction || undefined; + knowledgeBaseText = await getKnowledgeBaseText(artistAccountInfo?.knowledges); + } + + const accountWithDetails = await getAccountWithDetails(accountId); + const baseSystemPrompt = getSystemPrompt({ + roomId: body.roomId, + artistId, + accountId, + email, + artistInstruction, + knowledgeBaseText, + accountWithDetails, + }); + const imageUrls = extractImageUrlsFromMessages(messages); + const instructions = buildSystemPromptWithImages(baseSystemPrompt, imageUrls); + + const tools = await setupToolsForRequest(body); + const model = bodyModel || DEFAULT_MODEL; + const stopWhen = stepCountIs(111); + + const agent = new ToolLoopAgent({ + model, + instructions, + tools, + stopWhen, + }); + + return { + agent, + model, + instructions, + stopWhen, + }; +} diff --git a/lib/ai/generateText.ts b/lib/ai/generateText.ts new file mode 100644 index 00000000..0d509f89 --- /dev/null +++ b/lib/ai/generateText.ts @@ -0,0 +1,22 @@ +import { generateText as generate } from "ai"; +import { DEFAULT_MODEL } from "@/lib/const"; + +const generateText = async ({ + system, + prompt, + model, +}: { + system?: string; + prompt: string; + model?: string; +}) => { + const result = await generate({ + system, + model: model || DEFAULT_MODEL, + prompt, + }); + + return result; +}; + +export default generateText; diff --git a/lib/chat/buildSystemPromptWithImages.ts b/lib/chat/buildSystemPromptWithImages.ts new file mode 100644 index 00000000..397ba751 --- /dev/null +++ b/lib/chat/buildSystemPromptWithImages.ts @@ -0,0 +1,14 @@ +/** + * Appends image URLs to system prompt for GPT to extract for tool parameters + * + * @param basePrompt - The base system prompt + * @param imageUrls - Array of image URLs to append + * @returns System prompt with image URLs appended (if any) + */ +export function buildSystemPromptWithImages(basePrompt: string, imageUrls: string[]): string { + if (imageUrls.length === 0) { + return basePrompt; + } + + return `${basePrompt}\n\n**ATTACHED IMAGE URLS (for edit_image imageUrl parameter):**\n${imageUrls.map((url, i) => `- Image ${i}: ${url}`).join("\n")}`; +} diff --git a/lib/chat/const.ts b/lib/chat/const.ts new file mode 100644 index 00000000..804b35fb --- /dev/null +++ b/lib/chat/const.ts @@ -0,0 +1,83 @@ +export const SYSTEM_PROMPT = `You are Recoup, a friendly, sharp, and strategic AI assistant specialized in the music industry. Your purpose is to help music executives, artist teams, and self-starting artists analyze fan data, optimize marketing strategies, and improve artist growth. + +⸻ + +# Core Capabilities + 1. Artist Management: You can manage artist profiles, social media strategy, and overall marketing direction. Always use the available tools to fetch artist data when requested. + 2. Fan Analysis: You excel at analyzing fan demographics, engagement metrics, and behavioral segmentation. Always consider platform-specific metrics and patterns. + 3. Marketing Funnels: You identify conversion paths, diagnose bottlenecks, and suggest optimizations for acquisition and engagement. + 4. Social Media Strategy: You provide platform-specific recommendations tied to fan behavior and artist brand. + 5. Actionable Insights: You generate clear, data-informed, and instantly usable recommendations—not generic marketing advice. + +⸻ + +# Specialized Knowledge + • Music Industry Trends: You understand the realities of modern artist development, fan behavior, and campaign strategy. + • Platform Optimization: You are fluent in the nuances of Spotify, TikTok, Instagram, YouTube, and more. + • Fan Segmentation: You group fans based on behavior, geography, platform habits, and cultural signals. + +You do not give advice in a vacuum—you analyze everything in context. You understand the artists: + • Genre and brand + • Current career stage (emerging, breakout, legacy) + • Cultural relevance + • Position in the market + +What works for an underground rapper will not work for a legacy pop act. Your strategies adapt to the moment the artist is in. + +⸻ + +# How You Think + +You are proactive. When fan or campaign data reveals a trend, niche, or opportunity, you surface it immediately—even if the user did not ask. + +Once you identify an opportunity (e.g., growing skateboarding interest in Asia), you think through all viable monetization paths: + 1. Content series targeting that niche + 2. Influencer partnerships + 3. Brand collaborations (tailored by artist tier) + 4. Artist collaborations + 5. Tour or live event strategies + +Then you figure out how to execute: + • If it is a brand play, find regional brands likely to partner based on artist fit and engagement + • If it is an influencer angle, identify relevant creators by niche and geography + • If it is a touring strategy, suggest cities, venues, activations, and content ideas + • Bundle insights into a multi-pronged campaign blueprint + +You may suggest one or multiple directions—depending on user interest and feasibility. + +⸻ + +# How You Communicate + +You are: + • Brief by default, expanding when needed + • Conversational and collaborative—switch between telling and asking + • Warm but strategic—no fluff, just clarity and sharp thinking + • Always focused on next steps without overwhelming the user + • User-friendly—avoid technical jargon like "bytes", "root directory", "storage keys", etc. Speak naturally about creating, updating, or managing files + +# Markdown Formatting + +Keep it simple: +• Clarity first: short paragraphs; one idea per paragraph. +• Gentle structure: use H2 for sections when helpful; avoid deep nesting. +• Inline labels over bullets: for facets of one idea, use bold labels with a colon (e.g., Concept, Hook, CTA, Why) inline rather than bullets. +• Bullets only when necessary: use bullets only for multiple parallel items; otherwise keep prose. +• Subtle emphasis: bold for key terms; italics for nuance; keep headers plain. +• Minimal extras: at most one callout or a simple table if it meaningfully improves scanning; avoid decorative elements. + +⸻ + +🧷 Behavior Rules + • Be proactive when insights emerge from fan or artist data + • Always factor in the artists cultural relevance and career stage + • Avoid generic advice—ground everything in actual data and user context + • Suggest next steps clearly and strategically + • Use tools or data to expand on viable monetization paths + • Adjust depth and complexity based on the artists level (e.g., indie vs. major) + • Acknowledge limitations and adapt creatively with what you can access + • Do NOT ask for permission. Continue until you've accomplished the task + +⸻ + +You impress by being useful. Every conversation should feel like something the user could not have come up with on their own.`; diff --git a/lib/chat/createNewRoom.ts b/lib/chat/createNewRoom.ts new file mode 100644 index 00000000..7de233f9 --- /dev/null +++ b/lib/chat/createNewRoom.ts @@ -0,0 +1,54 @@ +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { generateChatTitle } from "@/lib/chat/generateChatTitle"; +import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; +import { UIMessage } from "ai"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +interface CreateNewRoomParams { + accountId: string; + roomId: string; + artistId?: string; + lastMessage: UIMessage; +} + +/** + * Creates a new room and sends a notification for a new conversation. + * + * @param params - The parameters for creating a new room + * @param params.accountId - The account ID + * @param params.roomId - The room ID + * @param params.artistId - Optional artist ID + * @param params.lastMessage - The last message from the conversation + * @returns void + */ +export async function createNewRoom({ + accountId, + roomId, + artistId, + lastMessage, +}: CreateNewRoomParams): Promise { + const latestMessageText = lastMessage.parts.find(part => part.type === "text")?.text || ""; + const conversationName = await generateChatTitle(latestMessageText); + + let email = ""; + const accountEmails = await selectAccountEmails({ accountIds: accountId }); + if (accountEmails.length > 0 && accountEmails[0].email) { + email = accountEmails[0].email; + } + + await Promise.all([ + insertRoom({ + account_id: accountId, + topic: conversationName, + artist_id: artistId || undefined, + id: roomId, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); +} diff --git a/lib/chat/filterExcludedTools.ts b/lib/chat/filterExcludedTools.ts new file mode 100644 index 00000000..3ab6ff35 --- /dev/null +++ b/lib/chat/filterExcludedTools.ts @@ -0,0 +1,20 @@ +import { ToolSet } from "ai"; + +/** + * Filters out excluded tools from a tools object + * + * @param tools - The tools object to filter + * @param excludeTools - Array of tool names to exclude + * @returns Filtered tools object with excluded tools removed + */ +export function filterExcludedTools(tools: ToolSet, excludeTools?: string[]): ToolSet { + if (!excludeTools || excludeTools.length === 0) { + return tools; + } + + const filteredTools = Object.fromEntries( + Object.entries(tools).filter(([toolName]) => !excludeTools.includes(toolName)), + ); + + return filteredTools as ToolSet; +} diff --git a/lib/chat/generateChatTitle.ts b/lib/chat/generateChatTitle.ts new file mode 100644 index 00000000..fd75d090 --- /dev/null +++ b/lib/chat/generateChatTitle.ts @@ -0,0 +1,21 @@ +import generateText from "@/lib/ai/generateText"; +import { LIGHTWEIGHT_MODEL } from "@/lib/const"; + +/** + * Generates a brief, formal title (max 20 characters) based on the given question context. + * Highlights segment names if present in the question. + * + * @param question - The question or context to generate a title for + * @returns A promise that resolves to the generated title string + */ +export async function generateChatTitle(question: string): Promise { + const response = await generateText({ + prompt: `Provide a brief title (more formal, no more than 20 characters!!!) that reflects the key elements of the given context. Do not wrap the title in quotes. + If the question is related to a segment or contains a segment name, highlight the segment name. + Context: ${question}`, + model: LIGHTWEIGHT_MODEL, + }); + + // In case model accidentally generates quotes again, remove them here + return response.text.replace(/^["']|["']$/g, ""); +} diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts new file mode 100644 index 00000000..8ebbff45 --- /dev/null +++ b/lib/chat/setupToolsForRequest.ts @@ -0,0 +1,28 @@ +import { ToolSet } from "ai"; +import { filterExcludedTools } from "./filterExcludedTools"; +import { ChatRequestBody } from "./validateChatRequest"; +import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +/** + * Sets up and filters tools for a chat request. + * This is a simplified version that returns an empty tool set. + * In a full implementation, this would load MCP tools, Google Sheets tools, etc. + * + * @param body - The chat request body + * @returns Filtered tool set ready for use + */ +export async function setupToolsForRequest(body: ChatRequestBody): Promise { + const { excludeTools } = body; + + const mcpClient = await createMCPClient({ + transport: new StreamableHTTPClientTransport(new URL("/mcp", "https://recoup-api.vercel.app")), + }); + + const mcpClientTools = (await mcpClient.tools()) as ToolSet; + + const allTools: ToolSet = { ...mcpClientTools }; + + const tools = filterExcludedTools(allTools, excludeTools); + return tools; +} diff --git a/lib/chat/types.ts b/lib/chat/types.ts new file mode 100644 index 00000000..4e20e74b --- /dev/null +++ b/lib/chat/types.ts @@ -0,0 +1,9 @@ +import { VercelToolCollection } from "@composio/vercel"; +import { type ToolSet, type StopCondition, type ToolLoopAgent } from "ai"; + +export interface RoutingDecision { + model: string; + instructions: string; + agent: ToolLoopAgent; + stopWhen?: StopCondition> | StopCondition>[] | undefined; +} diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts new file mode 100644 index 00000000..fbfb6aaf --- /dev/null +++ b/lib/chat/validateChatRequest.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const chatRequestSchema = z + .object({ + // Chat content + prompt: z.string().optional(), + messages: z.array(z.any()).default([]), + // Core routing / context fields + roomId: z.string().optional(), + accountId: z.string().optional(), + artistId: z.string().optional(), + model: z.string().optional(), + excludeTools: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + const hasMessages = Array.isArray(data.messages) && data.messages.length > 0; + const hasPrompt = typeof data.prompt === "string" && data.prompt.trim().length > 0; + + if ((hasMessages && hasPrompt) || (!hasMessages && !hasPrompt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["messages"], + }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["prompt"], + }); + } + }); + +type BaseChatRequestBody = z.infer; + +export type ChatRequestBody = BaseChatRequestBody & { + accountId: string; +}; diff --git a/lib/coinbase/getAccount.ts b/lib/coinbase/getAccount.ts index f64c43fd..fccb207f 100644 --- a/lib/coinbase/getAccount.ts +++ b/lib/coinbase/getAccount.ts @@ -12,8 +12,6 @@ export async function getAccount(accountId: string): Promise { const account = await cdp.evm.getAccount({ name: accountId }); return account; } catch { - // If account doesn't exist, create it - console.log(`[getAccount] Account ${accountId} not found, creating new account`); try { const newAccount = await cdp.evm.createAccount({ name: accountId }); return newAccount; diff --git a/lib/const.ts b/lib/const.ts index 303485e9..622d20c3 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -10,4 +10,5 @@ export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Addr export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.PAYMASTER_KEY}`; export const IMAGE_GENERATE_PRICE = "0.15"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; +export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; diff --git a/lib/emails/client.ts b/lib/emails/client.ts new file mode 100644 index 00000000..542d976c --- /dev/null +++ b/lib/emails/client.ts @@ -0,0 +1,18 @@ +import { Resend } from "resend"; + +const apiKey = process.env.RESEND_API_KEY; + +if (!apiKey) { + throw new Error("RESEND_API_KEY is not configured"); +} + +/** + * Returns a configured Resend client instance using the RESEND_API_KEY + * environment variable. Throws an error if the key + * is not configured. + * + * @returns Resend client instance + */ +export function getResendClient(): Resend { + return new Resend(apiKey); +} diff --git a/lib/emails/inbound/getEmailContent.ts b/lib/emails/inbound/getEmailContent.ts new file mode 100644 index 00000000..7c6dd3de --- /dev/null +++ b/lib/emails/inbound/getEmailContent.ts @@ -0,0 +1,21 @@ +import { getResendClient } from "@/lib/emails/client"; +import { GetReceivingEmailResponseSuccess } from "resend"; + +/** + * Fetches the full email content from Resend's Receiving API. + * Webhooks do not include the actual email body, so this must be called separately. + * + * @param emailId - The email ID from the Resend webhook event + * @returns The full email content object from Resend + * @throws Error if the email content cannot be fetched + */ +export async function getEmailContent(emailId: string): Promise { + const resend = getResendClient(); + const { data: emailContent } = await resend.emails.receiving.get(emailId); + + if (!emailContent) { + throw new Error("Failed to fetch email content from Resend"); + } + + return emailContent; +} diff --git a/lib/emails/inbound/getEmailRoomId.ts b/lib/emails/inbound/getEmailRoomId.ts new file mode 100644 index 00000000..3b860d60 --- /dev/null +++ b/lib/emails/inbound/getEmailRoomId.ts @@ -0,0 +1,22 @@ +import type { GetReceivingEmailResponseSuccess } from "resend"; +import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails"; + +/** + * Extracts the roomId from an email's references header by looking up existing memory_emails. + * + * @param emailContent - The email content from Resend's Receiving API + * @returns The roomId if found, undefined otherwise + */ +export async function getEmailRoomId( + emailContent: GetReceivingEmailResponseSuccess, +): Promise { + const references = emailContent.headers?.references; + if (!references) { + return undefined; + } + + const messageIds = JSON.parse(references); + const existingMemoryEmails = await selectMemoryEmails({ messageIds }); + + return existingMemoryEmails[0]?.memories?.room_id; +} diff --git a/lib/emails/inbound/getEmailRoomMessages.ts b/lib/emails/inbound/getEmailRoomMessages.ts new file mode 100644 index 00000000..cea1fa99 --- /dev/null +++ b/lib/emails/inbound/getEmailRoomMessages.ts @@ -0,0 +1,25 @@ +import type { ModelMessage } from "ai"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +/** + * Builds a messages array for agent.generate, including conversation history if roomId exists. + * + * @param roomId - Optional room ID to fetch existing conversation history + * @returns Array of ModelMessage objects with conversation history + */ +export async function getEmailRoomMessages(roomId: string): Promise { + let messages: ModelMessage[] = []; + + const existingMemories = await selectMemories(roomId, { ascending: true }); + if (existingMemories) { + messages = existingMemories.map(memory => { + const content = memory.content as { role: string; parts: unknown[] }; + return { + role: content.role as "user" | "assistant" | "system", + content: content.parts, + } as ModelMessage; + }); + } + + return messages; +} diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts new file mode 100644 index 00000000..cbd3b917 --- /dev/null +++ b/lib/emails/inbound/getFromWithName.ts @@ -0,0 +1,22 @@ +/** + * Gets a formatted "from" email address with a human-readable name. + * + * @param toEmails - Array of email addresses from the 'to' field + * @returns Formatted email address with display name (e.g., "Support ") + * @throws Error if no email ending with "@mail.recoupable.com" is found + */ +export function getFromWithName(toEmails: string[]): string { + // Find the first email in the 'to' array that ends with "@mail.recoupable.com" + const customFromEmail = toEmails.find(email => + email.toLowerCase().endsWith("@mail.recoupable.com"), + ); + + if (!customFromEmail) { + throw new Error("No email found ending with @mail.recoupable.com in the 'to' array"); + } + + // Extract the name part (everything before the @ sign) for a human-readable from name + const emailNameRaw = customFromEmail.split("@")[0]; + const emailName = emailNameRaw.charAt(0).toUpperCase() + emailNameRaw.slice(1); + return `${emailName} <${customFromEmail}>`; +} diff --git a/lib/emails/inbound/handleInboundEmail.ts b/lib/emails/inbound/handleInboundEmail.ts new file mode 100644 index 00000000..5529d82c --- /dev/null +++ b/lib/emails/inbound/handleInboundEmail.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateInboundEmailEvent } from "@/lib/emails/validateInboundEmailEvent"; +import { respondToInboundEmail } from "@/lib/emails/inbound/respondToInboundEmail"; + +/** + * Handles inbound email webhook events from Resend. + * For "email.received" events, sends a hard-coded reply email in the same thread + * using the Resend API and returns the Resend API response payload. + * + * @param request - The NextRequest object + * @returns A NextResponse object + */ +export async function handleInboundEmail(request: NextRequest): Promise { + const body = await request.json(); + const validatedOrError = validateInboundEmailEvent(body); + + if (validatedOrError instanceof NextResponse) { + return validatedOrError; + } + + const event = validatedOrError; + + if (event?.type === "email.received") { + return respondToInboundEmail(event); + } + + // For non-email.received events, just acknowledge with an empty payload + return NextResponse.json({}); +} diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts new file mode 100644 index 00000000..da856a01 --- /dev/null +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import type { ResendEmailReceivedEvent } from "@/lib/emails/validateInboundEmailEvent"; +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { getMessages } from "@/lib/messages/getMessages"; +import getGeneralAgent from "@/lib/agents/generalAgent/getGeneralAgent"; +import { getFromWithName } from "@/lib/emails/inbound/getFromWithName"; +import { getEmailRoomMessages } from "@/lib/emails/inbound/getEmailRoomMessages"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import { validateNewEmailMemory } from "@/lib/emails/inbound/validateNewEmailMemory"; + +/** + * Responds to an inbound email by sending a hard-coded reply in the same thread. + * Builds the reply payload and sends it via Resend. + * + * @param event - The validated Resend email received event. + * @returns A NextResponse with the Resend API response or an error response. + */ +export async function respondToInboundEmail( + event: ResendEmailReceivedEvent, +): Promise { + try { + const original = event.data; + const subject = original.subject ? `Re: ${original.subject}` : "Re: Your email"; + const messageId = original.message_id; + const to = original.from; + const toArray = [to]; + const from = getFromWithName(original.to); + + // Validate new memory and get chat request body (or early return if duplicate) + const validationResult = await validateNewEmailMemory(event); + if ("response" in validationResult) { + return validationResult.response; + } + + const { chatRequestBody } = validationResult; + const { roomId } = chatRequestBody; + + const decision = await getGeneralAgent(chatRequestBody); + const agent = decision.agent; + + const messages = await getEmailRoomMessages(roomId); + + const chatResponse = await agent.generate({ + messages, + }); + const payload = { + from, + to: toArray, + subject, + html: chatResponse.text, + headers: { + "In-Reply-To": messageId, + }, + }; + + const result = await sendEmailWithResend(payload); + + // Save the assistant response message + const assistantMessage = getMessages(chatResponse.text, "assistant")[0]; + await insertMemories({ + id: assistantMessage.id, + room_id: roomId, + content: filterMessageContentForMemories(assistantMessage), + }); + + if (result instanceof NextResponse) { + return result; + } + + return NextResponse.json(result); + } catch (error) { + console.error("[respondToInboundEmail] Failed to respond to inbound email", error); + return NextResponse.json({ error: "Internal error handling inbound email" }, { status: 500 }); + } +} diff --git a/lib/emails/inbound/validateNewEmailMemory.ts b/lib/emails/inbound/validateNewEmailMemory.ts new file mode 100644 index 00000000..4bd5ae1f --- /dev/null +++ b/lib/emails/inbound/validateNewEmailMemory.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import type { ResendEmailReceivedEvent } from "@/lib/emails/validateInboundEmailEvent"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { getMessages } from "@/lib/messages/getMessages"; +import { getEmailContent } from "@/lib/emails/inbound/getEmailContent"; +import { getEmailRoomId } from "@/lib/emails/inbound/getEmailRoomId"; +import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; +import insertMemories from "@/lib/supabase/memories/insertMemories"; +import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import { createNewRoom } from "@/lib/chat/createNewRoom"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import insertMemoryEmail from "@/lib/supabase/memory_emails/insertMemoryEmail"; + +/** + * Validates and processes a new memory from an inbound email. + * Handles room creation, memory insertion, and duplicate detection. + * + * @param event - The validated Resend email received event. + * @returns Either a ChatRequestBody and emailText for processing or a NextResponse if email was already processed. + */ +export async function validateNewEmailMemory( + event: ResendEmailReceivedEvent, +): Promise<{ chatRequestBody: ChatRequestBody; emailText: string } | { response: NextResponse }> { + const original = event.data; + const emailId = original.email_id; + const to = original.from; + + const accountEmails = await selectAccountEmails({ emails: [to] }); + if (accountEmails.length === 0) throw new Error("Account not found"); + const accountId = accountEmails[0].account_id; + + const emailContent = await getEmailContent(emailId); + const emailText = emailContent.text || emailContent.html || ""; + + const roomId = await getEmailRoomId(emailContent); + const finalRoomId = roomId || generateUUID(); + const promptMessage = getMessages(emailText)[0]; + if (!roomId) { + await createNewRoom({ + accountId, + roomId: finalRoomId, + artistId: undefined, + lastMessage: promptMessage, + }); + } + + // Insert the prompt message with emailId as the id to prevent duplicate processing + // If this email was already processed, the insert will fail with a unique constraint violation + try { + await insertMemories({ + id: emailId, + room_id: finalRoomId, + content: filterMessageContentForMemories(promptMessage), + }); + } catch (error: unknown) { + // If duplicate (unique constraint violation), return early to prevent duplicate response + if (error && typeof error === "object" && "code" in error && error.code === "23505") { + console.log(`[validateNewEmailMemory] Email ${emailId} already processed, skipping`); + return { + response: NextResponse.json({ message: "Email already processed" }, { status: 200 }), + }; + } + throw error; + } + + // Link the inbound email with the prompt message memory (using emailId as the memory id) + // The user message was already inserted with emailId as the id, so we use that directly + const messageId = original.message_id; + await insertMemoryEmail({ + email_id: emailId, + memory: emailId, + message_id: messageId, + created_at: original.created_at, + }); + + const chatRequestBody: ChatRequestBody = { + accountId, + messages: getMessages(emailText), + roomId: finalRoomId, + }; + + return { chatRequestBody, emailText }; +} diff --git a/lib/emails/isTestEmail.ts b/lib/emails/isTestEmail.ts new file mode 100644 index 00000000..8a05a352 --- /dev/null +++ b/lib/emails/isTestEmail.ts @@ -0,0 +1,4 @@ +// Returns true if the email is a test email +export const isTestEmail = (email: string): boolean => { + return email === "sweetmantech@gmail.com" || email === "sidney@recoupable.com"; +}; diff --git a/lib/emails/sendEmail.ts b/lib/emails/sendEmail.ts new file mode 100644 index 00000000..d1e92cdd --- /dev/null +++ b/lib/emails/sendEmail.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { getResendClient } from "@/lib/emails/client"; +import { CreateEmailOptions, CreateEmailRequestOptions, CreateEmailResponseSuccess } from "resend"; + +/** + * Sends an email via Resend using a shared client. + * Returns a NextResponse on error, or the Resend data object on success. + * + * @param payload - The email payload to send via Resend. + * @param options - The options to pass to the Resend API. + * @returns A NextResponse on error, or an object containing the Resend data on success. + */ +export async function sendEmailWithResend( + payload: CreateEmailOptions, + options?: CreateEmailRequestOptions, +): Promise { + const resend = getResendClient(); + + const { data, error } = await resend.emails.send(payload, options); + + if (error) { + console.error("Error sending email via Resend:", error); + return NextResponse.json( + { + error: "Failed to send email", + details: error, + }, + { status: 502 }, + ); + } + + return data; +} diff --git a/lib/emails/validateInboundEmailEvent.ts b/lib/emails/validateInboundEmailEvent.ts new file mode 100644 index 00000000..136adbb7 --- /dev/null +++ b/lib/emails/validateInboundEmailEvent.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +const resendAttachmentSchema = z.object({ + id: z.string(), + filename: z.string(), + content_type: z.string(), + content_disposition: z.string(), + content_id: z.string(), +}); + +const resendEmailDataSchema = z.object({ + email_id: z.string(), + created_at: z.string(), + from: z.string(), + to: z.array(z.string()), + bcc: z.array(z.string()), + cc: z.array(z.string()), + message_id: z.string(), + subject: z.string(), + attachments: z.array(resendAttachmentSchema), +}); + +export const resendEmailReceivedEventSchema = z.object({ + type: z.literal("email.received"), + created_at: z.string(), + data: resendEmailDataSchema, +}); + +export type ResendEmailReceivedEvent = z.infer; + +/** + * Validates the inbound Resend email webhook event against the expected schema. + * + * @param body - The parsed JSON body of the inbound request. + * @returns A NextResponse with an error if validation fails, or the validated event if validation passes. + */ +export function validateInboundEmailEvent(body: unknown): NextResponse | ResendEmailReceivedEvent { + const validationResult = resendEmailReceivedEventSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid inbound email event", + errors: validationResult.error.issues.map(err => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return validationResult.data; +} diff --git a/lib/files/getKnowledgeBaseText.ts b/lib/files/getKnowledgeBaseText.ts new file mode 100644 index 00000000..36186277 --- /dev/null +++ b/lib/files/getKnowledgeBaseText.ts @@ -0,0 +1,40 @@ +/** + * Processes knowledge base files and extracts text content. + * Filters for text-based file types and fetches their content from URLs. + * + * @param knowledges - Array of knowledge base entries with name, url, and type properties + * @returns Combined text content from all text-based knowledge files, or undefined if no valid files found + */ +export async function getKnowledgeBaseText(knowledges: unknown): Promise { + if (!knowledges || !Array.isArray(knowledges) || knowledges.length === 0) { + return undefined; + } + + const textTypes = new Set(["text/plain", "text/markdown", "application/json", "text/csv"]); + + interface Knowledge { + name?: string; + url?: string; + type?: string; + } + + const knowledgeFiles = knowledges as Knowledge[]; + + const texts = await Promise.all( + knowledgeFiles + .filter(f => f.type && textTypes.has(f.type) && f.url) + .map(async f => { + try { + const res = await fetch(f.url!); + if (!res.ok) return ""; + const content = await res.text(); + return `--- ${f.name || "Unknown"} ---\n${content}`; + } catch { + return ""; + } + }), + ); + + const combinedText = texts.filter(Boolean).join("\n\n"); + return combinedText || undefined; +} diff --git a/lib/mcp/tools/youtube/registerGetYouTubeRevenueTool.ts b/lib/mcp/tools/youtube/registerGetYouTubeRevenueTool.ts index 63c22245..149b4321 100644 --- a/lib/mcp/tools/youtube/registerGetYouTubeRevenueTool.ts +++ b/lib/mcp/tools/youtube/registerGetYouTubeRevenueTool.ts @@ -53,7 +53,6 @@ export function registerGetYouTubeRevenueTool(server: McpServer): void { } const tokens = await selectYouTubeTokens(args.artist_account_id); - console.log("tokens", tokens); if (!tokens) { return getToolResultError( @@ -71,8 +70,6 @@ export function registerGetYouTubeRevenueTool(server: McpServer): void { const defaultDates = getDefaultDateRange(); const startDate = args.startDate || defaultDates.startDate; const endDate = args.endDate || defaultDates.endDate; - console.log("startDate", startDate); - console.log("endDate", endDate); const analyticsResult = await queryAnalyticsReports({ accessToken: tokens.access_token, @@ -81,7 +78,6 @@ export function registerGetYouTubeRevenueTool(server: McpServer): void { endDate, metrics: "estimatedRevenue", }); - console.log("analyticsResult", analyticsResult); return getToolResultSuccess({ success: true, status: "success", diff --git a/lib/messages/extractImageUrlsFromMessages.ts b/lib/messages/extractImageUrlsFromMessages.ts new file mode 100644 index 00000000..33627616 --- /dev/null +++ b/lib/messages/extractImageUrlsFromMessages.ts @@ -0,0 +1,28 @@ +import { UIMessage } from "ai"; + +/** + * Extracts image URLs from user messages with file attachments + * + * @param messages - Array of UI messages from the chat + * @returns Array of image URLs found in message attachments + */ +export function extractImageUrlsFromMessages(messages: UIMessage[]): string[] { + const imageUrls: string[] = []; + + for (const message of messages) { + if (message.parts) { + for (const part of message.parts) { + if ( + part.type === "file" && + part.mediaType?.startsWith("image/") && + typeof part.url === "string" && + part.url.trim() !== "" + ) { + imageUrls.push(part.url); + } + } + } + } + + return imageUrls; +} diff --git a/lib/messages/filterMessageContentForMemories.ts b/lib/messages/filterMessageContentForMemories.ts new file mode 100644 index 00000000..a2b63990 --- /dev/null +++ b/lib/messages/filterMessageContentForMemories.ts @@ -0,0 +1,15 @@ +import { Json } from "@/types/database.types"; +import { UIMessage } from "ai"; + +const filterMessageContentForMemories = (message: UIMessage): Json => { + return { + role: message.role, + parts: message.parts, + content: message.parts + .filter(part => part.type === "text") + .map(part => (part.type === "text" ? part.text : "")) + .join(""), + } as Json; +}; + +export default filterMessageContentForMemories; diff --git a/lib/messages/getMessages.ts b/lib/messages/getMessages.ts new file mode 100644 index 00000000..ac52a779 --- /dev/null +++ b/lib/messages/getMessages.ts @@ -0,0 +1,24 @@ +import { UIMessage } from "ai"; +import generateUUID from "@/lib/uuid/generateUUID"; + +/** + * Converts a string message to an array of properly formatted message objects + * Can be used to generate initial messages for chat components + * + * @param content - The text content of the message + * @param role - Optional role for the message (defaults to "user") + * @returns An array of properly formatted message objects + */ +export function getMessages(content?: string, role: string = "user"): UIMessage[] { + if (!content) { + return []; + } + + return [ + { + id: generateUUID(), + role: role as "user" | "assistant" | "system", + parts: [{ type: "text", text: content }], + }, + ]; +} diff --git a/lib/messages/validateMessages.ts b/lib/messages/validateMessages.ts new file mode 100644 index 00000000..83604c63 --- /dev/null +++ b/lib/messages/validateMessages.ts @@ -0,0 +1,18 @@ +import { UIMessage } from "ai"; + +/** + * Validates the messages and returns the last message and the valid messages. + * + * @param messages - The messages to validate + * @returns The last message and the valid messages + */ +export function validateMessages(messages: UIMessage[]) { + if (!messages.length) { + throw new Error("No messages provided"); + } + + return { + lastMessage: messages[messages.length - 1], + validMessages: messages.filter(m => m.parts.find(part => part.type === "text")?.text?.length), + }; +} diff --git a/lib/prompts/getSystemPrompt.ts b/lib/prompts/getSystemPrompt.ts new file mode 100644 index 00000000..3d29afef --- /dev/null +++ b/lib/prompts/getSystemPrompt.ts @@ -0,0 +1,130 @@ +import { SYSTEM_PROMPT } from "@/lib/chat/const"; +import { AccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; + +/** + * Generates a system prompt for the chat + * + * @param params - The parameters for the system prompt + * @param params.roomId - The ID of the room + * @param params.artistId - The ID of the artist + * @param params.accountId - The ID of the account + * @param params.email - The email of the account + * @param params.knowledgeBaseText - The knowledge base text + * @param params.artistInstruction - The artist instruction + * @param params.conversationName - The name of the conversation + * @param params.accountWithDetails - The account with details + * @returns The system prompt + */ +export function getSystemPrompt({ + roomId, + artistId, + accountId, + email, + knowledgeBaseText, + artistInstruction, + conversationName = "New conversation", + accountWithDetails, +}: { + roomId?: string; + artistId?: string; + accountId: string; + email?: string; + knowledgeBaseText?: string; + artistInstruction?: string; + conversationName?: string; + accountWithDetails?: AccountWithDetails; +}): string { + let systemPrompt = `${SYSTEM_PROMPT} + + **IMPORTANT CONTEXT VALUES (use these exact values in tools):** + - account_id: ${accountId || "Unknown"} (use this for ALL tools that require account_id parameter) + - artist_account_id: ${artistId} + - active_account_email: ${email || "Unknown"} + - active_conversation_id: ${roomId || "No ID"} + - active_conversation_name: ${conversationName || "No Chat Name"} + + **IMAGE EDITING INSTRUCTIONS:** + When the user asks to edit an image (e.g., "add glasses", "make it darker", "add a hat"): + + **WHICH IMAGE TO EDIT:** + 1. Check conversation history for the most recent edit_image tool result + 2. If found: Use the imageUrl from that result (e.g., "https://v3b.fal.media/files/...") + 3. If NOT found OR user says "original": Use the URL from "ATTACHED IMAGE URLS" section below + 4. This ensures edits build on each other (glasses → then hat keeps the glasses) + + **HOW TO CALL THE TOOL:** + - IMMEDIATELY call edit_image (don't explain first) + - imageUrl: The URL determined from steps above (NEVER use "attachment://") + - prompt: Describe the edit clearly (e.g., "add sunglasses to the person") + - account_id: Use the account_id value shown above + - DO NOT ask the user for any information - you have everything you need`; + + if (accountWithDetails) { + let userSection = ` + +-----CURRENT USER CONTEXT----- +This is information about the person currently using this application (the human you're talking to): + +Name: ${accountWithDetails.name || "Not provided"} +Email: ${accountWithDetails.email || email || "Not provided"}`; + + if ( + accountWithDetails.job_title || + accountWithDetails.role_type || + accountWithDetails.company_name || + accountWithDetails.organization + ) { + userSection += ` + +Professional Context:`; + if (accountWithDetails.job_title) + userSection += ` +- Job Title: ${accountWithDetails.job_title}`; + if (accountWithDetails.role_type) + userSection += ` +- Role Type: ${accountWithDetails.role_type}`; + if (accountWithDetails.company_name) + userSection += ` +- Company: ${accountWithDetails.company_name}`; + if (accountWithDetails.organization) + userSection += ` +- Organization: ${accountWithDetails.organization}`; + } + + if (accountWithDetails.instruction) { + userSection += ` + +User's Custom Instructions & Preferences: +${accountWithDetails.instruction}`; + } + + userSection += ` +-----END USER CONTEXT-----`; + + systemPrompt = `${systemPrompt}${userSection}`; + } + + if (artistInstruction) { + systemPrompt = `${systemPrompt} + +-----SELECTED ARTIST/WORKSPACE CONTEXT----- +This is information about the artist/workspace the user is currently working with: + +Custom Instructions for this Artist: +${artistInstruction} +-----END ARTIST/WORKSPACE CONTEXT-----`; + } + + if (knowledgeBaseText) { + systemPrompt = `${systemPrompt} + +-----ARTIST/WORKSPACE KNOWLEDGE BASE----- +Additional context and knowledge for the selected artist/workspace: +${knowledgeBaseText} +-----END ARTIST/WORKSPACE KNOWLEDGE BASE-----`; + } + + return systemPrompt; +} + +export default getSystemPrompt; diff --git a/lib/songs/queueRedisSongs.ts b/lib/songs/queueRedisSongs.ts index 9c301539..48b38684 100644 --- a/lib/songs/queueRedisSongs.ts +++ b/lib/songs/queueRedisSongs.ts @@ -23,7 +23,6 @@ export async function queueRedisSongs(songs: SongWithSpotify[]): Promise { jobId: `isrc-${song.isrc}`, // Unique job ID to prevent duplicates priority: 1, // Higher priority for ISRC processing }); - console.log(`[Queue] Queued ISRC job: ${song.isrc}`); } catch (error) { console.error(`[Queue] Failed to queue ISRC job for ${song.isrc}:`, error); } diff --git a/lib/supabase/account_emails/selectAccountEmails.ts b/lib/supabase/account_emails/selectAccountEmails.ts index 630e2b8c..81f05530 100644 --- a/lib/supabase/account_emails/selectAccountEmails.ts +++ b/lib/supabase/account_emails/selectAccountEmails.ts @@ -2,23 +2,45 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Select account_emails by email addresses + * Select account_emails by email addresses and/or account IDs * * @param params - The parameters for the query - * @param params.emails - Array of email addresses to query + * @param params.emails - Optional array of email addresses to query + * @param params.accountIds - Optional array of account IDs to query * @returns Array of account_emails rows */ export default async function selectAccountEmails({ emails, + accountIds, }: { - emails: string[]; + emails?: string[]; + accountIds?: string | string[]; }): Promise[]> { - if (!Array.isArray(emails) || emails.length === 0) return []; + let query = supabase.from("account_emails").select("*"); - const { data, error } = await supabase.from("account_emails").select("*").in("email", emails); + // Build query based on provided parameters + const ids = accountIds ? (Array.isArray(accountIds) ? accountIds : [accountIds]) : []; + const hasEmails = Array.isArray(emails) && emails.length > 0; + const hasAccountIds = ids.length > 0; + + // If neither parameter is provided, return empty array + if (!hasEmails && !hasAccountIds) { + return []; + } + + // Apply filters + if (hasEmails) { + query = query.in("email", emails); + } + + if (hasAccountIds) { + query = query.in("account_id", ids); + } + + const { data, error } = await query; if (error) { - console.error("Error fetching account_emails by emails:", error); + console.error("Error fetching account_emails:", error); return []; } diff --git a/lib/supabase/accounts/getAccountWithDetails.ts b/lib/supabase/accounts/getAccountWithDetails.ts index e89ec75d..1f70bd33 100644 --- a/lib/supabase/accounts/getAccountWithDetails.ts +++ b/lib/supabase/accounts/getAccountWithDetails.ts @@ -1,22 +1,14 @@ import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; /** * Flattened account object with related details merged in. * Only the first row from each related table is included. */ -export interface AccountWithDetails { - id: string; - created_at: string; - // Fields from account_info (first row only) - name?: string; - image?: string; - // Fields from account_emails (first row only) - email?: string; - // Fields from account_wallets (first row only) - address?: string; - // Allow additional fields from the base account or relations - [key: string]: unknown; -} +export type AccountWithDetails = Tables<"accounts"> & + Partial> & + Partial> & + Partial>; /** * Retrieves an account with its related details (info, emails, wallets). @@ -28,9 +20,7 @@ export interface AccountWithDetails { * @param accountId - The account's ID (UUID) * @returns Flattened account object with related data, or null if not found/error */ -export async function getAccountWithDetails( - accountId: string, -): Promise { +export async function getAccountWithDetails(accountId: string): Promise { const { data: account, error } = await supabase .from("accounts") .select("*, account_info(*), account_emails(*), account_wallets(*)") @@ -50,4 +40,3 @@ export async function getAccountWithDetails( ...(account.account_wallets?.[0] || {}), }; } - diff --git a/lib/supabase/memories/insertMemories.ts b/lib/supabase/memories/insertMemories.ts new file mode 100644 index 00000000..f83d5b83 --- /dev/null +++ b/lib/supabase/memories/insertMemories.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type Memory = Tables<"memories">; + +type InsertMemoryParams = Pick; + +/** + * Inserts a new memory into the memories table + * + * @param params - The parameters for the memory + * @param params.id - The ID of the memory + * @param params.room_id - The ID of the room + * @param params.content - The content of the memory + * @returns The inserted memory, or null if the insert fails + */ +export default async function insertMemories(params: InsertMemoryParams): Promise { + const { data, error } = await supabase.from("memories").insert(params).select().single(); + + if (error) { + console.error("Error creating memory:", error); + throw error; + } + + return data; +} diff --git a/lib/supabase/memories/selectMemories.ts b/lib/supabase/memories/selectMemories.ts new file mode 100644 index 00000000..95ea547d --- /dev/null +++ b/lib/supabase/memories/selectMemories.ts @@ -0,0 +1,41 @@ +import { Tables } from "@/types/database.types"; +import supabase from "../serverClient"; + +/** + * Selects the memories table in Supabase + * + * @param roomId - The room ID to query memories for + * @param options - Options for the query (ascending order and limit) + * @param options.ascending - Whether to order the results by ascending order + * @param options.limit - The limit of the results + * @returns Supabase query result with memories data + */ +export default async function selectMemories( + roomId: string, + options?: { + ascending?: boolean; + limit?: number; + }, +): Promise[] | null> { + const ascending = options?.ascending ?? false; + const limit = options?.limit; + + let query = supabase + .from("memories") + .select("*") + .eq("room_id", roomId) + .order("updated_at", { ascending }); + + if (limit) { + query = query.limit(limit); + } + + const { data, error } = await query; + + if (error) { + console.error("Error selecting memories:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/memory_emails/insertMemoryEmail.ts b/lib/supabase/memory_emails/insertMemoryEmail.ts new file mode 100644 index 00000000..ea2828f8 --- /dev/null +++ b/lib/supabase/memory_emails/insertMemoryEmail.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +type MemoryEmail = Tables<"memory_emails">; + +type InsertMemoryEmailParams = TablesInsert<"memory_emails">; + +/** + * Inserts a new memory_email record to link an email with a memory. + * + * @param params - The parameters for the memory_email + * @param params.email_id - The email ID from Resend + * @param params.memory - The memory ID + * @param params.message_id - The message ID from Resend + * @param params.id - Optional ID for the memory_email record + * @param params.created_at - Optional created_at timestamp + * @returns The inserted memory_email record + */ +export default async function insertMemoryEmail( + params: InsertMemoryEmailParams, +): Promise { + const { data, error } = await supabase.from("memory_emails").insert(params).select().single(); + + if (error) { + console.error("Error creating memory_email:", error); + throw error; + } + + return data; +} diff --git a/lib/supabase/memory_emails/selectMemoryEmails.ts b/lib/supabase/memory_emails/selectMemoryEmails.ts new file mode 100644 index 00000000..da46737d --- /dev/null +++ b/lib/supabase/memory_emails/selectMemoryEmails.ts @@ -0,0 +1,44 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type MemoryEmail = Tables<"memory_emails">; +type Memory = Tables<"memories">; + +export type MemoryEmailWithMemory = MemoryEmail & { + memories: Memory | null; +}; + +interface SelectMemoryEmailsParams { + messageIds: string[]; +} + +/** + * Selects memory_emails by message IDs, joined with the memories table. + * + * @param params - The parameters for the query + * @param params.messageIds - Array of message IDs to query + * @returns Array of memory_emails rows with joined memory data + */ +export default async function selectMemoryEmails({ + messageIds, +}: SelectMemoryEmailsParams): Promise { + if (!Array.isArray(messageIds) || messageIds.length === 0) { + return []; + } + + const { data, error } = await supabase + .from("memory_emails") + .select("*, memories(*)") + .in("message_id", messageIds); + + if (error) { + console.error("Error fetching memory_emails:", error); + return []; + } + + if (!data) { + return []; + } + + return data; +} diff --git a/lib/supabase/rooms/insertRoom.ts b/lib/supabase/rooms/insertRoom.ts new file mode 100644 index 00000000..38c20d17 --- /dev/null +++ b/lib/supabase/rooms/insertRoom.ts @@ -0,0 +1,14 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +type Room = Tables<"rooms">; + +type CreateRoomParams = Pick; + +export const insertRoom = async (params: CreateRoomParams): Promise => { + const { data, error } = await supabase.from("rooms").insert(params).select("*").single(); + + if (error) throw error; + + return data; +}; diff --git a/lib/telegram/sendNewConversationNotification.ts b/lib/telegram/sendNewConversationNotification.ts new file mode 100644 index 00000000..a2e7746a --- /dev/null +++ b/lib/telegram/sendNewConversationNotification.ts @@ -0,0 +1,33 @@ +import { sendMessage } from "./sendMessage"; +import { isTestEmail } from "@/lib/emails/isTestEmail"; + +interface NewConversationNotificationParams { + email: string; + accountId: string; + conversationId: string; + topic: string; + firstMessage?: string; +} + +export const sendNewConversationNotification = async ({ + email, + accountId, + conversationId, + topic, + firstMessage, +}: NewConversationNotificationParams) => { + // Skip sending for test emails + if (isTestEmail(email)) return; + + try { + const formattedMessage = `🗣️ New Conversation Started +From: ${email || accountId} +Chat ID: ${conversationId} +Topic: ${topic} +Time: ${new Date().toISOString()}${firstMessage ? `\n\nFirst Message:\n${firstMessage}` : ""}`; + + await sendMessage(formattedMessage); + } catch (error) { + console.error("Error sending new conversation notification:", error); + } +}; diff --git a/lib/uuid/generateUUID.ts b/lib/uuid/generateUUID.ts new file mode 100644 index 00000000..99f5513e --- /dev/null +++ b/lib/uuid/generateUUID.ts @@ -0,0 +1,22 @@ +/** + * Generates a UUID v4 compatible string + * Uses a cryptographically secure method when available (browser) + * Falls back to a non-secure method for other environments + * + * @returns A randomly generated UUID string + */ +export function generateUUID(): string { + // Use crypto.randomUUID when available (modern browsers) + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback implementation for older browsers or Node.js environments + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export default generateUUID; diff --git a/lib/youtube/queryAnalyticsReports.ts b/lib/youtube/queryAnalyticsReports.ts index ff518f03..c2886f40 100644 --- a/lib/youtube/queryAnalyticsReports.ts +++ b/lib/youtube/queryAnalyticsReports.ts @@ -33,13 +33,11 @@ export async function queryAnalyticsReports({ }): Promise { // Get user's channel ID first const youtube = createYouTubeAPIClient(accessToken, refreshToken); - console.log("youtube", youtube); const channelResponse = await youtube.channels.list({ part: ["id"], mine: true, }); - console.log("channelResponse", channelResponse); if (!channelResponse.data.items || channelResponse.data.items.length === 0) { throw new Error( @@ -48,7 +46,6 @@ export async function queryAnalyticsReports({ } const channelId = channelResponse.data.items[0].id; - console.log("channelId", channelId); if (!channelId) { throw new Error( "Unable to retrieve channel ID. Please ensure your YouTube account is properly set up.", @@ -58,7 +55,6 @@ export async function queryAnalyticsReports({ // Create YouTube Analytics API client const ytAnalytics = createYouTubeAnalyticsClient(accessToken, refreshToken); - console.log("ytAnalytics", ytAnalytics); // Query analytics reports for the specified date range const response = await ytAnalytics.reports.query({ ids: `channel==${channelId}`, @@ -68,11 +64,9 @@ export async function queryAnalyticsReports({ dimensions: "day", sort: "day", }); - console.log("response", response); // Process the response const rows = response.data.rows || []; - console.log("rows", rows); if (rows.length === 0) { throw new Error( "No revenue data found. This could mean your channel is not monetized or you don't have the required Analytics scope permissions. Please ensure your channel is eligible for monetization and you've granted Analytics permissions.", diff --git a/package.json b/package.json index 5e9a8d49..6d0dfcef 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint:check": "eslint . --ext .ts" }, "dependencies": { + "@ai-sdk/mcp": "^0.0.12", "@coinbase/cdp-sdk": "^1.38.6", "@coinbase/x402": "^0.7.3", "@composio/core": "^0.2.6", @@ -33,6 +34,7 @@ "node-telegram-bot-api": "^0.66.0", "react": "^19.2.1", "react-dom": "^19.2.1", + "resend": "^6.6.0", "sharp": "^0.34.5", "viem": "^2.21.26", "x402-fetch": "^0.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f6b0073..5e2db238 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/mcp': + specifier: ^0.0.12 + version: 0.0.12(zod@4.1.13) '@coinbase/cdp-sdk': specifier: ^1.38.6 version: 1.38.6(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -68,6 +71,9 @@ importers: react-dom: specifier: ^19.2.1 version: 19.2.1(react@19.2.1) + resend: + specifier: ^6.6.0 + version: 6.6.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -141,6 +147,18 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@0.0.12': + resolution: {integrity: sha512-hyf31U2CmgGexqOLgLfno525pjbqidJLu9pU+XcEwW/PkMcfTFuRq1iD3wbqtAmURRW0qJITiKV+in1B4I23gA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@3.0.19': + resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.0-beta.38': resolution: {integrity: sha512-m1klVKT8KntgEIxHnSGEzdhdn48Uf/w6fe5rPWGnpTd+P532mADV7BC4txNYp40ziS5Z9VV1g1wn2xRScwEeRw==} engines: {node: '>=18'} @@ -157,6 +175,10 @@ packages: effect: optional: true + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.0-beta.20': resolution: {integrity: sha512-+JqXbqHHtucRsMFGidygRyftpjX1GD2r4cG3Sh2URZ6g8IaN8k4loXNh2gX92dd4YjlYYn3eTHp3R8dDJfX25Q==} engines: {node: '>=18'} @@ -1508,8 +1530,8 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@supabase/auth-js@2.86.0': resolution: {integrity: sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==} @@ -1599,6 +1621,9 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/phoenix@1.6.6': resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -4260,6 +4285,15 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@6.6.0: + resolution: {integrity: sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4581,6 +4615,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.76.1: + resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==} + svix@1.82.0: resolution: {integrity: sha512-K2M7yFSzuwJVPxi2/I5R9STofIQ8gO4PZ+ptZ5RB+zhTNHO12UtYk+uuuA2wIQ4wCj3GYY1WhvKYDeYqptIwKg==} @@ -5151,13 +5188,31 @@ snapshots: - arktype - effect + '@ai-sdk/mcp@0.0.12(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) + pkce-challenge: 5.0.1 + zod: 4.1.13 + + '@ai-sdk/provider-utils@3.0.19(zod@4.1.13)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.13 + '@ai-sdk/provider-utils@4.0.0-beta.38(zod@4.1.13)': dependencies: '@ai-sdk/provider': 3.0.0-beta.20 - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 zod: 4.1.13 + '@ai-sdk/provider@2.0.0': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.0-beta.20': dependencies: json-schema: 0.4.0 @@ -7237,7 +7292,7 @@ snapshots: '@stablelib/base64@1.0.1': {} - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@supabase/auth-js@2.86.0': dependencies: @@ -7391,6 +7446,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + '@types/phoenix@1.6.6': {} '@types/react-dom@19.2.3(@types/react@19.2.7)': @@ -10848,6 +10907,10 @@ snapshots: requires-port@1.0.0: {} + resend@6.6.0: + dependencies: + svix: 1.76.1 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11283,6 +11346,15 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.76.1: + dependencies: + '@stablelib/base64': 1.0.1 + '@types/node': 22.19.3 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + url-parse: 1.5.10 + uuid: 10.0.0 + svix@1.82.0: dependencies: '@stablelib/base64': 1.0.1 diff --git a/types/database.types.ts b/types/database.types.ts index d8299430..339fdb25 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -1921,6 +1921,38 @@ export type Database = { }, ]; }; + memory_emails: { + Row: { + created_at: string; + email_id: string; + id: string; + memory: string; + message_id: string; + }; + Insert: { + created_at?: string; + email_id: string; + id?: string; + memory: string; + message_id: string; + }; + Update: { + created_at?: string; + email_id?: string; + id?: string; + memory?: string; + message_id?: string; + }; + Relationships: [ + { + foreignKeyName: "memory_emails_memory_fkey"; + columns: ["memory"]; + isOneToOne: false; + referencedRelation: "memories"; + referencedColumns: ["id"]; + }, + ]; + }; notifications: { Row: { account_id: string;