diff --git a/agent/package.json b/agent/package.json index dc6d244551..780a3b5b85 100644 --- a/agent/package.json +++ b/agent/package.json @@ -108,6 +108,7 @@ "@elizaos/plugin-pyth-data": "workspace:*", "@elizaos/plugin-openai": "workspace:*", "@elizaos/plugin-devin": "workspace:*", + "@elizaos/plugin-beatsfoundation": "workspace:*", "readline": "1.3.0", "ws": "8.18.0", "yargs": "17.7.2" diff --git a/agent/src/index.ts b/agent/src/index.ts index eeb4d2828a..d578059b63 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -55,6 +55,7 @@ import { autonomePlugin } from "@elizaos/plugin-autonome"; import { availPlugin } from "@elizaos/plugin-avail"; import { avalanchePlugin } from "@elizaos/plugin-avalanche"; import { b2Plugin } from "@elizaos/plugin-b2"; +import { beatsfoundationPlugin } from "@elizaos/plugin-beatsfoundation"; import { binancePlugin } from "@elizaos/plugin-binance"; import { birdeyePlugin } from "@elizaos/plugin-birdeye"; import { @@ -1002,6 +1003,7 @@ export async function createAgent( ? abstractPlugin : null, getSecret(character, "B2_PRIVATE_KEY") ? b2Plugin : null, + getSecret(character, "BEATSFOUNDATION_API_KEY") ? beatsfoundationPlugin : null, getSecret(character, "BINANCE_API_KEY") && getSecret(character, "BINANCE_SECRET_KEY") ? binancePlugin diff --git a/packages/plugin-beatsfoundation/README.md b/packages/plugin-beatsfoundation/README.md new file mode 100644 index 0000000000..cfc6b97933 --- /dev/null +++ b/packages/plugin-beatsfoundation/README.md @@ -0,0 +1,118 @@ +# @elizaos/plugin-beatsfoundation + +A plugin for Eliza that enables AI music generation using the Beats Foundation API. + +## Features +- AI-powered music generation from text prompts +- Support for multiple genres and moods +- Optional lyrics input +- Instrumental track generation +- Natural language processing for music generation requests +- Access to the Beats Foundation song library + +## Installation +```bash +npm install @elizaos/plugin-beatsfoundation +``` + +## Configuration +1. Get your API key from [Beats Foundation](https://www.beatsfoundation.com) +2. Set up your environment variables: +```bash +BEATS_FOUNDATION_API_KEY=your_api_key +``` +3. Register the plugin in your Eliza configuration: +```typescript +import { BeatsFoundationPlugin } from "@elizaos/plugin-beatsfoundation"; +// In your Eliza configuration +plugins: [ + new BeatsFoundationPlugin(), + // ... other plugins +]; +``` + +## Usage +The plugin responds to natural language queries for music generation. Here are some examples: +```plaintext +"Generate a happy pop song about summer" +"Create an instrumental jazz track" +"Make me a rock song with these lyrics: [lyrics]" +"List recent AI-generated songs" +``` + +### Supported Parameters +The plugin supports various music generation parameters including: +- Genre (pop, rock, jazz, etc.) +- Mood (happy, sad, energetic, etc.) +- Lyrics (optional) +- Instrumental toggle (boolean, will generate instrumental track or with vocals) +- Custom prompts (up to 200 characters) + +### Available Actions +#### GENERATE_SONG +Generates a new AI song based on provided parameters. +```typescript +// Example response format +{ + id: "song_123", + title: "Summer Vibes", + audio_url: "https://...", + streams: 0, + upvote_count: 0, + song_url: "https://...", + username: "user123" +} +``` + +#### LIST_SONGS +Retrieves a paginated list of generated songs. + +## API Reference +For detailed API documentation, visit [docs.beatsfoundation.com](https://docs.beatsfoundation.com) + +### Environment Variables +| Variable | Description | Required | +| -------- | ----------- | -------- | +| BEATS_FOUNDATION_API_KEY | Your Beats Foundation API key | Yes | + +### Types +```typescript +interface GenerateSongRequest { + prompt: string; + lyrics?: string; + genre?: string; + mood?: string; + isInstrumental?: boolean; +} + +interface Song { + id: string; + title: string; + audio_url: string; + streams: number; + upvote_count: number; + song_url: string; + username: string; +} +``` + +## Error Handling +The plugin includes comprehensive error handling for: +- Invalid API keys +- Rate limiting (2 generations per hour) +- Network timeouts +- Invalid generation parameters +- Server errors + +## Rate Limits +The Beats Foundation API is currently free to use and has a rate limit of 2 song generations per hour per API key. Public endpoints like song listing and retrieval are not rate limited. + +## Support +For support, please: +- Visit [docs.beatsfoundation.com](https://docs.beatsfoundation.com) +- Open an issue in the repository +- Join our Discord community + +## Links +- [Beats Foundation API Documentation](https://docs.beatsfoundation.com) +- [GitHub Repository](https://github.com/elizaos/eliza/tree/main/packages/plugin-beatsfoundation) diff --git a/packages/plugin-beatsfoundation/package.json b/packages/plugin-beatsfoundation/package.json new file mode 100644 index 0000000000..eb18006620 --- /dev/null +++ b/packages/plugin-beatsfoundation/package.json @@ -0,0 +1,33 @@ +{ + "name": "@elizaos/plugin-beatsfoundation", + "version": "0.0.1", + "description": "Beats Foundation plugin for ElizaOS", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --format esm --dts", + "clean": "rimraf dist", + "dev": "tsup --format esm --dts --watch", + "lint": "eslint src --ext .ts", + "test": "jest" + }, + "dependencies": { + "@elizaos/core": "workspace:*", + "axios": "^1.6.7" + }, + "devDependencies": { + "@types/node": "^20.11.19", + "typescript": "^5.3.3", + "tsup": "^8.0.0" + }, + "peerDependencies": { + "@elizaos/core": "workspace:*" + } +} diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/examples.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/examples.ts new file mode 100644 index 0000000000..4d44c3d35b --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/examples.ts @@ -0,0 +1,35 @@ +import { ActionExample } from "@elizaos/core"; + +export const createSongExamples: ActionExample[][] = [ + [ + { + input: "Create a happy pop song about summer", + output: { + prompt: "Create a happy pop song about summer", + genre: "pop", + mood: "happy" + } + } + ], + [ + { + input: "Generate an instrumental jazz piece with a relaxing vibe", + output: { + prompt: "Generate an instrumental jazz piece with a relaxing vibe", + genre: "jazz", + mood: "relaxing", + isInstrumental: true + } + } + ], + [ + { + input: "Make a rock song with these lyrics: Life is a highway, I wanna ride it all night long", + output: { + prompt: "Make a rock song", + genre: "rock", + lyrics: "Life is a highway, I wanna ride it all night long" + } + } + ] +]; diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/index.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/index.ts new file mode 100644 index 0000000000..7ed514858a --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/index.ts @@ -0,0 +1,115 @@ +import { + composeContext, + elizaLogger, + generateObject, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + type Action, +} from "@elizaos/core"; +import axios from 'axios'; +import { validateBeatsFoundationConfig } from "../../environment.js"; +import { sanitizeCreateSongContent } from "../../utils/content-sanitizer.js"; +import { createSafeResponse } from "../../utils/response-sanitizer.js"; +import { createSongExamples } from "./examples.js"; +import { createSongService } from "./service.js"; +import { createSongTemplate } from "./template.js"; +import { CreateSongContent } from "./types.js"; +import { isCreateSongContent } from "./validation.js"; + +export default { + name: "CREATE_SONG", + similes: ["GENERATE_SONG", "MAKE_SONG", "COMPOSE_SONG"], + validate: async (runtime: IAgentRuntime, _message: Memory) => { + await validateBeatsFoundationConfig(runtime); + return true; + }, + description: "Create a new song using Beats Foundation", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting Beats Foundation CREATE_SONG handler..."); + + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + try { + // Compose and generate content + const context = composeContext({ + state, + template: createSongTemplate, + }); + + const content = (await generateObject({ + runtime, + context, + modelClass: ModelClass.SMALL, + })) as unknown as CreateSongContent; + + // Validate and sanitize content + if (!isCreateSongContent(content)) { + throw new Error("Invalid song creation content"); + } + + // Sanitize content + const sanitizedContent = sanitizeCreateSongContent(content); + + // Get config with validation + const config = await validateBeatsFoundationConfig(runtime); + const songService = createSongService(config.BEATSFOUNDATION_API_KEY); + + try { + // Create cancel token for request cancellation + const source = axios.CancelToken.source(); + const song = await songService.createSong(sanitizedContent, { cancelToken: source.token }); + elizaLogger.success( + `Song created successfully! Title: ${song.title}` + ); + + if (callback) { + callback({ + text: `Created new song: ${song.title}`, + content: createSafeResponse( + song, + Boolean(sanitizedContent.lyrics), + sanitizedContent.genre, + sanitizedContent.mood, + sanitizedContent.isInstrumental + ), + }); + } + + return true; + } catch (error: any) { + elizaLogger.error("Error in CREATE_SONG handler:", error); + if (callback) { + callback({ + text: `Error creating song: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + } catch (error: any) { + elizaLogger.error("Error in CREATE_SONG handler:", error); + if (callback) { + callback({ + text: `Error creating song: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + }, + examples: createSongExamples, +} satisfies Action; diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/service.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/service.ts new file mode 100644 index 0000000000..3b91ebf049 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/service.ts @@ -0,0 +1,58 @@ +import axios, { CancelToken } from 'axios'; +import { Song } from '../../types.js'; +import { CreateSongContent, CreateSongOptions } from './types.js'; + +export function createSongService(apiKey: string) { + // Create axios instance with retry configuration + const client = axios.create(); + + // Will be configured once axios-retry package is added + /* + axiosRetry(client, { + retries: 3, + retryDelay: axiosRetry.exponentialDelay, + retryCondition: (error) => { + return axiosRetry.isNetworkOrIdempotentRequestError(error) + || error.code === 'ECONNABORTED'; + } + }); + */ + + return { + createSong: async (content: CreateSongContent, options?: CreateSongOptions): Promise => { + try { + const response = await client.post( + 'https://www.beatsfoundation.com/api/songs', + content, + { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 300000, // 5 minutes timeout for song generation + } + ); + return response.data.song; + } catch (error: any) { + // Handle cancellation + if (axios.isCancel(error)) { + throw new Error('Song creation request was cancelled'); + } + + // Handle API errors + if (error.response) { + throw new Error(`Beats Foundation API Error: ${error.response.data.error || error.response.status}`); + } + + // Handle network errors + if (error.code === 'ECONNABORTED') { + throw new Error('Song creation request timed out'); + } + + // Handle other errors + throw error; + } + } + }; +} diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/template.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/template.ts new file mode 100644 index 0000000000..fa06c8f69d --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/template.ts @@ -0,0 +1,11 @@ +export const createSongTemplate = ` +Given the conversation context, extract the song creation parameters. +Return a JSON object with the following structure: +{ + "prompt": string (required), + "lyrics": string (optional), + "genre": string (optional), + "mood": string (optional), + "isInstrumental": boolean (optional) +} +`; \ No newline at end of file diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/types.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/types.ts new file mode 100644 index 0000000000..d4860c4e50 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/types.ts @@ -0,0 +1,14 @@ +import { Content } from "@elizaos/core"; +import { CancelToken } from 'axios'; + +export interface CreateSongContent extends Content { + prompt: string; + lyrics?: string; + genre?: string; + mood?: string; + isInstrumental?: boolean; +} + +export interface CreateSongOptions { + cancelToken?: CancelToken; +} diff --git a/packages/plugin-beatsfoundation/src/actions/CreateSong/validation.ts b/packages/plugin-beatsfoundation/src/actions/CreateSong/validation.ts new file mode 100644 index 0000000000..48e91f6f66 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/CreateSong/validation.ts @@ -0,0 +1,29 @@ +import { CreateSongContent } from "./types"; + +const MAX_PROMPT_LENGTH = 1000; +const MAX_LYRICS_LENGTH = 5000; + +export function isCreateSongContent(content: unknown): content is CreateSongContent { + if (!content || typeof content !== "object" || Array.isArray(content) || content === null) return false; + const c = content as Record; + + // Required field validation + if (typeof c.prompt !== "string" || c.prompt.length === 0 || c.prompt.length > MAX_PROMPT_LENGTH) return false; + + // Optional field validation + if (c.lyrics !== undefined) { + if (typeof c.lyrics !== "string" || c.lyrics.length > MAX_LYRICS_LENGTH) return false; + } + + if (c.genre !== undefined) { + if (typeof c.genre !== "string" || c.genre.trim().length === 0) return false; + } + + if (c.mood !== undefined) { + if (typeof c.mood !== "string" || c.mood.trim().length === 0) return false; + } + + if (c.isInstrumental !== undefined && typeof c.isInstrumental !== "boolean") return false; + + return true; +} diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/examples.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/examples.ts new file mode 100644 index 0000000000..2a622bf47a --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/examples.ts @@ -0,0 +1,28 @@ +import { ActionExample } from "@elizaos/core"; + +export const getSongByIdExamples: ActionExample[][] = [ + [ + { + input: "Get song with ID abc123", + output: { + songId: "abc123" + } + } + ], + [ + { + input: "Show me song xyz789", + output: { + songId: "xyz789" + } + } + ], + [ + { + input: "Fetch song details for def456", + output: { + songId: "def456" + } + } + ] +]; diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/index.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/index.ts new file mode 100644 index 0000000000..555a47fa21 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/index.ts @@ -0,0 +1,88 @@ +import { + composeContext, + elizaLogger, + generateObject, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + type Action, +} from "@elizaos/core"; +import { validateBeatsFoundationConfig } from "../../environment.js"; +import { handleBeatsFoundationError } from "../../utils/error-handlers.js"; +import { getSongByIdExamples } from "./examples.js"; +import { createSongService } from "./service.js"; +import { getSongByIdTemplate } from "./template.js"; +import { GetSongByIdContent } from "./types.js"; +import { isGetSongByIdContent } from "./validation.js"; + +export default { + name: "GET_SONG_BY_ID", + similes: ["FETCH_SONG", "GET_SONG", "RETRIEVE_SONG"], + validate: async (runtime: IAgentRuntime, _message: Memory) => { + await validateBeatsFoundationConfig(runtime); + return true; + }, + description: "Get a song by its ID from Beats Foundation", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting Beats Foundation GET_SONG_BY_ID handler..."); + + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + try { + // Compose and generate content + const context = composeContext({ + state, + template: getSongByIdTemplate, + }); + + const content = (await generateObject({ + runtime, + context, + modelClass: ModelClass.SMALL, + })) as unknown as GetSongByIdContent; + + // Validate content + if (!isGetSongByIdContent(content)) { + throw new Error("Invalid song ID content"); + } + + // Get config with validation + const config = await validateBeatsFoundationConfig(runtime); + const songService = createSongService(config.BEATSFOUNDATION_API_KEY); + + try { + const song = await songService.getSongById(content.songId); + elizaLogger.success( + `Song retrieved successfully! ID: ${content.songId}, Title: ${song.title}` + ); + + if (callback) { + callback({ + text: `Retrieved song: ${song.title}`, + content: song, + }); + } + + return true; + } catch (error: any) { + return handleBeatsFoundationError(error, "GET_SONG_BY_ID", callback); + } + } catch (error: any) { + return handleBeatsFoundationError(error, "GET_SONG_BY_ID", callback); + } + }, + examples: getSongByIdExamples, +} satisfies Action; diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/service.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/service.ts new file mode 100644 index 0000000000..ca69f57f48 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/service.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { Song } from '../../types.js'; + +export function createSongService(apiKey: string) { + return { + getSongById: async (songId: string): Promise => { + try { + const response = await axios.get( + `https://www.beatsfoundation.com/api/songs/${songId}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + return response.data.song; + } catch (error: any) { + if (error.response) { + throw new Error(`Beats Foundation API Error: ${error.response.data.error || error.response.status}`); + } + throw error; + } + } + }; +} \ No newline at end of file diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/template.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/template.ts new file mode 100644 index 0000000000..4c670e56b7 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/template.ts @@ -0,0 +1,22 @@ +export const getSongByIdTemplate = ` +You are helping to retrieve a song by its ID from the Beats Foundation platform. +Based on the user's request, extract the song ID they want to retrieve. + +Example inputs: +"Get song with ID abc123" +"Show me song xyz789" +"Fetch song details for def456" + +Example outputs: +{ + "songId": "abc123" +} +{ + "songId": "xyz789" +} +{ + "songId": "def456" +} + +Remember to only include the song ID in the output. +`; diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/types.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/types.ts new file mode 100644 index 0000000000..e35562db47 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/types.ts @@ -0,0 +1,5 @@ +import { Content } from "@elizaos/core"; + +export interface GetSongByIdContent extends Content { + songId: string; +} diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongById/validation.ts b/packages/plugin-beatsfoundation/src/actions/GetSongById/validation.ts new file mode 100644 index 0000000000..ca890ad155 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongById/validation.ts @@ -0,0 +1,10 @@ +import { GetSongByIdContent } from "./types"; + +export function isGetSongByIdContent(content: unknown): content is GetSongByIdContent { + if (!content || typeof content !== "object" || Array.isArray(content) || content === null) return false; + const c = content as Record; + + if (typeof c.songId !== "string" || c.songId.trim().length === 0) return false; + + return true; +} diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/examples.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/examples.ts new file mode 100644 index 0000000000..f9bb997a2d --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/examples.ts @@ -0,0 +1,27 @@ +import { ActionExample } from "@elizaos/core"; + +export const getSongsExamples: ActionExample[][] = [ + [ + { + input: "Show me all songs", + output: {} + } + ], + [ + { + input: "Get the first 10 songs", + output: { + limit: 10 + } + } + ], + [ + { + input: "Show me the next 20 songs starting from position 40", + output: { + limit: 20, + offset: 40 + } + } + ] +]; diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/index.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/index.ts new file mode 100644 index 0000000000..3c849e3628 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/index.ts @@ -0,0 +1,101 @@ +import { + composeContext, + elizaLogger, + generateObject, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + type Action, +} from "@elizaos/core"; +import { validateBeatsFoundationConfig } from "../../environment.js"; +import { getSongsExamples } from "./examples.js"; +import { createSongsService } from "./service.js"; +import { getSongsTemplate } from "./template.js"; +import { GetSongsContent } from "./types.js"; +import { isGetSongsContent } from "./validation.js"; + +export default { + name: "GET_SONGS", + similes: ["LIST_SONGS", "FETCH_SONGS", "SHOW_SONGS"], + validate: async (runtime: IAgentRuntime, _message: Memory) => { + await validateBeatsFoundationConfig(runtime); + return true; + }, + description: "Get a list of songs from Beats Foundation", + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State, + _options: { [key: string]: unknown }, + callback?: HandlerCallback + ): Promise => { + elizaLogger.log("Starting Beats Foundation GET_SONGS handler..."); + + // Initialize or update state + if (!state) { + state = (await runtime.composeState(message)) as State; + } else { + state = await runtime.updateRecentMessageState(state); + } + + try { + // Compose and generate content + const context = composeContext({ + state, + template: getSongsTemplate, + }); + + const content = (await generateObject({ + runtime, + context, + modelClass: ModelClass.SMALL, + })) as unknown as GetSongsContent; + + // Validate content + if (!isGetSongsContent(content)) { + throw new Error("Invalid songs content"); + } + + // Get config with validation + const config = await validateBeatsFoundationConfig(runtime); + const songsService = createSongsService(config.BEATSFOUNDATION_API_KEY); + + try { + const result = await songsService.getSongs(content.limit, content.offset); + elizaLogger.success( + `Songs retrieved successfully! Count: ${result.data.length}, Total: ${result.pagination.total}` + ); + + if (callback) { + callback({ + text: `Retrieved ${result.data.length} songs (Total: ${result.pagination.total})`, + content: result, + }); + } + + return true; + } catch (error: any) { + elizaLogger.error("Error in GET_SONGS handler:", error); + if (callback) { + callback({ + text: `Error fetching songs: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + } catch (error: any) { + elizaLogger.error("Error in GET_SONGS handler:", error); + if (callback) { + callback({ + text: `Error fetching songs: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } + }, + examples: getSongsExamples, +} satisfies Action; diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/service.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/service.ts new file mode 100644 index 0000000000..018966e71d --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/service.ts @@ -0,0 +1,40 @@ +import axios from 'axios'; +import { PaginatedSongsResponse } from '../../types.js'; + +export function createSongsService(apiKey: string) { + return { + getSongs: async (limit?: number, offset?: number): Promise => { + try { + const params: Record = { + limit: Math.min(limit || 10, 100), // Default 10, max 100 + offset: Math.max(offset || 0, 0) // Default 0, min 0 + }; + + const response = await axios.get( + 'https://www.beatsfoundation.com/api/songs', + { + params, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + const { songs, total } = response.data; + return { + data: songs, + pagination: { + total, + limit: limit || 10, // Default limit + offset: offset || 0 // Default offset + } + }; + } catch (error: any) { + if (error.response) { + throw new Error(`Beats Foundation API Error: ${error.response.data.error || error.response.status}`); + } + throw error; + } + } + }; +} diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/template.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/template.ts new file mode 100644 index 0000000000..fdad32eaaf --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/template.ts @@ -0,0 +1,8 @@ +export const getSongsTemplate = ` +Given the conversation context, extract any pagination parameters for retrieving songs. +Return a JSON object with the following optional structure: +{ + "limit": number (optional), + "offset": number (optional) +} +`; \ No newline at end of file diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/types.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/types.ts new file mode 100644 index 0000000000..d522c51980 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/types.ts @@ -0,0 +1,6 @@ +import { Content } from "@elizaos/core"; + +export interface GetSongsContent extends Content { + limit?: number; + offset?: number; +} \ No newline at end of file diff --git a/packages/plugin-beatsfoundation/src/actions/GetSongs/validation.ts b/packages/plugin-beatsfoundation/src/actions/GetSongs/validation.ts new file mode 100644 index 0000000000..0cc7c92d24 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/actions/GetSongs/validation.ts @@ -0,0 +1,23 @@ +import { GetSongsContent } from "./types"; + +const MAX_LIMIT = 100; +const MIN_OFFSET = 0; + +export function isGetSongsContent(content: unknown): content is GetSongsContent { + if (!content || typeof content !== "object" || Array.isArray(content) || content === null) return false; + const c = content as Record; + + // Validate limit + if (c.limit !== undefined) { + if (typeof c.limit !== "number") return false; + if (c.limit < 0 || c.limit > MAX_LIMIT) return false; + } + + // Validate offset + if (c.offset !== undefined) { + if (typeof c.offset !== "number") return false; + if (c.offset < MIN_OFFSET) return false; + } + + return true; +} diff --git a/packages/plugin-beatsfoundation/src/environment.ts b/packages/plugin-beatsfoundation/src/environment.ts new file mode 100644 index 0000000000..7197778c2f --- /dev/null +++ b/packages/plugin-beatsfoundation/src/environment.ts @@ -0,0 +1,33 @@ +import { IAgentRuntime } from "@elizaos/core"; +import { z } from "zod"; + +export const beatsFoundationEnvSchema = z.object({ + BEATSFOUNDATION_API_KEY: z + .string() + .min(1, "BeatsFoundation API key is required"), +}); + +export type BeatsFoundationConfig = z.infer; + +export async function validateBeatsFoundationConfig( + runtime: IAgentRuntime +): Promise { + try { + const config = { + BEATSFOUNDATION_API_KEY: runtime.getSetting("BEATSFOUNDATION_API_KEY"), + }; + + return beatsFoundationEnvSchema.parse(config); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => `${err.path.join(".")}: ${err.message}`) + .join("\n"); + throw new Error( + `Beats Foundation configuration validation failed:\n${errorMessages}` + ); + } + throw error; + } +} + diff --git a/packages/plugin-beatsfoundation/src/index.ts b/packages/plugin-beatsfoundation/src/index.ts new file mode 100644 index 0000000000..46157f262a --- /dev/null +++ b/packages/plugin-beatsfoundation/src/index.ts @@ -0,0 +1,15 @@ +import { Plugin } from "@elizaos/core"; +import GetSongById from './actions/GetSongById'; +import GetSongs from './actions/GetSongs'; +import CreateSong from './actions/CreateSong'; + +export const beatsfoundationPlugin: Plugin = { + name: 'beatsfoundation', + description: 'Beats Foundation plugin for ElizaOS', + clients: [], + actions: [GetSongById, GetSongs, CreateSong], + services: [], + providers: [], +}; + +export default beatsfoundationPlugin; \ No newline at end of file diff --git a/packages/plugin-beatsfoundation/src/types.ts b/packages/plugin-beatsfoundation/src/types.ts new file mode 100644 index 0000000000..eda1eefa30 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/types.ts @@ -0,0 +1,28 @@ +export interface PaginatedResponse { + data: T[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +} + +export interface GenerateSongRequest { + prompt: string; + lyrics?: string; + genre?: string; + mood?: string; + isInstrumental?: boolean; +} + +export interface Song { + id: string; + title: string; + audio_url: string; + streams: number; + upvote_count: number; + song_url: string; + username: string; +} + +export type PaginatedSongsResponse = PaginatedResponse; diff --git a/packages/plugin-beatsfoundation/src/utils/content-sanitizer.ts b/packages/plugin-beatsfoundation/src/utils/content-sanitizer.ts new file mode 100644 index 0000000000..ac6d85165a --- /dev/null +++ b/packages/plugin-beatsfoundation/src/utils/content-sanitizer.ts @@ -0,0 +1,43 @@ +import { CreateSongContent } from "../actions/CreateSong/types.js"; + +const MAX_PROMPT_LENGTH = 1000; +const MAX_LYRICS_LENGTH = 5000; +const VALID_GENRES = [ + "pop", "rock", "jazz", "classical", "electronic", "hip-hop", "rap", + "r&b", "country", "folk", "indie", "metal", "blues", "reggae" +]; +const VALID_MOODS = [ + "happy", "sad", "energetic", "calm", "angry", "peaceful", "romantic", + "mysterious", "dark", "uplifting", "melancholic", "nostalgic" +]; + +export function sanitizeCreateSongContent(content: CreateSongContent): CreateSongContent { + return { + ...content, + prompt: sanitizeString(content.prompt, MAX_PROMPT_LENGTH), + lyrics: content.lyrics ? sanitizeString(content.lyrics, MAX_LYRICS_LENGTH) : undefined, + genre: content.genre ? sanitizeGenre(content.genre) : undefined, + mood: content.mood ? sanitizeMood(content.mood) : undefined, + isInstrumental: Boolean(content.isInstrumental), + }; +} + +function sanitizeString(str: string, maxLength: number): string { + // Trim whitespace and normalize spaces + let sanitized = str.trim().replace(/\s+/g, ' '); + // Truncate if too long + if (sanitized.length > maxLength) { + sanitized = sanitized.slice(0, maxLength); + } + return sanitized; +} + +function sanitizeGenre(genre: string): string | undefined { + const normalized = genre.toLowerCase().trim(); + return VALID_GENRES.find(g => g === normalized); +} + +function sanitizeMood(mood: string): string | undefined { + const normalized = mood.toLowerCase().trim(); + return VALID_MOODS.find(m => m === normalized); +} diff --git a/packages/plugin-beatsfoundation/src/utils/error-handlers.ts b/packages/plugin-beatsfoundation/src/utils/error-handlers.ts new file mode 100644 index 0000000000..4d87bffe51 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/utils/error-handlers.ts @@ -0,0 +1,17 @@ +import { elizaLogger } from "@elizaos/core"; +import type { HandlerCallback } from "@elizaos/core"; + +export function handleBeatsFoundationError( + error: any, + handlerName: string, + callback?: HandlerCallback +): boolean { + elizaLogger.error(`Error in ${handlerName} handler:`, error); + if (callback) { + callback({ + text: `Error in ${handlerName.toLowerCase()}: ${error.message}`, + content: { error: error.message }, + }); + } + return false; +} diff --git a/packages/plugin-beatsfoundation/src/utils/response-sanitizer.ts b/packages/plugin-beatsfoundation/src/utils/response-sanitizer.ts new file mode 100644 index 0000000000..ac872eed70 --- /dev/null +++ b/packages/plugin-beatsfoundation/src/utils/response-sanitizer.ts @@ -0,0 +1,38 @@ +import { Song } from '../types.js'; + +/** + * Fields considered sensitive in song creation requests: + * - prompt: May contain user-specific context or private information + * - lyrics: Could contain personal or copyrighted content + * - genre/mood: While not highly sensitive, could indicate user preferences + * + * Safe fields to expose: + * - title: Public song title + * - id: Public identifier + * - audio_url: Public URL to the generated audio + * - song_url: Public URL to the song page + * - streams/upvote_count: Public metrics + * - username: Public creator attribution + */ + +export interface SafeSongResponse { + song: Song; + metadata: { + hasLyrics: boolean; + genre?: string; + mood?: string; + isInstrumental: boolean; + }; +} + +export function createSafeResponse(song: Song, hasLyrics: boolean, genre?: string, mood?: string, isInstrumental = false): SafeSongResponse { + return { + song, + metadata: { + hasLyrics, + genre, + mood, + isInstrumental + } + }; +} diff --git a/packages/plugin-beatsfoundation/tsconfig.json b/packages/plugin-beatsfoundation/tsconfig.json new file mode 100644 index 0000000000..9d6765b653 --- /dev/null +++ b/packages/plugin-beatsfoundation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "baseUrl": ".", + "paths": { + "@elizaos/core": ["../core/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugin-beatsfoundation/tsup.config.ts b/packages/plugin-beatsfoundation/tsup.config.ts new file mode 100644 index 0000000000..ba44a7c49c --- /dev/null +++ b/packages/plugin-beatsfoundation/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], + external: [ + "dotenv", + "fs", + "path", + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + ], +}); + diff --git a/turbo.json b/turbo.json index 3a0492e3b8..6bcc938225 100644 --- a/turbo.json +++ b/turbo.json @@ -61,6 +61,10 @@ "outputs": ["dist/**"], "dependsOn": ["@elizaos/plugin-tee#build"] }, + "@elizaos/plugin-beatsfoundation#build": { + "outputs": ["dist/**"], + "dependsOn": ["@elizaos/core#build"] + }, "eliza-docs#build": { "outputs": ["build/**"] },