diff --git a/lib/ai/generateArray.ts b/lib/ai/generateArray.ts new file mode 100644 index 000000000..c33c459fe --- /dev/null +++ b/lib/ai/generateArray.ts @@ -0,0 +1,35 @@ +import { generateObject } from "ai"; +import { DEFAULT_MODEL } from "@/lib/const"; +import { z } from "zod"; + +export interface GenerateArrayResult { + segmentName: string; + fans: string[]; +} + +const generateArray = async ({ + system, + prompt, +}: { + system?: string; + prompt: string; +}): Promise => { + const result = await generateObject({ + model: DEFAULT_MODEL, + system, + prompt, + output: "array", + schema: z.object({ + segmentName: z.string().describe("Segment name."), + fans: z + .array(z.string()) + .describe( + "For each Segment Name return an array of fan_social_id included in the segment. Do not make these up. Only use the actual fan_social_id provided in the fan data prompt input.", + ), + }), + }); + + return result.object; +}; + +export default generateArray; diff --git a/lib/artist/createKnowledgeBase.ts b/lib/artist/createKnowledgeBase.ts new file mode 100644 index 000000000..54e29f3af --- /dev/null +++ b/lib/artist/createKnowledgeBase.ts @@ -0,0 +1,68 @@ +import { uploadTextToArweave } from "@/lib/arweave/uploadTextToArweave"; +import { arweaveGatewayUrl } from "@/lib/arweave/arweaveGatewayUrl"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { updateAccountInfo } from "@/lib/supabase/account_info/updateAccountInfo"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import type { Knowledge } from "./updateArtistProfile"; + +/** + * Creates a knowledge base entry for an artist by uploading text to Arweave + * and appending it to the artist's knowledges array. + * + * @param artistId - The artist account ID + * @param knowledgeBaseText - The text content to add to the knowledge base + * @returns The created knowledge entry with Arweave URL + */ +export async function createKnowledgeBase( + artistId: string, + knowledgeBaseText: string, +): Promise { + if (!knowledgeBaseText || knowledgeBaseText.trim().length === 0) { + throw new Error("Knowledge base text is required"); + } + + // Upload text to Arweave + const arweaveUrl = await uploadTextToArweave(knowledgeBaseText); + const fetchableUrl = arweaveGatewayUrl(arweaveUrl); + + // Generate a name from the first line or a default name + const firstLine = knowledgeBaseText.split("\n")[0].trim(); + const name = firstLine.length > 0 && firstLine.length <= 100 ? firstLine : "Knowledge Base Entry"; + + // Create the knowledge entry + const newKnowledge: Knowledge = { + url: fetchableUrl || arweaveUrl, + name, + type: "text/plain", + }; + + // Get current account_info + const accountInfo = await selectAccountInfo(artistId); + + // Get existing knowledges or initialize empty array + const existingKnowledges: Knowledge[] = accountInfo?.knowledges + ? (accountInfo.knowledges as unknown as Knowledge[]) + : []; + + // Append new knowledge (deduplicate by URL) + const knowledgeMap = new Map(); + existingKnowledges.forEach(k => { + knowledgeMap.set(k.url, k); + }); + knowledgeMap.set(newKnowledge.url, newKnowledge); + const updatedKnowledges = Array.from(knowledgeMap.values()); + + // Update account_info + if (accountInfo) { + await updateAccountInfo(artistId, { + knowledges: updatedKnowledges as unknown as typeof accountInfo.knowledges, + }); + } else { + await insertAccountInfo({ + account_id: artistId, + knowledges: updatedKnowledges as unknown as typeof accountInfo.knowledges, + }); + } + + return newKnowledge; +} diff --git a/lib/artist/getArtistSegments.ts b/lib/artist/getArtistSegments.ts index dbd0e0cb4..f4267ae2e 100644 --- a/lib/artist/getArtistSegments.ts +++ b/lib/artist/getArtistSegments.ts @@ -1,5 +1,5 @@ import { selectArtistSegmentsCount } from "@/lib/supabase/artist_segments/selectArtistSegmentsCount"; -import { selectArtistSegments } from "@/lib/supabase/artist_segments/selectArtistSegments"; +import { selectArtistSegmentsWithDetails } from "@/lib/supabase/artist_segments/selectArtistSegmentsWithDetails"; import type { ArtistSegmentsQuery } from "@/lib/artist/validateArtistSegmentsQuery"; import { mapArtistSegments, type MappedArtistSegment } from "@/lib/artist/mapArtistSegments"; @@ -38,7 +38,7 @@ export const getArtistSegments = async ({ }; } - const data = await selectArtistSegments(artist_account_id, offset, limit); + const data = await selectArtistSegmentsWithDetails(artist_account_id, offset, limit); if (!data) { return { diff --git a/lib/artist/mapArtistSegments.ts b/lib/artist/mapArtistSegments.ts index c68a569cd..b6e40af8c 100644 --- a/lib/artist/mapArtistSegments.ts +++ b/lib/artist/mapArtistSegments.ts @@ -1,4 +1,4 @@ -import type { SegmentQueryResult } from "@/lib/supabase/artist_segments/selectArtistSegments"; +import type { SegmentQueryResult } from "@/lib/supabase/artist_segments/selectArtistSegmentsWithDetails"; export interface MappedArtistSegment { id: string; diff --git a/lib/artist/updateArtistProfile.ts b/lib/artist/updateArtistProfile.ts new file mode 100644 index 000000000..9c7cbd0f0 --- /dev/null +++ b/lib/artist/updateArtistProfile.ts @@ -0,0 +1,98 @@ +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { updateAccount } from "@/lib/supabase/accounts/updateAccount"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import { updateAccountInfo } from "@/lib/supabase/account_info/updateAccountInfo"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import type { Tables } from "@/types/database.types"; + +export type Knowledge = { + url: string; + name: string; + type: string; +}; + +export type ArtistProfile = Tables<"accounts"> & Tables<"account_info">; + +/** + * Updates an artist profile (account and account_info). + * All fields are optional except artistId. + * + * @param artistId - The artist account ID + * @param image - Optional profile image URL + * @param name - Optional display name + * @param instruction - Optional custom instructions + * @param label - Optional label/role + * @param knowledges - Optional array of knowledge objects + * @returns The updated artist profile + */ +export async function updateArtistProfile( + artistId: string, + image: string, + name: string, + instruction: string, + label: string, + knowledges: Knowledge[] | null, +): Promise { + // Fetch current account + const currentAccounts = await selectAccounts(artistId); + const currentAccount = currentAccounts[0]; + if (!currentAccount) { + throw new Error("Artist does not exist"); + } + + // Update account name if provided + if (name) { + const accountUpdate = { name }; + const updatedAccount = await updateAccount(artistId, accountUpdate); + if (!updatedAccount) { + throw new Error("Failed to update account"); + } + } + + // Get or create account_info + const accountInfo = await selectAccountInfo(artistId); + + if (accountInfo) { + // Update existing account_info + const infoUpdate: Partial = {}; + if (image) infoUpdate.image = image; + if (instruction) infoUpdate.instruction = instruction; + + if (knowledges !== null && knowledges !== undefined) { + // Deduplicate knowledges by URL + const uniqueMap = new Map(knowledges.map(k => [k.url, k])); + infoUpdate.knowledges = Array.from(uniqueMap.values()); + } + + if (label !== undefined) { + infoUpdate.label = label === "" ? null : label; + } + + await updateAccountInfo(artistId, infoUpdate); + } else { + // Create new account_info + await insertAccountInfo({ + image: image || null, + instruction: instruction || null, + knowledges: knowledges || null, + label: label === "" ? null : label || null, + account_id: artistId, + }); + } + + // Fetch and return the latest account and account_info + const [updatedAccounts, updatedAccountInfo] = await Promise.all([ + selectAccounts(artistId), + selectAccountInfo(artistId), + ]); + + const account = updatedAccounts[0]; + if (!account || !updatedAccountInfo) { + throw new Error("Failed to fetch updated artist profile"); + } + + return { + ...updatedAccountInfo, + ...account, + }; +} diff --git a/lib/artist/updateArtistSocials.ts b/lib/artist/updateArtistSocials.ts new file mode 100644 index 000000000..12fc25c71 --- /dev/null +++ b/lib/artist/updateArtistSocials.ts @@ -0,0 +1,68 @@ +import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; +import { getUsernameFromProfileUrl } from "@/lib/socials/getUsernameFromProfileUrl"; +import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; +import { deleteAccountSocial } from "@/lib/supabase/account_socials/deleteAccountSocial"; +import { insertAccountSocial } from "@/lib/supabase/account_socials/insertAccountSocial"; +import { selectSocials } from "@/lib/supabase/socials/selectSocials"; +import { insertSocials } from "@/lib/supabase/socials/insertSocials"; +import type { AccountSocialWithSocial } from "@/lib/supabase/account_socials/selectAccountSocials"; + +/** + * Updates artist socials by replacing existing socials for each platform type. + * + * @param artistId - The artist account ID + * @param profileUrls - Record mapping platform types to profile URLs + * @returns Array of updated account socials with joined social data + */ +export async function updateArtistSocials( + artistId: string, + profileUrls: Record, +): Promise { + // Get current account socials (with no limit to get all) + const accountSocials = await selectAccountSocials(artistId, 0, 10000); + + // Process each platform type + const profilePromises = Object.entries(profileUrls).map(async ([type, value]) => { + const socials = value ? await selectSocials({ profile_url: value }) : null; + const social = socials && socials.length > 0 ? socials[0] : null; + const existingSocial = (accountSocials || []).find( + (account_social: AccountSocialWithSocial) => + getSocialPlatformByLink(account_social.social?.profile_url || "") === type, + ); + + // Delete existing social for this platform type if it exists + if (existingSocial && existingSocial.social?.id) { + await deleteAccountSocial(artistId, existingSocial.social.id); + } + + // Insert new social if URL provided + if (value) { + if (social) { + // Social already exists, check if account_social relationship exists + const existing = (accountSocials || []).find( + (as: AccountSocialWithSocial) => as.social_id === social.id, + ); + if (!existing) { + await insertAccountSocial(artistId, social.id); + } + } else { + // Create new social record + const newSocials = await insertSocials([ + { + username: getUsernameFromProfileUrl(value), + profile_url: value, + }, + ]); + if (newSocials.length > 0) { + await insertAccountSocial(artistId, newSocials[0].id); + } + } + } + }); + + await Promise.all(profilePromises); + + // Return the updated account socials + const updated = await selectAccountSocials(artistId, 0, 10000); + return updated || []; +} diff --git a/lib/arweave/uploadTextToArweave.ts b/lib/arweave/uploadTextToArweave.ts new file mode 100644 index 000000000..291549a4d --- /dev/null +++ b/lib/arweave/uploadTextToArweave.ts @@ -0,0 +1,13 @@ +import { uploadToArweave } from "./uploadToArweave"; + +/** + * Uploads text content to Arweave as a text/plain file. + * + * @param text - The text content to upload + * @returns The Arweave URL (ar://...) + */ +export async function uploadTextToArweave(text: string): Promise { + const textBuffer = Buffer.from(text, "utf-8"); + const transaction = await uploadToArweave(textBuffer, "text/plain"); + return `ar://${transaction.id}`; +} diff --git a/lib/files/generateAndStoreTxtFile.ts b/lib/files/generateAndStoreTxtFile.ts new file mode 100644 index 000000000..331505dd0 --- /dev/null +++ b/lib/files/generateAndStoreTxtFile.ts @@ -0,0 +1,68 @@ +import { uploadTextToArweave } from "@/lib/arweave/uploadTextToArweave"; +import uploadJsonToArweave from "@/lib/arweave/uploadJsonToArweave"; + +export interface GeneratedTxtResponse { + txt: { + base64Data: string; + mimeType: string; + }; + arweave?: string | null; + metadataArweave?: string | null; +} + +/** + * Generates and stores a TXT file by uploading it to Arweave. + * Creates both the text file and metadata JSON on Arweave. + * + * @param contents - The text contents to store + * @returns The generated TXT response with Arweave URLs + */ +export async function generateAndStoreTxtFile(contents: string): Promise { + if (!contents) { + throw new Error("Contents are required"); + } + + const mimeType = "text/plain"; + + // Upload the TXT file to Arweave + let txtFile: string | null = null; + try { + txtFile = await uploadTextToArweave(contents); + } catch (arweaveError) { + console.error("Error uploading TXT to Arweave:", arweaveError); + // Continue even if Arweave upload fails + } + + const image = "ar://EXwe2peizXKxjUMop6W-JPflC5sWyeQR1y0JiRDwUB0"; + + // Upload metadata JSON to Arweave + let metadataArweave: string | null = null; + try { + const transaction = await uploadJsonToArweave({ + image, + animation_url: txtFile || undefined, + content: { + mime: mimeType, + uri: txtFile || "", + }, + description: contents, + name: contents.substring(0, 100), // Limit name length + }); + metadataArweave = `ar://${transaction.id}`; + } catch (metadataError) { + console.error("Error uploading metadata to Arweave:", metadataError); + } + + // Encode contents to base64 for response + const base64Data = Buffer.from(contents, "utf-8").toString("base64"); + + return { + txt: { + base64Data, + mimeType, + }, + arweave: txtFile, + metadataArweave, + }; +} + diff --git a/lib/mcp/getCallToolResult.ts b/lib/mcp/getCallToolResult.ts new file mode 100644 index 000000000..1da12049c --- /dev/null +++ b/lib/mcp/getCallToolResult.ts @@ -0,0 +1,22 @@ +import { TextContent } from "@modelcontextprotocol/sdk/types.js"; + +export type CallToolResult = { + content: TextContent[]; +}; + +/** + * Creates the base structure for an MCP tool result. + * + * @param text - The text content to include in the response + * @returns An MCP tool response structure + */ +export function getCallToolResult(text: string): CallToolResult { + return { + content: [ + { + type: "text" as const, + text, + }, + ], + }; +} diff --git a/lib/mcp/getToolResultError.ts b/lib/mcp/getToolResultError.ts new file mode 100644 index 000000000..a3240dda6 --- /dev/null +++ b/lib/mcp/getToolResultError.ts @@ -0,0 +1,16 @@ +import { CallToolResult, getCallToolResult } from "./getCallToolResult"; + +/** + * Creates a standardized error response for MCP tools. + * + * @param message - The error message to return + * @returns An MCP tool response with error content + */ +export function getToolResultError(message: string): CallToolResult { + return getCallToolResult( + JSON.stringify({ + success: false, + message, + }), + ); +} diff --git a/lib/mcp/getToolResultSuccess.ts b/lib/mcp/getToolResultSuccess.ts new file mode 100644 index 000000000..961df744a --- /dev/null +++ b/lib/mcp/getToolResultSuccess.ts @@ -0,0 +1,11 @@ +import { CallToolResult, getCallToolResult } from "./getCallToolResult"; + +/** + * Creates a standardized success response for MCP tools. + * + * @param data - The data to return + * @returns An MCP tool response with success content + */ +export function getToolResultSuccess(data: unknown): CallToolResult { + return getCallToolResult(JSON.stringify(data)); +} diff --git a/lib/mcp/tools/artistSocials/index.ts b/lib/mcp/tools/artistSocials/index.ts new file mode 100644 index 000000000..6e31ff254 --- /dev/null +++ b/lib/mcp/tools/artistSocials/index.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerGetArtistSocialsTool } from "./registerGetArtistSocialsTool"; +import { registerUpdateArtistSocialsTool } from "./registerUpdateArtistSocialsTool"; + +/** + * Registers all artist socials-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllArtistSocialsTools = (server: McpServer): void => { + registerGetArtistSocialsTool(server); + registerUpdateArtistSocialsTool(server); +}; diff --git a/lib/mcp/tools/registerGetArtistSocialsTool.ts b/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts similarity index 88% rename from lib/mcp/tools/registerGetArtistSocialsTool.ts rename to lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts index 804d007d9..54d216ea2 100644 --- a/lib/mcp/tools/registerGetArtistSocialsTool.ts +++ b/lib/mcp/tools/artistSocials/registerGetArtistSocialsTool.ts @@ -4,6 +4,7 @@ import { ArtistSocialsQuery, artistSocialsQuerySchema, } from "@/lib/artist/validateArtistSocialsQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "get_artist_socials" tool on the MCP server. @@ -21,7 +22,8 @@ export function registerGetArtistSocialsTool(server: McpServer): void { }, async (args: ArtistSocialsQuery) => { const result = await getArtistSocials(args); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + return getToolResultSuccess(result); }, ); } + diff --git a/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts b/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts new file mode 100644 index 000000000..898e1dc59 --- /dev/null +++ b/lib/mcp/tools/artistSocials/registerUpdateArtistSocialsTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { updateArtistSocials } from "@/lib/artist/updateArtistSocials"; +import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const updateArtistSocialsSchema = z.object({ + artistId: z + .string() + .min(1, "Artist account ID is required") + .describe("The artist's account ID to update socials for."), + urls: z + .array(z.string()) + .describe( + "An array of social profile URLs to associate with the artist. The platform will be inferred automatically.", + ), +}); + +type UpdateArtistSocialsArgs = z.infer; + +/** + * Registers the "update_artist_socials" tool on the MCP server. + * Updates the artist_socials records for an artist. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerUpdateArtistSocialsTool(server: McpServer): void { + server.registerTool( + "update_artist_socials", + { + description: `Update the artist_socials records for an artist. Provide the artistId and an array of social profile URLs. The tool will infer the platform for each URL and update the artist's socials accordingly.`, + inputSchema: updateArtistSocialsSchema, + }, + async (args: UpdateArtistSocialsArgs) => { + try { + // Map each URL to its platform type + const profileUrls: Record = {}; + for (const url of args.urls) { + const platform = getSocialPlatformByLink(url); + if (platform && platform !== "NONE") { + profileUrls[platform] = url; + } + } + + const socials = await updateArtistSocials(args.artistId, profileUrls); + + const response = { + success: true, + message: "Artist socials updated successfully.", + socials, + }; + + return getToolResultSuccess(response); + } catch (error) { + console.error("Error updating artist socials:", error); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to update artist socials."; + return getToolResultError(errorMessage); + } + }, + ); +} + diff --git a/lib/mcp/tools/catalogs/registerGetCatalogSongsTool.ts b/lib/mcp/tools/catalogs/registerGetCatalogSongsTool.ts index 64e9d1a28..4af5a91ac 100644 --- a/lib/mcp/tools/catalogs/registerGetCatalogSongsTool.ts +++ b/lib/mcp/tools/catalogs/registerGetCatalogSongsTool.ts @@ -4,6 +4,7 @@ import { getCatalogSongsQuerySchema, type GetCatalogSongsQuery, } from "@/lib/catalog/validateGetCatalogSongsQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "select_catalog_songs" tool on the MCP server. @@ -41,14 +42,7 @@ export function registerGetCatalogSongsTool(server: McpServer): void { criteria: args.criteria, }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; + return getToolResultSuccess(response); }, ); } diff --git a/lib/mcp/tools/catalogs/registerGetCatalogsTool.ts b/lib/mcp/tools/catalogs/registerGetCatalogsTool.ts index 2ee114a26..fdea636b8 100644 --- a/lib/mcp/tools/catalogs/registerGetCatalogsTool.ts +++ b/lib/mcp/tools/catalogs/registerGetCatalogsTool.ts @@ -4,6 +4,7 @@ import { type GetCatalogsQuery, } from "@/lib/catalog/validateGetCatalogsQuery"; import { selectAccountCatalogs } from "@/lib/supabase/account_catalogs/selectAccountCatalogs"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "select_catalogs" tool on the MCP server. * Retrieves catalogs associated with the current account for ANY music recommendation request. @@ -28,15 +29,7 @@ export function registerGetCatalogsTool(server: McpServer): void { }, async (args: GetCatalogsQuery) => { const data = await selectAccountCatalogs({ accountIds: [args.account_id] }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(data), - }, - ], - }; + return getToolResultSuccess(data); }, ); } diff --git a/lib/mcp/tools/catalogs/registerInsertCatalogSongsTool.ts b/lib/mcp/tools/catalogs/registerInsertCatalogSongsTool.ts index 15d66436b..1518f0b8e 100644 --- a/lib/mcp/tools/catalogs/registerInsertCatalogSongsTool.ts +++ b/lib/mcp/tools/catalogs/registerInsertCatalogSongsTool.ts @@ -4,6 +4,7 @@ import { insertCatalogSongsQuerySchema, type InsertCatalogSongsQuery, } from "@/lib/catalog/validateInsertCatalogSongsQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "insert_catalog_songs" tool on the MCP server. @@ -33,15 +34,7 @@ export function registerInsertCatalogSongsTool(server: McpServer): void { }, async (args: InsertCatalogSongsQuery) => { const response = await insertCatalogSongsFunction(args.songs); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; + return getToolResultSuccess(response); }, ); } diff --git a/lib/mcp/tools/files/index.ts b/lib/mcp/tools/files/index.ts new file mode 100644 index 000000000..56669b752 --- /dev/null +++ b/lib/mcp/tools/files/index.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerCreateKnowledgeBaseTool } from "./registerCreateKnowledgeBaseTool"; +import { registerGenerateTxtFileTool } from "./registerGenerateTxtFileTool"; + +/** + * Registers all file-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllFileTools = (server: McpServer): void => { + registerCreateKnowledgeBaseTool(server); + registerGenerateTxtFileTool(server); +}; diff --git a/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts new file mode 100644 index 000000000..b086155b3 --- /dev/null +++ b/lib/mcp/tools/files/registerCreateKnowledgeBaseTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createKnowledgeBase } from "@/lib/artist/createKnowledgeBase"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const createKnowledgeBaseSchema = z.object({ + artistId: z + .string() + .min(1, "Artist account ID is required") + .describe( + "The artist_account_id to add the knowledge base to. If not provided, check system prompt for the active artist_account_id.", + ), + knowledgeBaseText: z + .string() + .min(1, "Knowledge base text is required") + .describe("Text to add to the knowledge base"), +}); + +type CreateKnowledgeBaseArgs = z.infer; + +/** + * Registers the "create_knowledge_base" tool on the MCP server. + * Adds a knowledge base file to an artist by uploading text to Arweave and appending it to the artist's knowledges. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerCreateKnowledgeBaseTool(server: McpServer): void { + server.registerTool( + "create_knowledge_base", + { + description: `Adds a knowledge base file to the active artist by generating a text file and appending it to the list of existing knowledge bases. The text will be uploaded to Arweave for permanent storage.`, + inputSchema: createKnowledgeBaseSchema, + }, + async (args: CreateKnowledgeBaseArgs) => { + try { + const knowledge = await createKnowledgeBase(args.artistId, args.knowledgeBaseText); + + const response = { + success: true, + knowledge, + message: "Knowledge base text prepared for creation and storage.", + }; + + return getToolResultSuccess(response); + } catch (error) { + console.error("Error creating knowledge base:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to create knowledge base", + ); + } + }, + ); +} + diff --git a/lib/mcp/tools/files/registerGenerateTxtFileTool.ts b/lib/mcp/tools/files/registerGenerateTxtFileTool.ts new file mode 100644 index 000000000..c757d3246 --- /dev/null +++ b/lib/mcp/tools/files/registerGenerateTxtFileTool.ts @@ -0,0 +1,60 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { generateAndStoreTxtFile } from "@/lib/files/generateAndStoreTxtFile"; +import { getFetchableUrl } from "@/lib/arweave/getFetchableUrl"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const generateTxtFileSchema = z.object({ + contents: z.string().min(1, "Contents are required").describe("The contents of the TXT file"), +}); + +type GenerateTxtFileArgs = z.infer; + +/** + * Registers the "generate_txt_file" tool on the MCP server. + * Creates a downloadable TXT file from provided contents and stores it on Arweave. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGenerateTxtFileTool(server: McpServer): void { + server.registerTool( + "generate_txt_file", + { + description: + "Create a downloadable TXT file from provided contents. The file will be stored onchain with Arweave and metadata will be created.", + inputSchema: generateTxtFileSchema, + }, + async (args: GenerateTxtFileArgs) => { + try { + const result = await generateAndStoreTxtFile(args.contents); + + const arweaveUrl = getFetchableUrl(result.arweave || null); + + const response = { + success: true, + arweaveUrl, + metadataArweave: result.metadataArweave ? getFetchableUrl(result.metadataArweave) : null, + message: "TXT file successfully generated and stored on Arweave.", + }; + + return getToolResultSuccess(response); + } catch (error) { + console.error("Error generating TXT file:", error); + + // Format helpful error messages based on common issues + let errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + + if (errorMessage.includes("API key")) { + errorMessage = "API key is missing or invalid. Please check your environment variables."; + } else if (errorMessage.includes("content policy")) { + errorMessage = "Your contents may violate content policy. Please try different contents."; + } else if (errorMessage.includes("rate limit")) { + errorMessage = "Rate limit exceeded. Please try again later."; + } + + return getToolResultError(`Failed to generate TXT file. ${errorMessage}`); + } + }, + ); +} diff --git a/lib/mcp/tools/images/registerEditImageTool.ts b/lib/mcp/tools/images/registerEditImageTool.ts index e7a00cfd2..f0e0a0511 100644 --- a/lib/mcp/tools/images/registerEditImageTool.ts +++ b/lib/mcp/tools/images/registerEditImageTool.ts @@ -4,6 +4,8 @@ import { type GenerateAndProcessImageResult, } from "@/lib/image/generateAndProcessImage"; import { editImageQuerySchema, type EditImageQuery } from "@/lib/image/validateEditImageQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { CallToolResult } from "@/lib/mcp/getCallToolResult"; /** * Registers the "edit_image" tool on the MCP server. @@ -18,7 +20,7 @@ export function registerEditImageTool(server: McpServer): void { inputSchema: editImageQuerySchema, description: "Edit existing images.", }, - async (args: EditImageQuery): Promise<{ content: Array<{ type: "text"; text: string }> }> => { + async (args: EditImageQuery): Promise => { const result: GenerateAndProcessImageResult = await generateAndProcessImage( args.prompt, args.account_id, @@ -30,14 +32,7 @@ export function registerEditImageTool(server: McpServer): void { ], ); - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/images/registerGenerateImageTool.ts b/lib/mcp/tools/images/registerGenerateImageTool.ts index 363874f5c..0f150c08d 100644 --- a/lib/mcp/tools/images/registerGenerateImageTool.ts +++ b/lib/mcp/tools/images/registerGenerateImageTool.ts @@ -7,6 +7,7 @@ import { generateImageQuerySchema, type GenerateImageQuery, } from "@/lib/image/validateGenerateImageQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "generate_image" tool on the MCP server. @@ -27,14 +28,7 @@ export function registerGenerateImageTool(server: McpServer): void { args.account_id, ); - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 7cd56e952..e4581b5ad 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -1,11 +1,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerGetArtistSocialsTool } from "./registerGetArtistSocialsTool"; import { registerGetLocalTimeTool } from "./registerGetLocalTimeTool"; import { registerAllTaskTools } from "./tasks"; import { registerAllImageTools } from "./images"; import { registerAllCatalogTools } from "./catalogs"; import { registerAllSora2Tools } from "./sora2"; +import { registerAllSpotifyTools } from "./spotify"; import { registerContactTeamTool } from "./registerContactTeamTool"; +import { registerUpdateAccountInfoTool } from "./registerUpdateAccountInfoTool"; +import { registerAllArtistSocialsTools } from "./artistSocials"; +import { registerSearchWebTool } from "./registerSearchWebTool"; +import { registerAllFileTools } from "./files"; +import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; +import { registerAllYouTubeTools } from "./youtube"; /** * Registers all MCP tools on the server. @@ -14,11 +20,17 @@ import { registerContactTeamTool } from "./registerContactTeamTool"; * @param server - The MCP server instance to register tools on. */ export const registerAllTools = (server: McpServer): void => { - registerGetArtistSocialsTool(server); - registerGetLocalTimeTool(server); - registerAllTaskTools(server); - registerAllImageTools(server); + registerAllArtistSocialsTools(server); registerAllCatalogTools(server); + registerAllFileTools(server); + registerAllImageTools(server); registerAllSora2Tools(server); + registerAllSpotifyTools(server); + registerAllTaskTools(server); registerContactTeamTool(server); + registerGetLocalTimeTool(server); + registerSearchWebTool(server); + registerUpdateAccountInfoTool(server); + registerCreateSegmentsTool(server); + registerAllYouTubeTools(server); }; diff --git a/lib/mcp/tools/registerContactTeamTool.ts b/lib/mcp/tools/registerContactTeamTool.ts index 2e81369b2..a5f6bd2f4 100644 --- a/lib/mcp/tools/registerContactTeamTool.ts +++ b/lib/mcp/tools/registerContactTeamTool.ts @@ -4,6 +4,7 @@ import { contactTeamQuerySchema, type ContactTeamQuery, } from "@/lib/contact/validateContactTeamQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "contact_team" tool on the MCP server. @@ -21,16 +22,7 @@ export function registerContactTeamTool(server: McpServer): void { }, async (args: ContactTeamQuery) => { const result = await contactTeam(args); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } - diff --git a/lib/mcp/tools/registerCreateSegmentsTool.ts b/lib/mcp/tools/registerCreateSegmentsTool.ts new file mode 100644 index 000000000..bece16a7f --- /dev/null +++ b/lib/mcp/tools/registerCreateSegmentsTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createSegments } from "@/lib/segments/createSegments"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const createSegmentsSchema = z.object({ + artist_account_id: z + .string() + .min(1, "Artist account ID is required") + .describe( + "The artist_account_id to create segments for. If not provided, check system prompt for the active artist_account_id.", + ), + prompt: z + .string() + .min(1, "Prompt is required") + .describe( + "The prompt to use for generating segment names. This should be generated by the system if not provided.", + ), +}); + +export type CreateSegmentsArgs = z.infer; + +/** + * Registers the "create_segments" tool on the MCP server. + * Creates segments by analyzing fan data and generating segment names. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerCreateSegmentsTool(server: McpServer): void { + server.registerTool( + "create_segments", + { + description: + "Create segments by analyzing fan data and generating segment names. This tool fetches all fans for an artist, generates segment names based on the provided prompt, and saves the segments to the database. If required information is missing (social accounts or fan data), the tool will provide step-by-step instructions to gather the missing prerequisites using other available tools.", + inputSchema: createSegmentsSchema, + }, + async (args: CreateSegmentsArgs) => { + try { + const result = await createSegments(args); + + if (result.success) { + return getToolResultSuccess(result); + } + + return getToolResultError(result.message || "Failed to create segments"); + } catch (error) { + console.error("Error creating segments:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to create segments", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/registerGetLocalTimeTool.ts b/lib/mcp/tools/registerGetLocalTimeTool.ts index 2be82876c..8d5db480d 100644 --- a/lib/mcp/tools/registerGetLocalTimeTool.ts +++ b/lib/mcp/tools/registerGetLocalTimeTool.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getLocalTime } from "@/lib/time/getLocalTime"; import { GetLocalTimeQuery, getLocalTimeQuerySchema } from "@/lib/time/validateGetLocalTimeQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "get_local_time" tool on the MCP server. @@ -18,7 +19,7 @@ export function registerGetLocalTimeTool(server: McpServer): void { }, async (args: GetLocalTimeQuery) => { const result = await getLocalTime(args); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/registerSearchWebTool.ts b/lib/mcp/tools/registerSearchWebTool.ts new file mode 100644 index 000000000..8de65d2c0 --- /dev/null +++ b/lib/mcp/tools/registerSearchWebTool.ts @@ -0,0 +1,65 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const searchWebSchema = z.object({ + query: z.string().min(1, "Search query is required").describe("The search query"), + max_results: z + .number() + .min(1) + .max(20) + .optional() + .describe("Maximum number of results (1-20, default: 10)"), + country: z + .string() + .length(2) + .optional() + .describe("ISO country code for regional results (e.g., 'US', 'GB')"), +}); + +type SearchWebArgs = z.infer; + +/** + * Registers the "search_web" tool on the MCP server. + * Searches the web for real-time information using Perplexity API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerSearchWebTool(server: McpServer): void { + server.registerTool( + "search_web", + { + description: + "DEFAULT TOOL: Use this tool FIRST for any information you're unsure about. " + + "NEVER respond with 'I can't find X', 'I don't have access to X', or 'I do not know X'. " + + "This tool searches the web for real-time information and should be your go-to resource. " + + "Returns ranked web search results with titles, URLs, and content snippets.", + inputSchema: searchWebSchema, + }, + async (args: SearchWebArgs) => { + try { + const searchResponse = await searchPerplexity({ + query: args.query, + max_results: args.max_results || 10, + max_tokens_per_page: 1024, + ...(args.country && { country: args.country }), + }); + + const formattedResults = formatSearchResultsAsMarkdown(searchResponse); + + return getToolResultSuccess({ + results: searchResponse.results, + formatted: formattedResults, + }); + } catch (error) { + console.error("Error searching the web:", error); + return getToolResultError( + error instanceof Error ? `Search failed: ${error.message}` : "Failed to search the web", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/registerUpdateAccountInfoTool.ts b/lib/mcp/tools/registerUpdateAccountInfoTool.ts new file mode 100644 index 000000000..9520ead1f --- /dev/null +++ b/lib/mcp/tools/registerUpdateAccountInfoTool.ts @@ -0,0 +1,80 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { updateArtistProfile } from "@/lib/artist/updateArtistProfile"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const updateAccountInfoSchema = z.object({ + artistId: z + .string() + .min(1, "Artist account ID is required") + .describe( + "The artist_account_id to update. If not provided, check system prompt for the active artist_account_id.", + ), + image: z.string().optional().describe("(Optional) The new profile image URL for the artist."), + name: z.string().optional().describe("(Optional) The display name for the artist."), + instruction: z + .string() + .optional() + .describe("(Optional) Custom instructions for the artist's account."), + label: z.string().optional().describe("(Optional) The label or role for the artist."), + knowledges: z + .array( + z.object({ + url: z.string(), + name: z.string(), + type: z + .string() + .describe( + 'MIME type of the file, e.g., "text/plain" for TXT files, "application/pdf" for PDFs', + ), + }), + ) + .optional() + .describe( + "(Optional) Array of knowledge objects ({ url, name, type }) to be stored as the knowledge base or notes for the artist. The 'type' field must be a valid MIME type (e.g., 'text/plain' for TXT files).", + ), +}); + +type UpdateAccountInfoArgs = z.infer; + +/** + * Registers the "update_account_info" tool on the MCP server. + * Updates the account_info record for an artist. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerUpdateAccountInfoTool(server: McpServer): void { + server.registerTool( + "update_account_info", + { + description: `Update the account_info record for an artist. All fields are optional except for artistId. This tool is used to update the artist's profile image, name, instructions, label, and knowledge base. If artistId is not provided, use the active artist_account_id from the system prompt.`, + inputSchema: updateAccountInfoSchema, + }, + async (args: UpdateAccountInfoArgs) => { + try { + const artistProfile = await updateArtistProfile( + args.artistId, + args.image || "", + args.name || "", + args.instruction || "", + args.label || "", + args.knowledges || null, + ); + + const response = { + success: true, + artistProfile, + message: `Account info updated successfully for account_id: ${artistProfile.id}`, + }; + + return getToolResultSuccess(response); + } catch (error) { + console.error("Error updating account info:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to update account info"; + return getToolResultError(errorMessage); + } + }, + ); +} diff --git a/lib/mcp/tools/sora2/registerGenerateVideoTool.ts b/lib/mcp/tools/sora2/registerGenerateVideoTool.ts index 62e3213d3..d4e5c0b24 100644 --- a/lib/mcp/tools/sora2/registerGenerateVideoTool.ts +++ b/lib/mcp/tools/sora2/registerGenerateVideoTool.ts @@ -4,6 +4,7 @@ import { generateVideoQuerySchema, type GenerateVideoQuery, } from "@/lib/video/validateGenerateVideoQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "generate_sora_2_video" tool on the MCP server. @@ -28,15 +29,7 @@ export function registerGenerateVideoTool(server: McpServer): void { }, async (args: GenerateVideoQuery) => { const result = await generateVideoFunction(args); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/sora2/registerRetrieveVideoContentTool.ts b/lib/mcp/tools/sora2/registerRetrieveVideoContentTool.ts index 84d997d40..7328f0136 100644 --- a/lib/mcp/tools/sora2/registerRetrieveVideoContentTool.ts +++ b/lib/mcp/tools/sora2/registerRetrieveVideoContentTool.ts @@ -4,6 +4,7 @@ import { retrieveVideoQuerySchema, type RetrieveVideoQuery, } from "@/lib/video/validateRetrieveVideoQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "retrieve_sora_2_video_content" tool on the MCP server. @@ -31,15 +32,7 @@ export function registerRetrieveVideoContentTool(server: McpServer): void { }, async (args: RetrieveVideoQuery) => { const result = await retrieveVideoContentFunction(args); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/sora2/registerRetrieveVideoTool.ts b/lib/mcp/tools/sora2/registerRetrieveVideoTool.ts index d1391e4b3..e59bb2005 100644 --- a/lib/mcp/tools/sora2/registerRetrieveVideoTool.ts +++ b/lib/mcp/tools/sora2/registerRetrieveVideoTool.ts @@ -4,6 +4,7 @@ import { retrieveVideoQuerySchema, type RetrieveVideoQuery, } from "@/lib/video/validateRetrieveVideoQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "retrieve_sora_2_video" tool on the MCP server. @@ -30,15 +31,7 @@ export function registerRetrieveVideoTool(server: McpServer): void { }, async (args: RetrieveVideoQuery) => { const result = await retrieveVideoFunction(args); - - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/spotify/index.ts b/lib/mcp/tools/spotify/index.ts new file mode 100644 index 000000000..d103f753a --- /dev/null +++ b/lib/mcp/tools/spotify/index.ts @@ -0,0 +1,19 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerGetSpotifySearchTool } from "./registerGetSpotifySearchTool"; +import { registerGetSpotifyArtistTopTracksTool } from "./registerGetSpotifyArtistTopTracksTool"; +import { registerGetSpotifyArtistAlbumsTool } from "./registerGetSpotifyArtistAlbumsTool"; +import { registerGetSpotifyAlbumTool } from "./registerGetSpotifyAlbumTool"; +import { registerGetSpotifyDeepResearchTool } from "./registerGetSpotifyDeepResearchTool"; + +/** + * Registers all Spotify-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllSpotifyTools = (server: McpServer): void => { + registerGetSpotifySearchTool(server); + registerGetSpotifyArtistTopTracksTool(server); + registerGetSpotifyArtistAlbumsTool(server); + registerGetSpotifyAlbumTool(server); + registerGetSpotifyDeepResearchTool(server); +}; diff --git a/lib/mcp/tools/spotify/registerGetSpotifyAlbumTool.ts b/lib/mcp/tools/spotify/registerGetSpotifyAlbumTool.ts new file mode 100644 index 000000000..75ab2701b --- /dev/null +++ b/lib/mcp/tools/spotify/registerGetSpotifyAlbumTool.ts @@ -0,0 +1,61 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbum from "@/lib/spotify/getAlbum"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; + +// Zod schema for the MCP tool - matches the original tool interface +const getSpotifyAlbumSchema = z.object({ + id: z.string().min(1, "Album ID is required"), + market: z.string().length(2).optional().describe("ISO 3166-1 alpha-2 country code"), +}); + +type GetSpotifyAlbumArgs = z.infer; + +/** + * Registers the "get_spotify_album" tool on the MCP server. + * Retrieve Spotify catalog information for a single album. + * You should call get_spotify_artist_albums or get_spotify_search first in order to get an album ID to use in the tool call. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetSpotifyAlbumTool(server: McpServer): void { + server.registerTool( + "get_spotify_album", + { + description: + "Retrieve Spotify catalog information for a single album. You should call get_spotify_artist_albums or get_spotify_search first in order to get an album ID to use in the tool call.", + inputSchema: getSpotifyAlbumSchema, + }, + async (args: GetSpotifyAlbumArgs) => { + try { + // Generate Spotify access token + const tokenResult = await generateAccessToken(); + + if (!tokenResult || tokenResult.error || !tokenResult.access_token) { + return getToolResultError("Failed to generate Spotify access token"); + } + + // Call Spotify API to get album + const { album, error } = await getAlbum({ + id: args.id, + market: args.market, + accessToken: tokenResult.access_token, + }); + + if (error || !album) { + return getToolResultError(error?.message || "Failed to fetch Spotify album"); + } + + // Return the album data directly + return getToolResultSuccess(album); + } catch (error) { + console.error("Error fetching Spotify album:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch Spotify album", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/spotify/registerGetSpotifyArtistAlbumsTool.ts b/lib/mcp/tools/spotify/registerGetSpotifyArtistAlbumsTool.ts new file mode 100644 index 000000000..ff4b06b29 --- /dev/null +++ b/lib/mcp/tools/spotify/registerGetSpotifyArtistAlbumsTool.ts @@ -0,0 +1,70 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getArtistAlbums from "@/lib/spotify/getArtistAlbums"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; + +// Zod schema for the MCP tool - matches the original tool interface +const getSpotifyArtistAlbumsSchema = z.object({ + id: z.string().min(1, "Artist ID is required"), + include_groups: z + .string() + .optional() + .describe("Comma separated values of album types: album, single, appears_on, compilation"), + market: z.string().length(2).optional().describe("ISO 3166-1 alpha-2 country code"), + limit: z.number().min(1).max(50).optional().default(20), + offset: z.number().min(0).optional().default(0), +}); + +type GetSpotifyArtistAlbumsArgs = z.infer; + +/** + * Registers the "get_spotify_artist_albums" tool on the MCP server. + * Retrieve Spotify catalog information about an artist's albums. + * You should call get_artist_socials or get_spotify_search first to obtain the artist ID before using this tool. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetSpotifyArtistAlbumsTool(server: McpServer): void { + server.registerTool( + "get_spotify_artist_albums", + { + description: + "Retrieve Spotify catalog information about an artist's albums. You should call get_artist_socials or get_spotify_search first to obtain the artist ID before using this tool.", + inputSchema: getSpotifyArtistAlbumsSchema, + }, + async (args: GetSpotifyArtistAlbumsArgs) => { + try { + // Generate Spotify access token + const tokenResult = await generateAccessToken(); + + if (!tokenResult || tokenResult.error || !tokenResult.access_token) { + return getToolResultError("Failed to generate Spotify access token"); + } + + // Call Spotify API to get artist albums + const { data, error } = await getArtistAlbums({ + id: args.id, + include_groups: args.include_groups, + market: args.market, + limit: args.limit, + offset: args.offset, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return getToolResultError(error?.message || "Failed to fetch Spotify artist albums"); + } + + // Return the data directly (Spotify API returns paginated album list) + return getToolResultSuccess(data); + } catch (error) { + console.error("Error fetching Spotify artist albums:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch Spotify artist albums", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/spotify/registerGetSpotifyArtistTopTracksTool.ts b/lib/mcp/tools/spotify/registerGetSpotifyArtistTopTracksTool.ts new file mode 100644 index 000000000..0d89e0293 --- /dev/null +++ b/lib/mcp/tools/spotify/registerGetSpotifyArtistTopTracksTool.ts @@ -0,0 +1,61 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getArtistTopTracks from "@/lib/spotify/getArtistTopTracks"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; + +// Zod schema for the MCP tool - matches the original tool interface +const getSpotifyArtistTopTracksSchema = z.object({ + id: z.string().min(1, "Artist ID is required"), + market: z.string().length(2).optional(), +}); + +type GetSpotifyArtistTopTracksArgs = z.infer; + +/** + * Registers the "get_spotify_artist_top_tracks" tool on the MCP server. + * Retrieve an artist's top tracks by country using the Spotify API. + * You should call get_artist_socials or get_spotify_search first in order to get an artist ID to use in the tool call. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetSpotifyArtistTopTracksTool(server: McpServer): void { + server.registerTool( + "get_spotify_artist_top_tracks", + { + description: + "Retrieve an artist's top tracks by country using the Spotify API. You should call get_artist_socials or get_spotify_search first in order to get an artist ID to use in the tool call.", + inputSchema: getSpotifyArtistTopTracksSchema, + }, + async (args: GetSpotifyArtistTopTracksArgs) => { + try { + // Generate Spotify access token + const tokenResult = await generateAccessToken(); + + if (!tokenResult || tokenResult.error || !tokenResult.access_token) { + return getToolResultError("Failed to generate Spotify access token"); + } + + // Call Spotify API to get artist top tracks + const { data, error } = await getArtistTopTracks({ + id: args.id, + market: args.market, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return getToolResultError(error?.message || "Failed to fetch artist top tracks"); + } + + // Return the data directly (Spotify API returns { tracks: [...] }) + return getToolResultSuccess(data); + } catch (error) { + console.error("Error fetching artist top tracks:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch artist top tracks", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/spotify/registerGetSpotifyDeepResearchTool.ts b/lib/mcp/tools/spotify/registerGetSpotifyDeepResearchTool.ts new file mode 100644 index 000000000..8264a4baa --- /dev/null +++ b/lib/mcp/tools/spotify/registerGetSpotifyDeepResearchTool.ts @@ -0,0 +1,77 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getArtistSocials } from "@/lib/artist/getArtistSocials"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const SPOTIFY_DEEP_RESEARCH_REQUIREMENTS = ` + - popularity info (MANDATORY): + * Track popularity scores (0-100) for all tracks + * Average popularity across all tracks + * Most popular tracks ranked by popularity + * Popularity trends over time (if available) + - Spotify follower metrics (MANDATORY): + * Current total follower count for the artist on Spotify + - engagement info + - tracklist + - collaborators + - album art + - album name +`; + +// Zod schema for the MCP tool - matches the original tool interface +const getSpotifyDeepResearchSchema = z.object({ + artist_account_id: z.string().min(1, "Artist account ID is required"), +}); + +type GetSpotifyDeepResearchArgs = z.infer; + +/** + * Registers the "spotify_deep_research" tool on the MCP server. + * Performs deep research on an artist using a Spotify ID. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetSpotifyDeepResearchTool(server: McpServer): void { + server.registerTool( + "spotify_deep_research", + { + description: `Performs deep research on an artist using a Spotify ID. + + Required items in deep research document: + ${SPOTIFY_DEEP_RESEARCH_REQUIREMENTS}`, + inputSchema: getSpotifyDeepResearchSchema, + }, + async (args: GetSpotifyDeepResearchArgs) => { + try { + // Call getArtistSocials with default pagination (page 1, limit 20) + const result = await getArtistSocials({ + artist_account_id: args.artist_account_id, + page: 1, + limit: 20, + }); + + if (result.status === "error") { + return getToolResultError(result.message || "Failed to fetch artist socials"); + } + + // Format response to match the original tool's expected structure + const response = { + success: true, + artistSocials: { + socials: result.socials, + }, + artist_account_id: args.artist_account_id, + pagination: result.pagination, + }; + + return getToolResultSuccess(response); + } catch (error) { + console.error("Error performing Spotify deep research:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to perform Spotify deep research", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/spotify/registerGetSpotifySearchTool.ts b/lib/mcp/tools/spotify/registerGetSpotifySearchTool.ts new file mode 100644 index 000000000..512d42472 --- /dev/null +++ b/lib/mcp/tools/spotify/registerGetSpotifySearchTool.ts @@ -0,0 +1,85 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; + +// Supported Spotify search types +const SPOTIFY_TYPES = [ + "album", + "artist", + "playlist", + "track", + "show", + "episode", + "audiobook", +] as const; + +// Zod schema for the MCP tool - matches the original tool interface +const getSpotifySearchSchema = z.object({ + name: z.string().min(1, "Search query is required"), + type: z.array(z.enum(SPOTIFY_TYPES)).min(1, "At least one type is required"), + limit: z.number().min(1).max(50).optional().default(5), +}); + +type GetSpotifySearchArgs = z.infer; + +/** + * Registers the "get_spotify_search" tool on the MCP server. + * Search for Spotify items (artist, album, track, playlist, etc.) by name. + * Returns results for each requested type. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetSpotifySearchTool(server: McpServer): void { + server.registerTool( + "get_spotify_search", + { + description: + "Search for Spotify items (artist, album, track, playlist, etc.) by name. Returns results for each requested type.", + inputSchema: getSpotifySearchSchema, + }, + async (args: GetSpotifySearchArgs) => { + try { + // Generate Spotify access token + const tokenResult = await generateAccessToken(); + + if (!tokenResult || tokenResult.error || !tokenResult.access_token) { + return getToolResultError("Failed to generate Spotify access token"); + } + + // Convert type array to comma-separated string for API + const typeString = args.type.join(","); + + // Call Spotify search API + const { data, error } = await getSearch({ + q: args.name, + type: typeString, + limit: args.limit, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return getToolResultError(error?.message || "Failed to search Spotify"); + } + + // Filter results to only include requested types (matching original tool behavior) + const result: Record = { success: true }; + for (const t of args.type) { + const key = `${t}s`; + if (data[key]) { + result[key] = data[key]; + } + } + + return getToolResultSuccess(result); + } catch (error) { + console.error("Error searching Spotify:", error); + return getToolResultError( + error instanceof Error ? error.message : "Failed to search Spotify", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/tasks/registerCreateTaskTool.ts b/lib/mcp/tools/tasks/registerCreateTaskTool.ts index 0e19e2914..1c88e3718 100644 --- a/lib/mcp/tools/tasks/registerCreateTaskTool.ts +++ b/lib/mcp/tools/tasks/registerCreateTaskTool.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createTask } from "@/lib/tasks/createTask"; import { createTaskBodySchema, type CreateTaskBody } from "@/lib/tasks/validateCreateTaskBody"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "create_task" tool on the MCP server. @@ -17,14 +18,7 @@ export function registerCreateTaskTool(server: McpServer): void { }, async (args: CreateTaskBody) => { const result = await createTask(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/tasks/registerDeleteTaskTool.ts b/lib/mcp/tools/tasks/registerDeleteTaskTool.ts index 0a764eba0..5e036c964 100644 --- a/lib/mcp/tools/tasks/registerDeleteTaskTool.ts +++ b/lib/mcp/tools/tasks/registerDeleteTaskTool.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { deleteTask } from "@/lib/tasks/deleteTask"; import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; import { deleteTaskBodySchema, type DeleteTaskBody } from "@/lib/tasks/validateDeleteTaskBody"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "delete_task" tool on the MCP server. @@ -24,14 +25,7 @@ export function registerDeleteTaskTool(server: McpServer): void { // Delete the task await deleteTask(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(taskToDelete), - }, - ], - }; + return getToolResultSuccess(taskToDelete); }, ); } diff --git a/lib/mcp/tools/tasks/registerGetTasksTool.ts b/lib/mcp/tools/tasks/registerGetTasksTool.ts index 58005582d..e69caf447 100644 --- a/lib/mcp/tools/tasks/registerGetTasksTool.ts +++ b/lib/mcp/tools/tasks/registerGetTasksTool.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; import { GetTasksQuery, getTasksQuerySchema } from "@/lib/tasks/validateGetTasksQuery"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "get_tasks" tool on the MCP server. @@ -17,15 +18,7 @@ export function registerGetTasksTool(server: McpServer): void { }, async (args: GetTasksQuery) => { const tasks = await selectScheduledActions(args); - - return { - content: [ - { - type: "text", - text: JSON.stringify(tasks), - }, - ], - }; + return getToolResultSuccess(tasks); }, ); } diff --git a/lib/mcp/tools/tasks/registerUpdateTaskTool.ts b/lib/mcp/tools/tasks/registerUpdateTaskTool.ts index 9e29132a7..1cf2deaf5 100644 --- a/lib/mcp/tools/tasks/registerUpdateTaskTool.ts +++ b/lib/mcp/tools/tasks/registerUpdateTaskTool.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { updateTask } from "@/lib/tasks/updateTask"; import { updateTaskBodySchema, type UpdateTaskBody } from "@/lib/tasks/validateUpdateTaskBody"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; /** * Registers the "update_task" tool on the MCP server. @@ -19,14 +20,7 @@ Omitting a field leaves the existing value unchanged.`, }, async (args: UpdateTaskBody) => { const result = await updateTask(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }; + return getToolResultSuccess(result); }, ); } diff --git a/lib/mcp/tools/youtube/index.ts b/lib/mcp/tools/youtube/index.ts new file mode 100644 index 000000000..24fced82b --- /dev/null +++ b/lib/mcp/tools/youtube/index.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerYouTubeLoginTool } from "./registerYouTubeLoginTool"; + +/** + * Registers all YouTube-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllYouTubeTools = (server: McpServer): void => { + registerYouTubeLoginTool(server); +}; diff --git a/lib/mcp/tools/youtube/registerYouTubeLoginTool.ts b/lib/mcp/tools/youtube/registerYouTubeLoginTool.ts new file mode 100644 index 000000000..19d71faae --- /dev/null +++ b/lib/mcp/tools/youtube/registerYouTubeLoginTool.ts @@ -0,0 +1,69 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { selectYouTubeTokens } from "@/lib/supabase/youtube_tokens/selectYouTubeTokens"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { isTokenExpired } from "@/lib/youtube/isTokenExpired"; + +const youtubeLoginSchema = z.object({ + artist_account_id: z + .string() + .min(1, "Artist account ID is required") + .describe("Artist account ID from the system prompt of the active artist."), +}); + +type YouTubeLoginArgs = z.infer; + +/** + * Registers the "youtube_login" tool on the MCP server. + * Checks YouTube authentication status for a specific account. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerYouTubeLoginTool(server: McpServer): void { + server.registerTool( + "youtube_login", + { + description: + "Check YouTube authentication status for a specific account. " + + "Returns authentication status and token expiry if authenticated, or clear instructions if not. " + + "IMPORTANT: This tool requires the artist_account_id parameter. Never ask the user for this parameter. It is always passed in the system prompt.", + inputSchema: youtubeLoginSchema, + }, + async (args: YouTubeLoginArgs) => { + try { + if (!args.artist_account_id || args.artist_account_id.trim() === "") { + return getToolResultError( + "No artist_account_id provided to YouTube login tool. The LLM must pass the artist_account_id parameter. Please ensure you're passing the current artist's artist_account_id.", + ); + } + + const tokens = await selectYouTubeTokens(args.artist_account_id); + + if (!tokens) { + return getToolResultError( + "YouTube authentication required for this account. Please authenticate by connecting your YouTube account.", + ); + } + + if (isTokenExpired(tokens.expires_at)) { + return getToolResultError( + "YouTube access token has expired. Please re-authenticate your YouTube account.", + ); + } + + return getToolResultSuccess({ + message: "YouTube is connected for this account.", + authenticated: true, + }); + } catch (error) { + console.error("Error checking YouTube authentication:", error); + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to check YouTube authentication. Please try again.", + ); + } + }, + ); +} diff --git a/lib/perplexity/config.ts b/lib/perplexity/config.ts new file mode 100644 index 000000000..af4abebe4 --- /dev/null +++ b/lib/perplexity/config.ts @@ -0,0 +1,32 @@ +export const PERPLEXITY_BASE_URL = "https://api.perplexity.ai"; + +/** + * Gets the Perplexity API key from the environment variables. + * + * @returns The Perplexity API key. + */ +export function getPerplexityApiKey(): string { + const apiKey = process.env.PERPLEXITY_API_KEY; + + if (!apiKey) { + throw new Error( + "PERPLEXITY_API_KEY environment variable is not set. " + + "Please add it to your environment variables.", + ); + } + + return apiKey; +} + +/** + * Gets the Perplexity headers for the API request. + * + * @param apiKey - The Perplexity API key. + * @returns The Perplexity headers. + */ +export function getPerplexityHeaders(apiKey: string): HeadersInit { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; +} diff --git a/lib/perplexity/formatSearchResultsAsMarkdown.ts b/lib/perplexity/formatSearchResultsAsMarkdown.ts new file mode 100644 index 000000000..7b218f109 --- /dev/null +++ b/lib/perplexity/formatSearchResultsAsMarkdown.ts @@ -0,0 +1,31 @@ +import { SearchResponse } from "./searchPerplexity"; + +/** + * Formats the search results as Markdown. + * + * @param response - The search response. + * @returns The formatted search results. + */ +export function formatSearchResultsAsMarkdown(response: SearchResponse): string { + const { results } = response; + + if (results.length === 0) { + return "No search results found."; + } + + let formatted = `Found ${results.length} search results:\n\n`; + + results.forEach((result, index) => { + formatted += `### ${index + 1}. ${result.title}\n\n`; + formatted += `**URL:** ${result.url}\n\n`; + + if (result.date) { + formatted += `**Published:** ${result.date}\n\n`; + } + + formatted += `${result.snippet}\n\n`; + formatted += `---\n\n`; + }); + + return formatted; +} diff --git a/lib/perplexity/searchPerplexity.ts b/lib/perplexity/searchPerplexity.ts new file mode 100644 index 000000000..c7c702037 --- /dev/null +++ b/lib/perplexity/searchPerplexity.ts @@ -0,0 +1,60 @@ +import { getPerplexityApiKey, getPerplexityHeaders, PERPLEXITY_BASE_URL } from "./config"; + +export interface SearchResult { + title: string; + url: string; + snippet: string; + date?: string; + last_updated?: string; +} + +export interface SearchResponse { + results: SearchResult[]; + id: string; +} + +export interface SearchParams { + query: string; + max_results?: number; + max_tokens_per_page?: number; + country?: string; + search_domain_filter?: string[]; +} + +/** + * Searches the Perplexity API for the given query. + * + * @param params - The search parameters. + * @returns The search response. + */ +export async function searchPerplexity(params: SearchParams): Promise { + const apiKey = getPerplexityApiKey(); + const url = `${PERPLEXITY_BASE_URL}/search`; + + const body = { + query: params.query, + max_results: params.max_results || 10, + max_tokens_per_page: params.max_tokens_per_page || 1024, + ...(params.country && { country: params.country }), + ...(params.search_domain_filter && { search_domain_filter: params.search_domain_filter }), + }; + + try { + const response = await fetch(url, { + method: "POST", + headers: getPerplexityHeaders(apiKey), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Perplexity Search API error: ${response.status} ${response.statusText}\n${errorText}`, + ); + } + + return await response.json(); + } catch (error) { + throw new Error(`Failed to search Perplexity API: ${error}`); + } +} diff --git a/lib/segments/consts.ts b/lib/segments/consts.ts new file mode 100644 index 000000000..341222cec --- /dev/null +++ b/lib/segments/consts.ts @@ -0,0 +1,16 @@ +export const SEGMENT_FAN_SOCIAL_ID_PROMPT = `For each Segment Name return an array of fan_social_id included in the segment. Do not make these up. Only use the actual fan_social_id provided in the fan data prompt input.`; + +export const SEGMENT_SYSTEM_PROMPT = `You are an expert music industry analyst specializing in fan segmentation. + Your task is to analyze fan data and generate meaningful segment names that would be useful for marketing and engagement strategies. + + Guidelines for segment names: + - Keep names concise and descriptive (2-4 words) + - Focus on engagement patterns, demographics, or behavioral characteristics + - Use clear, actionable language that marketers can understand + - Avoid generic terms like "fans" or "followers" + - Consider factors like engagement frequency, recency, and intensity + - Generate 5-10 segment names that cover different aspects of the fan base + + The segment names should help artists and managers understand their audience better for targeted marketing campaigns. + + ${SEGMENT_FAN_SOCIAL_ID_PROMPT}`; diff --git a/lib/segments/createSegmentResponses.ts b/lib/segments/createSegmentResponses.ts new file mode 100644 index 000000000..c9269ab08 --- /dev/null +++ b/lib/segments/createSegmentResponses.ts @@ -0,0 +1,30 @@ +import { Tables } from "@/types/database.types"; +import type { GenerateArrayResult } from "./generateSegments"; + +interface CreateArtistSegmentsSuccessData { + supabase_segments: Tables<"segments">[]; + supabase_artist_segments: Tables<"artist_segments">[]; + segments: GenerateArrayResult[]; + supabase_fan_segments: Tables<"fan_segments">[]; +} + +export const successResponse = ( + message: string, + data: CreateArtistSegmentsSuccessData, + count: number, +) => ({ + success: true, + status: "success", + message, + data, + count, +}); + +export const errorResponse = (message: string) => ({ + success: false, + status: "error", + message, + data: [], + count: 0, +}); + diff --git a/lib/segments/createSegments.ts b/lib/segments/createSegments.ts new file mode 100644 index 000000000..df358664c --- /dev/null +++ b/lib/segments/createSegments.ts @@ -0,0 +1,117 @@ +import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; +import { selectSocialFans } from "@/lib/supabase/social_fans/selectSocialFans"; +import { generateSegments } from "./generateSegments"; +import { insertSegments } from "@/lib/supabase/segments/insertSegments"; +import { deleteSegments } from "@/lib/supabase/segments/deleteSegments"; +import { insertArtistSegments } from "@/lib/supabase/artist_segments/insertArtistSegments"; +import { insertFanSegments } from "@/lib/supabase/fan_segments/insertFanSegments"; +import { Tables } from "@/types/database.types"; +import { successResponse, errorResponse } from "./createSegmentResponses"; +import type { GenerateArrayResult } from "./generateSegments"; +import { getFanSegmentsToInsert } from "./getFanSegmentsToInsert"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { CreateSegmentsArgs } from "../mcp/tools/registerCreateSegmentsTool"; + +export const createSegments = async ({ artist_account_id, prompt }: CreateSegmentsArgs) => { + try { + // Get artist info for better error messages + const accounts = await selectAccounts(artist_account_id); + const artistInfo = accounts[0]; + const artistName = artistInfo?.name || "this artist"; + + // Step 1: Get all social IDs for the artist + const accountSocials = await selectAccountSocials(artist_account_id, 0, 10000); + const socialIds = (accountSocials || []).map((as: { social_id: string }) => as.social_id); + + if (socialIds.length === 0) { + return { + ...errorResponse("No social account found for this artist"), + feedback: + `No Instagram accounts found for ${artistName}. To automatically set up Instagram accounts, please follow these steps:\n` + + `1. Call 'search_web' to search for "${artistName} Instagram handle"\n` + + "2. Call 'update_artist_socials' with the discovered Instagram profile URL\n" + + "3. Call 'create_segments' again to retry segment creation\n" + + "Instagram is required for fan segmentation as it's the primary social platform configured for segments.", + }; + } + + // Step 2: Get all fans for the artist + const fans = await selectSocialFans({ + social_ids: socialIds, + orderBy: "latest_engagement", + orderDirection: "desc", + }); + + if (fans.length === 0) { + return { + ...errorResponse("No fans found for this artist"), + feedback: + `No social_fans records found for ${artistName}. Before creating segments, you need social_fans data. Follow these steps:\n` + + "1. Call 'scrape_instagram_profile' with the artist's Instagram handles to get posts\n" + + "2. Call 'scrape_instagram_comments' with Instagram post URLs to scrape comment data\n" + + "3. Wait for the scraping jobs to complete and process into social_fans records\n" + + "4. Call 'create_segments' again once social_fans records are populated\n" + + "Note: Scraping jobs may take several minutes to complete.", + }; + } + + // Step 3: Generate segment names using AI + const segments = await generateSegments({ fans, prompt }); + + if (segments.length === 0) { + return errorResponse("Failed to generate segment names"); + } + + // Step 4: Delete existing segments for the artist + await deleteSegments(artist_account_id); + + // Step 5: Insert segments into the database + const segmentsToInsert = segments.map((segment: GenerateArrayResult) => ({ + name: segment.segmentName, + updated_at: new Date().toISOString(), + })); + + const insertedSegments = await insertSegments(segmentsToInsert); + + // Step 6: Associate segments with the artist + const artistSegmentsToInsert = insertedSegments.map((segment: Tables<"segments">) => ({ + artist_account_id, + segment_id: segment.id, + updated_at: new Date().toISOString(), + })); + + const insertedArtistSegments = await insertArtistSegments(artistSegmentsToInsert); + + // Step 7: Associate fans with the new segments + // Build a set of valid IDs from the fetched fan list + const validFanIds = new Set(fans.map(f => f.fan_social_id)); + + const fanSegmentsToInsert = getFanSegmentsToInsert(segments, insertedSegments).filter(fs => { + const ok = validFanIds.has(fs.fan_social_id); + if (!ok) console.warn(`Skipping unknown fan_social_id: ${fs.fan_social_id}`); + return ok; + }); + + if (fanSegmentsToInsert.length === 0) { + return errorResponse("No valid fan IDs matched any segment."); + } + + const insertedFanSegments = await insertFanSegments(fanSegmentsToInsert); + + return successResponse( + `Successfully created ${segments.length} segments for artist`, + { + supabase_segments: insertedSegments, + supabase_artist_segments: insertedArtistSegments, + supabase_fan_segments: insertedFanSegments, + segments, + }, + segments.length, + ); + } catch (error) { + console.error("Error creating artist segments:", error); + return errorResponse( + error instanceof Error ? error.message : "Failed to create artist segments", + ); + } +}; diff --git a/lib/segments/generateSegments.ts b/lib/segments/generateSegments.ts new file mode 100644 index 000000000..40beeb091 --- /dev/null +++ b/lib/segments/generateSegments.ts @@ -0,0 +1,33 @@ +import generateArray from "@/lib/ai/generateArray"; +import { SEGMENT_SYSTEM_PROMPT } from "./consts"; +import getAnalysisPrompt from "./getAnalysisPrompt"; +import type { SocialFanWithDetails } from "@/lib/supabase/social_fans/selectSocialFans"; + +export interface GenerateSegmentsParams { + fans: SocialFanWithDetails[]; + prompt: string; +} + +export interface GenerateArrayResult { + segmentName: string; + fans: string[]; +} + +export const generateSegments = async ({ + fans, + prompt, +}: GenerateSegmentsParams): Promise => { + try { + const analysisPrompt = getAnalysisPrompt({ fans, prompt }); + + const result = await generateArray({ + system: SEGMENT_SYSTEM_PROMPT, + prompt: analysisPrompt, + }); + + return result; + } catch (error) { + console.error("Error generating segments:", error); + throw new Error("Failed to generate segments from fan data"); + } +}; diff --git a/lib/segments/getAnalysisPrompt.ts b/lib/segments/getAnalysisPrompt.ts new file mode 100644 index 000000000..d6a05ffde --- /dev/null +++ b/lib/segments/getAnalysisPrompt.ts @@ -0,0 +1,36 @@ +import { SEGMENT_FAN_SOCIAL_ID_PROMPT } from "./consts"; +import type { GenerateSegmentsParams } from "./generateSegments"; + +const getAnalysisPrompt = ({ fans, prompt }: GenerateSegmentsParams) => { + const fanCount = fans.length; + const fanData = fans.map(fan => { + const obj = { + fan_social_id: fan.fan_social_id, + username: fan.fan_social.username, + bio: fan.fan_social.bio, + followerCount: fan.fan_social.followerCount, + followingCount: fan.fan_social.followingCount, + comment: fan.latest_engagement_comment?.comment || null, + }; + // Remove keys with null values + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== null)); + }); + + const maxFans = 111; + const slicedFanData = fanData.slice(0, maxFans); + + const fanDataString = JSON.stringify(slicedFanData, null, 2); + const analysisPrompt = `Analyze the following fan data and generate segment names based on the provided prompt. + + Fan Data Summary: + - Total fans: ${fanCount} + - Fan data: ${fanDataString} + + Artist's specific prompt: ${prompt} + + Generate segment names that align with the artist's requirements and the fan data characteristics. + ${SEGMENT_FAN_SOCIAL_ID_PROMPT}`; + return analysisPrompt; +}; + +export default getAnalysisPrompt; diff --git a/lib/segments/getFanSegmentsToInsert.ts b/lib/segments/getFanSegmentsToInsert.ts new file mode 100644 index 000000000..a6f7097d9 --- /dev/null +++ b/lib/segments/getFanSegmentsToInsert.ts @@ -0,0 +1,27 @@ +import type { GenerateArrayResult } from "./generateSegments"; +import { Tables } from "@/types/database.types"; + +/** + * Returns an array of fan-segment associations to insert, based on the AI-generated segments and the inserted segment records. + * Each fan is only associated with the segment(s) they are assigned to in the segments array. + * + * @param segments - The AI-generated segments. + * @param insertedSegments - The inserted segment records. + * @returns An array of fan-segment associations to insert. + */ +export function getFanSegmentsToInsert( + segments: GenerateArrayResult[], + insertedSegments: Tables<"segments">[], +) { + const segmentNameToId = new Map(insertedSegments.map(seg => [seg.name, seg.id])); + + return segments.flatMap((segment: GenerateArrayResult) => { + const segmentId = segmentNameToId.get(segment.segmentName); + if (!segmentId || !segment.fans) return []; + return segment.fans.map((fan_social_id: string) => ({ + fan_social_id, + segment_id: segmentId, + updated_at: new Date().toISOString(), + })); + }); +} diff --git a/lib/supabase/account_info/selectAccountInfo.ts b/lib/supabase/account_info/selectAccountInfo.ts new file mode 100644 index 000000000..85499020a --- /dev/null +++ b/lib/supabase/account_info/selectAccountInfo.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Retrieves account_info by account_id. + * + * @param accountId - The account ID + * @returns The account_info record, or null if not found + */ +export async function selectAccountInfo(accountId: string): Promise | null> { + const { data, error } = await supabase + .from("account_info") + .select("*") + .eq("account_id", accountId) + .single(); + + if (error || !data) { + return null; + } + + return data; +} diff --git a/lib/supabase/account_info/updateAccountInfo.ts b/lib/supabase/account_info/updateAccountInfo.ts new file mode 100644 index 000000000..3b96eccdf --- /dev/null +++ b/lib/supabase/account_info/updateAccountInfo.ts @@ -0,0 +1,28 @@ +import supabase from "../serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; + +/** + * Updates an account_info record. + * + * @param accountId - The account ID + * @param updates - Partial account_info data to update + * @returns The updated account_info record, or null if failed + */ +export async function updateAccountInfo( + accountId: string, + updates: TablesUpdate<"account_info">, +): Promise | null> { + const { data, error } = await supabase + .from("account_info") + .update(updates) + .eq("account_id", accountId) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] updateAccountInfo:", error); + return null; + } + + return data || null; +} diff --git a/lib/supabase/account_socials/deleteAccountSocial.ts b/lib/supabase/account_socials/deleteAccountSocial.ts new file mode 100644 index 000000000..50544c340 --- /dev/null +++ b/lib/supabase/account_socials/deleteAccountSocial.ts @@ -0,0 +1,23 @@ +import supabase from "../serverClient"; + +/** + * Deletes an account_social record by account ID and social ID. + * + * @param accountId - The account ID + * @param socialId - The social ID + * @returns True if successful, false otherwise + */ +export async function deleteAccountSocial(accountId: string, socialId: string): Promise { + const { error } = await supabase + .from("account_socials") + .delete() + .eq("account_id", accountId) + .eq("social_id", socialId); + + if (error) { + console.error("[ERROR] deleteAccountSocial:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/account_socials/insertAccountSocial.ts b/lib/supabase/account_socials/insertAccountSocial.ts new file mode 100644 index 000000000..bf1f53b93 --- /dev/null +++ b/lib/supabase/account_socials/insertAccountSocial.ts @@ -0,0 +1,27 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Inserts a new account_social record. + * + * @param accountId - The account ID + * @param socialId - The social ID + * @returns The inserted account_social record, or null if failed + */ +export async function insertAccountSocial( + accountId: string, + socialId: string, +): Promise | null> { + const { data, error } = await supabase + .from("account_socials") + .insert({ account_id: accountId, social_id: socialId }) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] insertAccountSocial:", error); + return null; + } + + return data || null; +} diff --git a/lib/supabase/accounts/selectAccounts.ts b/lib/supabase/accounts/selectAccounts.ts new file mode 100644 index 000000000..21fa53fbb --- /dev/null +++ b/lib/supabase/accounts/selectAccounts.ts @@ -0,0 +1,18 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Retrieves accounts by ID. + * + * @param accountId - The account ID + * @returns Array of account records (empty array if not found) + */ +export async function selectAccounts(accountId: string): Promise[]> { + const { data, error } = await supabase.from("accounts").select("*").eq("id", accountId); + + if (error || !data) { + return []; + } + + return data; +} diff --git a/lib/supabase/accounts/updateAccount.ts b/lib/supabase/accounts/updateAccount.ts new file mode 100644 index 000000000..53e835e05 --- /dev/null +++ b/lib/supabase/accounts/updateAccount.ts @@ -0,0 +1,28 @@ +import supabase from "../serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; + +/** + * Updates an account record. + * + * @param accountId - The account ID + * @param updates - Partial account data to update + * @returns The updated account record, or null if failed + */ +export async function updateAccount( + accountId: string, + updates: TablesUpdate<"accounts">, +): Promise | null> { + const { data, error } = await supabase + .from("accounts") + .update(updates) + .eq("id", accountId) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] updateAccount:", error); + return null; + } + + return data || null; +} diff --git a/lib/supabase/artist_segments/insertArtistSegments.ts b/lib/supabase/artist_segments/insertArtistSegments.ts new file mode 100644 index 000000000..1e442eec0 --- /dev/null +++ b/lib/supabase/artist_segments/insertArtistSegments.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts artist_segments associations into the database. + * + * @param artistSegments - Array of artist_segment objects to insert + * @returns Array of inserted artist_segments + * @throws Error if the insertion fails + */ +export const insertArtistSegments = async ( + artistSegments: TablesInsert<"artist_segments">[], +): Promise[]> => { + const { data, error } = await supabase.from("artist_segments").insert(artistSegments).select(); + + if (error) { + console.error("Error inserting artist segments:", error); + throw error; + } + + return data || []; +}; diff --git a/lib/supabase/artist_segments/selectArtistSegments.ts b/lib/supabase/artist_segments/selectArtistSegments.ts index 6f24e1e07..370991b34 100644 --- a/lib/supabase/artist_segments/selectArtistSegments.ts +++ b/lib/supabase/artist_segments/selectArtistSegments.ts @@ -1,58 +1,25 @@ import supabase from "../serverClient"; /** - * Type for the Supabase query result with joined segments and accounts - */ -export interface SegmentQueryResult { - id: string; - artist_account_id: string; - segment_id: string; - updated_at: string | null; - segments: { - name: string | null; - } | null; - accounts: { - name: string | null; - } | null; -} - -/** - * Selects artist segments with joined segment and account data, filtered by artist account ID. + * Selects artist_segments records for an artist account. * - * @param artist_account_id - The unique identifier of the artist account - * @param offset - The number of records to skip - * @param limit - The maximum number of records to return - * @returns The query results with joined segment and account data - * @throws Error if the query fails + * @param artist_account_id - The artist account ID + * @returns Array of artist_segments records with segment_id, or empty array if none found or on error */ -export async function selectArtistSegments( - artist_account_id: string, - offset: number, - limit: number, -): Promise { - const queryText = ` - id, - artist_account_id, - segment_id, - updated_at, - segments ( - name - ), - accounts:artist_account_id ( - name - ) - `; - +export async function selectArtistSegments(artist_account_id: string) { const { data, error } = await supabase .from("artist_segments") - .select(queryText) - .eq("artist_account_id", artist_account_id) - .order("updated_at", { ascending: false }) - .range(offset, offset + limit - 1); + .select("segment_id") + .eq("artist_account_id", artist_account_id); if (error) { - throw new Error(`Failed to fetch artist segments: ${error.message}`); + console.error("Error fetching artist segments:", error); + return []; + } + + if (!data || data.length === 0) { + return []; } - return data as unknown as SegmentQueryResult[] | null; + return data; } diff --git a/lib/supabase/artist_segments/selectArtistSegmentsWithDetails.ts b/lib/supabase/artist_segments/selectArtistSegmentsWithDetails.ts new file mode 100644 index 000000000..56dce627f --- /dev/null +++ b/lib/supabase/artist_segments/selectArtistSegmentsWithDetails.ts @@ -0,0 +1,44 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Type for the Supabase query result with joined segments and accounts + */ +export type SegmentQueryResult = Tables<"artist_segments"> & { + segments: Tables<"segments"> | null; + accounts: Tables<"accounts"> | null; +}; + +/** + * Selects artist segments with joined segment and account data, filtered by artist account ID. + * + * @param artist_account_id - The unique identifier of the artist account + * @param offset - The number of records to skip + * @param limit - The maximum number of records to return + * @returns The query results with joined segment and account data + * @throws Error if the query fails + */ +export async function selectArtistSegmentsWithDetails( + artist_account_id: string, + offset: number, + limit: number, +): Promise { + const queryText = ` + *, + segments(*), + accounts:artist_account_id(*) + `; + + const { data, error } = await supabase + .from("artist_segments") + .select(queryText) + .eq("artist_account_id", artist_account_id) + .order("updated_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + throw new Error(`Failed to fetch artist segments: ${error.message}`); + } + + return data as unknown as SegmentQueryResult[] | null; +} diff --git a/lib/supabase/fan_segments/insertFanSegments.ts b/lib/supabase/fan_segments/insertFanSegments.ts new file mode 100644 index 000000000..c1951886c --- /dev/null +++ b/lib/supabase/fan_segments/insertFanSegments.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts fan_segments associations into the database. + * + * @param fanSegments - Array of fan_segment objects to insert + * @returns Array of inserted fan_segments + * @throws Error if the insertion fails + */ +export const insertFanSegments = async ( + fanSegments: TablesInsert<"fan_segments">[], +): Promise[]> => { + const { data, error } = await supabase.from("fan_segments").insert(fanSegments).select(); + + if (error) { + console.error("Error inserting fan segments:", error); + throw error; + } + + return data || []; +}; diff --git a/lib/supabase/segments/deleteSegments.ts b/lib/supabase/segments/deleteSegments.ts new file mode 100644 index 000000000..c4fa7a2b8 --- /dev/null +++ b/lib/supabase/segments/deleteSegments.ts @@ -0,0 +1,35 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; +import { selectArtistSegments } from "../artist_segments/selectArtistSegments"; + +/** + * Deletes all segments associated with an artist account. + * + * @param artist_account_id - The artist account ID + * @returns Array of deleted segments + */ +export const deleteSegments = async (artist_account_id: string): Promise[]> => { + // Get all segment_ids associated with the artist + const artistSegments = await selectArtistSegments(artist_account_id); + const segmentIds = artistSegments.map((item: { segment_id: string }) => item.segment_id); + + if (segmentIds.length === 0) { + // No segments to delete + return []; + } + + // Delete the segments from the segments table + const { data, error } = await supabase.from("segments").delete().in("id", segmentIds).select(); + + if (error) { + console.error("Error deleting segments:", error); + return []; + } + + if (!data || data.length === 0) { + console.warn(`No segments found with ids: ${segmentIds.join(", ")}`); + return []; + } + + return data; +}; diff --git a/lib/supabase/segments/insertSegments.ts b/lib/supabase/segments/insertSegments.ts new file mode 100644 index 000000000..4c4c1111c --- /dev/null +++ b/lib/supabase/segments/insertSegments.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts segments into the database. + * + * @param segments - Array of segment objects to insert + * @returns Array of inserted segments + * @throws Error if the insertion fails + */ +export const insertSegments = async ( + segments: TablesInsert<"segments">[], +): Promise[]> => { + const { data, error } = await supabase.from("segments").insert(segments).select(); + + if (error) { + console.error("Error inserting segments:", error); + throw error; + } + + return data || []; +}; diff --git a/lib/supabase/social_fans/selectSocialFans.ts b/lib/supabase/social_fans/selectSocialFans.ts new file mode 100644 index 000000000..af8553769 --- /dev/null +++ b/lib/supabase/social_fans/selectSocialFans.ts @@ -0,0 +1,89 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +type SocialFan = Tables<"social_fans">; +type Social = Tables<"socials">; +type PostComment = Tables<"post_comments">; + +// Extended type to include joined data +export interface SocialFanWithDetails extends SocialFan { + artist_social: Social; + fan_social: Social; + latest_engagement_comment: PostComment | null; +} + +// Allowed top-level columns for ordering +const SOCIAL_FANS_ORDERABLE_COLUMNS = [ + "id", + "artist_social_id", + "fan_social_id", + "created_at", + "updated_at", + "latest_engagement", + "latest_engagement_id", +] as const; +type SocialFansOrderableColumn = (typeof SOCIAL_FANS_ORDERABLE_COLUMNS)[number]; + +interface SelectSocialFansParams { + social_ids?: string[]; + orderBy?: SocialFansOrderableColumn; + orderDirection?: "asc" | "desc"; +} + +export const selectSocialFans = async ( + params?: SelectSocialFansParams, +): Promise => { + let query = supabase.from("social_fans").select(` + *, + artist_social:socials!social_fans_artist_social_id_fkey( + id, + username, + bio, + followerCount, + followingCount, + avatar, + profile_url, + region, + updated_at + ), + fan_social:socials!social_fans_fan_social_id_fkey( + id, + username, + bio, + followerCount, + followingCount, + avatar, + profile_url, + region, + updated_at + ), + latest_engagement_comment:post_comments!social_fans_latest_engagement_id_fkey( + id, + comment, + commented_at, + post_id, + social_id + ) + `); + + if (params?.social_ids && params.social_ids.length > 0) { + query = query.in("artist_social_id", params.social_ids); + } + + // Only allow ordering by top-level columns + if (params?.orderBy && SOCIAL_FANS_ORDERABLE_COLUMNS.includes(params.orderBy)) { + query = query.order(params.orderBy, { + ascending: params.orderDirection !== "desc", + nullsFirst: false, + }); + } + + const { data, error } = await query; + + if (error) { + console.error("Error selecting social fans:", error); + throw error; + } + + return (data || []) as SocialFanWithDetails[]; +}; diff --git a/lib/supabase/socials/insertSocials.ts b/lib/supabase/socials/insertSocials.ts new file mode 100644 index 000000000..f149a0a3c --- /dev/null +++ b/lib/supabase/socials/insertSocials.ts @@ -0,0 +1,21 @@ +import supabase from "../serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts one or more social records. + * + * @param socials - Array of social data to insert + * @returns Array of inserted social records + */ +export async function insertSocials( + socials: TablesInsert<"socials">[], +): Promise[]> { + const { data, error } = await supabase.from("socials").insert(socials).select("*"); + + if (error) { + console.error("[ERROR] insertSocials:", error); + return []; + } + + return data || []; +} diff --git a/lib/supabase/socials/selectSocials.ts b/lib/supabase/socials/selectSocials.ts index da0b91ca7..ee04075b0 100644 --- a/lib/supabase/socials/selectSocials.ts +++ b/lib/supabase/socials/selectSocials.ts @@ -3,6 +3,7 @@ import supabase from "../serverClient"; type SelectSocialsParams = { id?: string; + profile_url?: string; }; /** @@ -21,6 +22,10 @@ export async function selectSocials( query = query.eq("id", params.id); } + if (params.profile_url) { + query = query.eq("profile_url", params.profile_url); + } + const { data, error } = await query; if (error) { diff --git a/lib/supabase/youtube_tokens/selectYouTubeTokens.ts b/lib/supabase/youtube_tokens/selectYouTubeTokens.ts new file mode 100644 index 000000000..2724de1cd --- /dev/null +++ b/lib/supabase/youtube_tokens/selectYouTubeTokens.ts @@ -0,0 +1,24 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Retrieves YouTube tokens for an artist account. + * + * @param artist_account_id - The artist account ID + * @returns The YouTube tokens record, or null if not found + */ +export async function selectYouTubeTokens( + artist_account_id: string, +): Promise | null> { + const { data, error } = await supabase + .from("youtube_tokens") + .select("*") + .eq("artist_account_id", artist_account_id) + .single(); + + if (error || !data) { + return null; + } + + return data; +} diff --git a/lib/youtube/isTokenExpired.ts b/lib/youtube/isTokenExpired.ts new file mode 100644 index 000000000..81a044a6c --- /dev/null +++ b/lib/youtube/isTokenExpired.ts @@ -0,0 +1,13 @@ +/** + * Checks if a YouTube token is expired or about to expire (within 1 minute safety buffer). + * + * @param expiresAt - The expiration timestamp (ISO string) + * @returns True if token is expired or about to expire + */ +export function isTokenExpired(expiresAt: string): boolean { + const expirationTime = new Date(expiresAt).getTime(); + const now = Date.now(); + const oneMinuteInMs = 60 * 1000; + return expirationTime <= now + oneMinuteInMs; +} +