diff --git a/.env.example b/.env.example index 1540f43c516..37714174998 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,12 @@ STARKNET_ADDRESS= STARKNET_PRIVATE_KEY= STARKNET_RPC_URL= + +# Farcaster +FARCASTER_HUB_URL= +FARCASTER_FID= +FARCASTER_PRIVATE_KEY= + # Coinbase COINBASE_COMMERCE_KEY= # from coinbase developer portal COINBASE_API_KEY= # from coinbase developer portal @@ -112,6 +118,7 @@ ZEROG_EVM_RPC= ZEROG_PRIVATE_KEY= ZEROG_FLOW_ADDRESS= + # Coinbase Commerce COINBASE_COMMERCE_KEY= diff --git a/packages/client-farcaster/package.json b/packages/client-farcaster/package.json new file mode 100644 index 00000000000..13b2e94a08a --- /dev/null +++ b/packages/client-farcaster/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ai16z/client-farcaster", + "version": "0.0.1", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@ai16z/eliza": "workspace:*", + "@farcaster/hub-nodejs": "^0.12.7", + "viem": "^2.21.47" + }, + "devDependencies": { + "tsup": "^8.3.5" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --watch" + }, + "peerDependencies": {} +} diff --git a/packages/client-farcaster/src/actions.ts b/packages/client-farcaster/src/actions.ts new file mode 100644 index 00000000000..1d44557e800 --- /dev/null +++ b/packages/client-farcaster/src/actions.ts @@ -0,0 +1,79 @@ +import { CastId, FarcasterNetwork, Signer } from "@farcaster/hub-nodejs"; +import { CastType, makeCastAdd } from "@farcaster/hub-nodejs"; +import type { FarcasterClient } from "./client"; +import type { Content, IAgentRuntime, Memory, UUID } from "@ai16z/eliza"; +import type { Cast, Profile } from "./types"; +import { createCastMemory } from "./memory"; +import { splitPostContent } from "./utils"; + +export async function sendCast({ + client, + runtime, + content, + roomId, + inReplyTo, + signer, + profile, +}: { + profile: Profile; + client: FarcasterClient; + runtime: IAgentRuntime; + content: Content; + roomId: UUID; + signer: Signer; + inReplyTo?: CastId; +}): Promise<{ memory: Memory; cast: Cast }[]> { + const chunks = splitPostContent(content.text); + const sent: Cast[] = []; + let parentCastId = inReplyTo; + + for (const chunk of chunks) { + const castAddMessageResult = await makeCastAdd( + { + text: chunk, + embeds: [], + embedsDeprecated: [], + mentions: [], + mentionsPositions: [], + type: CastType.CAST, // TODO: check CastType.LONG_CAST + parentCastId, + }, + { + fid: profile.fid, + network: FarcasterNetwork.MAINNET, + }, + signer + ); + + if (castAddMessageResult.isErr()) { + throw castAddMessageResult.error; + } + + await client.submitMessage(castAddMessageResult.value); + + const cast = await client.loadCastFromMessage( + castAddMessageResult.value + ); + + sent.push(cast); + + parentCastId = { + fid: cast.profile.fid, + hash: cast.message.hash, + }; + + // TODO: check rate limiting + // Wait a bit between tweets to avoid rate limiting issues + // await wait(1000, 2000); + } + + return sent.map((cast) => ({ + cast, + memory: createCastMemory({ + roomId, + agentId: runtime.agentId, + userId: runtime.agentId, + cast, + }), + })); +} diff --git a/packages/client-farcaster/src/client.ts b/packages/client-farcaster/src/client.ts new file mode 100644 index 00000000000..c63c2b6f4d6 --- /dev/null +++ b/packages/client-farcaster/src/client.ts @@ -0,0 +1,193 @@ +import { IAgentRuntime } from "@ai16z/eliza"; +import { + CastAddMessage, + CastId, + FidRequest, + getInsecureHubRpcClient, + getSSLHubRpcClient, + HubRpcClient, + isCastAddMessage, + isUserDataAddMessage, + Message, + MessagesResponse, +} from "@farcaster/hub-nodejs"; +import { Cast, Profile } from "./types"; +import { toHex } from "viem"; +import { populateMentions } from "./utils"; + +export class FarcasterClient { + runtime: IAgentRuntime; + farcaster: HubRpcClient; + + cache: Map; + + constructor(opts: { + runtime: IAgentRuntime; + url: string; + ssl: boolean; + cache: Map; + }) { + this.cache = opts.cache; + this.runtime = opts.runtime; + this.farcaster = opts.ssl + ? getSSLHubRpcClient(opts.url) + : getInsecureHubRpcClient(opts.url); + } + + async submitMessage(cast: Message, retryTimes?: number): Promise { + const result = await this.farcaster.submitMessage(cast); + + if (result.isErr()) { + throw result.error; + } + } + + async loadCastFromMessage(message: CastAddMessage): Promise { + const profileMap = {}; + + const profile = await this.getProfile(message.data.fid); + + profileMap[message.data.fid] = profile; + + for (const mentionId of message.data.castAddBody.mentions) { + if (profileMap[mentionId]) continue; + profileMap[mentionId] = await this.getProfile(mentionId); + } + + const text = populateMentions( + message.data.castAddBody.text, + message.data.castAddBody.mentions, + message.data.castAddBody.mentionsPositions, + profileMap + ); + + return { + id: toHex(message.hash), + message, + text, + profile, + }; + } + + async getCast(castId: CastId): Promise { + const castHash = toHex(castId.hash); + + if (this.cache.has(`farcaster/cast/${castHash}`)) { + return this.cache.get(`farcaster/cast/${castHash}`); + } + + const cast = await this.farcaster.getCast(castId); + + if (cast.isErr()) { + throw cast.error; + } + + this.cache.set(`farcaster/cast/${castHash}`, cast); + + return cast.value; + } + + async getCastsByFid(request: FidRequest): Promise { + const cast = await this.farcaster.getCastsByFid(request); + if (cast.isErr()) { + throw cast.error; + } + + cast.value.messages.map((cast) => { + this.cache.set(`farcaster/cast/${toHex(cast.hash)}`, cast); + }); + + return cast.value; + } + + async getMentions(request: FidRequest): Promise { + const cast = await this.farcaster.getCastsByMention(request); + if (cast.isErr()) { + throw cast.error; + } + + cast.value.messages.map((cast) => { + this.cache.set(`farcaster/cast/${toHex(cast.hash)}`, cast); + }); + + return cast.value; + } + + async getProfile(fid: number): Promise { + if (this.cache.has(`farcaster/profile/${fid}`)) { + return this.cache.get(`farcaster/profile/${fid}`) as Profile; + } + + const result = await this.farcaster.getUserDataByFid({ + fid: fid, + reverse: true, + }); + + if (result.isErr()) { + throw result.error; + } + + const profile: Profile = { + fid, + name: "", + signer: "0x", + username: "", + }; + + const userDataBodyType = { + 1: "pfp", + 2: "name", + 3: "bio", + 5: "url", + 6: "username", + // 7: "location", + // 8: "twitter", + // 9: "github", + } as const; + + for (const message of result.value.messages) { + if (isUserDataAddMessage(message)) { + if (message.data.userDataBody.type in userDataBodyType) { + const prop = + userDataBodyType[message.data.userDataBody.type]; + profile[prop] = message.data.userDataBody.value; + } + } + } + + const [lastMessage] = result.value.messages; + + if (lastMessage) { + profile.signer = toHex(lastMessage.signer); + } + + this.cache.set(`farcaster/profile/${fid}`, profile); + + return profile; + } + + async getTimeline(request: FidRequest): Promise<{ + timeline: Cast[]; + nextPageToken?: Uint8Array | undefined; + }> { + const timeline: Cast[] = []; + + const results = await this.getCastsByFid(request); + + for (const message of results.messages) { + if (isCastAddMessage(message)) { + this.cache.set( + `farcaster/cast/${toHex(message.hash)}`, + message + ); + + timeline.push(await this.loadCastFromMessage(message)); + } + } + + return { + timeline, + nextPageToken: results.nextPageToken, + }; + } +} diff --git a/packages/client-farcaster/src/index.ts b/packages/client-farcaster/src/index.ts new file mode 100644 index 00000000000..dfbe5b4e329 --- /dev/null +++ b/packages/client-farcaster/src/index.ts @@ -0,0 +1,58 @@ +import { Client, IAgentRuntime } from "@ai16z/eliza"; +import { Signer, NobleEd25519Signer } from "@farcaster/hub-nodejs"; +import { Hex, hexToBytes } from "viem"; +import { FarcasterClient } from "./client"; +import { FarcasterPostManager } from "./post"; +import { FarcasterInteractionManager } from "./interactions"; + +export class FarcasterAgentClient implements Client { + client: FarcasterClient; + posts: FarcasterPostManager; + interactions: FarcasterInteractionManager; + + private signer: Signer; + + constructor( + public runtime: IAgentRuntime, + client?: FarcasterClient + ) { + const cache = new Map(); + + this.signer = new NobleEd25519Signer( + hexToBytes(runtime.getSetting("FARCASTER_PRIVATE_KEY")! as Hex) + ); + + this.client = + client ?? + new FarcasterClient({ + runtime, + ssl: true, + url: + runtime.getSetting("FARCASTER_HUB_URL") ?? + "hub.pinata.cloud", + cache, + }); + + this.posts = new FarcasterPostManager( + this.client, + this.runtime, + this.signer, + cache + ); + + this.interactions = new FarcasterInteractionManager( + this.client, + this.runtime, + this.signer, + cache + ); + } + + async start() { + await Promise.all([this.posts.start(), this.interactions.start()]); + } + + async stop() { + await Promise.all([this.posts.stop(), this.interactions.stop()]); + } +} diff --git a/packages/client-farcaster/src/interactions.ts b/packages/client-farcaster/src/interactions.ts new file mode 100644 index 00000000000..59b16ff2746 --- /dev/null +++ b/packages/client-farcaster/src/interactions.ts @@ -0,0 +1,230 @@ +import { + composeContext, + generateMessageResponse, + generateShouldRespond, + Memory, + ModelClass, + stringToUuid, + type IAgentRuntime, +} from "@ai16z/eliza"; +import { isCastAddMessage, type Signer } from "@farcaster/hub-nodejs"; +import type { FarcasterClient } from "./client"; +import { toHex } from "viem"; +import { buildConversationThread, createCastMemory } from "./memory"; +import { Cast, Profile } from "./types"; +import { + formatCast, + formatTimeline, + messageHandlerTemplate, + shouldRespondTemplate, +} from "./prompts"; +import { castUuid } from "./utils"; +import { sendCast } from "./actions"; + +export class FarcasterInteractionManager { + private timeout: NodeJS.Timeout | undefined; + constructor( + public client: FarcasterClient, + public runtime: IAgentRuntime, + private signer: Signer, + public cache: Map + ) {} + + public async start() { + const handleInteractionsLoop = async () => { + try { + await this.handleInteractions(); + } catch (error) { + console.error(error); + return; + } + + this.timeout = setTimeout( + handleInteractionsLoop, + (Math.floor(Math.random() * (5 - 2 + 1)) + 2) * 60 * 1000 + ); // Random interval between 2-5 minutes + }; + + handleInteractionsLoop(); + } + + public async stop() { + if (this.timeout) clearTimeout(this.timeout); + } + + private async handleInteractions() { + const agentFid = Number(this.runtime.getSetting("FARCASTER_FID")); + + const { messages } = await this.client.getMentions({ + fid: agentFid, + }); + + const agent = await this.client.getProfile(agentFid); + + for (const mention of messages) { + if (!isCastAddMessage(mention)) continue; + + const messageHash = toHex(mention.hash); + const messageSigner = toHex(mention.signer); + const conversationId = `${messageHash}-${this.runtime.agentId}`; + const roomId = stringToUuid(conversationId); + const userId = stringToUuid(messageSigner); + + const cast = await this.client.loadCastFromMessage(mention); + + await this.runtime.ensureConnection( + userId, + roomId, + cast.profile.username, + cast.profile.name, + "farcaster" + ); + + await buildConversationThread({ + client: this.client, + runtime: this.runtime, + cast, + }); + + const memory: Memory = { + content: { text: mention.data.castAddBody.text }, + agentId: this.runtime.agentId, + userId, + roomId, + }; + + await this.handleCast({ + agent, + cast, + memory, + }); + } + } + + private async handleCast({ + agent, + cast, + memory, + }: { + agent: Profile; + cast: Cast; + memory: Memory; + }) { + if (cast.profile.fid === agent.fid) { + console.log("skipping cast from bot itself", cast.id); + return; + } + + if (!memory.content.text) { + console.log("skipping cast with no text", cast.id); + return { text: "", action: "IGNORE" }; + } + + const currentPost = formatCast(cast); + + const { timeline } = await this.client.getTimeline({ + fid: agent.fid, + pageSize: 10, + }); + + const formattedTimeline = formatTimeline( + this.runtime.character, + timeline + ); + + const state = await this.runtime.composeState(memory, { + farcasterUsername: agent.username, + timeline: formattedTimeline, + currentPost, + }); + + const shouldRespondContext = composeContext({ + state, + template: + this.runtime.character.templates + ?.farcasterShouldRespondTemplate || + this.runtime.character?.templates?.shouldRespondTemplate || + shouldRespondTemplate, + }); + + const memoryId = castUuid({ + agentId: this.runtime.agentId, + hash: cast.id, + }); + + const castMemory = + await this.runtime.messageManager.getMemoryById(memoryId); + + if (!castMemory) { + await this.runtime.messageManager.createMemory( + createCastMemory({ + agentId: this.runtime.agentId, + roomId: memory.roomId, + userId: memory.userId, + cast, + }) + ); + } + + const shouldRespond = await generateShouldRespond({ + runtime: this.runtime, + context: shouldRespondContext, + modelClass: ModelClass.SMALL, + }); + + if (!shouldRespond) { + console.log("Not responding to message"); + return { text: "", action: "IGNORE" }; + } + + const context = composeContext({ + state, + template: + this.runtime.character.templates + ?.farcasterMessageHandlerTemplate ?? + this.runtime.character?.templates?.messageHandlerTemplate ?? + messageHandlerTemplate, + }); + + const response = await generateMessageResponse({ + runtime: this.runtime, + context, + modelClass: ModelClass.SMALL, + }); + + response.inReplyTo = memoryId; + + if (!response.text) return; + + try { + const results = await sendCast({ + runtime: this.runtime, + client: this.client, + signer: this.signer, + profile: cast.profile, + content: response, + roomId: memory.roomId, + inReplyTo: { + fid: cast.message.data.fid, + hash: cast.message.hash, + }, + }); + + const newState = await this.runtime.updateRecentMessageState(state); + + for (const { memory } of results) { + await this.runtime.messageManager.createMemory(memory); + } + + await this.runtime.evaluate(memory, newState); + + await this.runtime.processActions( + memory, + results.map((result) => result.memory), + newState + ); + } catch (error) { + console.error(`Error sending response cast: ${error}`); + } + } +} diff --git a/packages/client-farcaster/src/memory.ts b/packages/client-farcaster/src/memory.ts new file mode 100644 index 00000000000..e263e63cafc --- /dev/null +++ b/packages/client-farcaster/src/memory.ts @@ -0,0 +1,118 @@ +import { isCastAddMessage } from "@farcaster/hub-nodejs"; +import { + elizaLogger, + embeddingZeroVector, + IAgentRuntime, + stringToUuid, + type Memory, + type UUID, +} from "@ai16z/eliza"; +import type { Cast } from "./types"; +import { toHex } from "viem"; +import { castUuid } from "./utils"; +import { FarcasterClient } from "./client"; + +export function createCastMemory({ + roomId, + agentId, + userId, + cast, +}: { + roomId: UUID; + agentId: UUID; + userId: UUID; + cast: Cast; +}): Memory { + const inReplyTo = cast.message.data.castAddBody.parentCastId + ? castUuid({ + hash: toHex(cast.message.data.castAddBody.parentCastId.hash), + agentId, + }) + : undefined; + + return { + id: castUuid({ + hash: cast.id, + agentId, + }), + agentId, + userId, + content: { + text: cast.text, + source: "farcaster", + url: "", + inReplyTo, + hash: cast.id, + }, + roomId, + embedding: embeddingZeroVector, + createdAt: cast.message.data.timestamp * 1000, + }; +} + +export async function buildConversationThread({ + cast, + runtime, + client, +}: { + cast: Cast; + runtime: IAgentRuntime; + client: FarcasterClient; +}): Promise { + const thread: Cast[] = []; + const visited: Set = new Set(); + + async function processThread(currentCast: Cast) { + if (visited.has(cast.id)) { + return; + } + + visited.add(cast.id); + + const roomId = castUuid({ + hash: currentCast.id, + agentId: runtime.agentId, + }); + + // Check if the current tweet has already been saved + const memory = await runtime.messageManager.getMemoryById(roomId); + + if (!memory) { + elizaLogger.log("Creating memory for cast", cast.id); + + const userId = stringToUuid(cast.profile.username); + + await runtime.ensureConnection( + userId, + roomId, + currentCast.profile.username, + currentCast.profile.name, + "farcaster" + ); + + await runtime.messageManager.createMemory( + createCastMemory({ + roomId, + agentId: runtime.agentId, + userId, + cast: currentCast, + }) + ); + } + + thread.unshift(currentCast); + + if (currentCast.message.data.castAddBody.parentCastId) { + const message = await client.getCast( + currentCast.message.data.castAddBody.parentCastId + ); + + if (isCastAddMessage(message)) { + const parentCast = await client.loadCastFromMessage(message); + await processThread(parentCast); + } + } + } + + await processThread(cast); +} diff --git a/packages/client-farcaster/src/post.ts b/packages/client-farcaster/src/post.ts new file mode 100644 index 00000000000..4777de94d53 --- /dev/null +++ b/packages/client-farcaster/src/post.ts @@ -0,0 +1,162 @@ +import { Signer } from "@farcaster/hub-nodejs"; +import { + composeContext, + generateText, + IAgentRuntime, + ModelClass, + stringToUuid, +} from "@ai16z/eliza"; +import { FarcasterClient } from "./client"; +import { formatTimeline, postTemplate } from "./prompts"; +import { castUuid } from "./utils"; +import { createCastMemory } from "./memory"; +import { sendCast } from "./actions"; + +export class FarcasterPostManager { + private timeout: NodeJS.Timeout | undefined; + + constructor( + public client: FarcasterClient, + public runtime: IAgentRuntime, + private signer: Signer, + public cache: Map + ) {} + + public async start() { + const generateNewCastLoop = async () => { + try { + await this.generateNewCast(); + } catch (error) { + console.error(error); + return; + } + + this.timeout = setTimeout( + generateNewCastLoop, + (Math.floor(Math.random() * (4 - 1 + 1)) + 1) * 60 * 60 * 1000 + ); // Random interval between 1 and 4 hours + }; + + generateNewCastLoop(); + } + + public async stop() { + if (this.timeout) clearTimeout(this.timeout); + } + + private async generateNewCast() { + console.log("Generating new cast"); + try { + const fid = Number(this.runtime.getSetting("FARCASTER_FID")!); + // const farcasterUserName = + // this.runtime.getSetting("FARCASTER_USERNAME")!; + + const profile = await this.client.getProfile(fid); + + await this.runtime.ensureUserExists( + this.runtime.agentId, + profile.username, + this.runtime.character.name, + "farcaster" + ); + + const { timeline } = await this.client.getTimeline({ + fid, + pageSize: 10, + }); + + this.cache.set("farcaster/timeline", timeline); + + const formattedHomeTimeline = formatTimeline( + this.runtime.character, + timeline + ); + + const generateRoomId = stringToUuid("farcaster_generate_room"); + + const state = await this.runtime.composeState( + { + roomId: generateRoomId, + userId: this.runtime.agentId, + agentId: this.runtime.agentId, + content: { text: "", action: "" }, + }, + { + farcasterUserName: profile.username, + timeline: formattedHomeTimeline, + } + ); + + // Generate new tweet + const context = composeContext({ + state, + template: + this.runtime.character.templates?.farcasterPostTemplate || + postTemplate, + }); + + const newContent = await generateText({ + runtime: this.runtime, + context, + modelClass: ModelClass.SMALL, + }); + + const slice = newContent.replaceAll(/\\n/g, "\n").trim(); + + const contentLength = 240; + + let content = slice.slice(0, contentLength); + // if its bigger than 280, delete the last line + if (content.length > 280) { + content = content.slice(0, content.lastIndexOf("\n")); + } + + if (content.length > contentLength) { + // slice at the last period + content = content.slice(0, content.lastIndexOf(".")); + } + + // if it's still too long, get the period before the last period + if (content.length > contentLength) { + content = content.slice(0, content.lastIndexOf(".")); + } + + try { + // TODO: handle all the casts? + const [{ cast }] = await sendCast({ + client: this.client, + runtime: this.runtime, + signer: this.signer, + roomId: generateRoomId, + content: { text: content }, + profile, + }); + + const roomId = castUuid({ + agentId: this.runtime.agentId, + hash: cast.id, + }); + + await this.runtime.ensureRoomExists(roomId); + + await this.runtime.ensureParticipantInRoom( + this.runtime.agentId, + roomId + ); + + await this.runtime.messageManager.createMemory( + createCastMemory({ + roomId, + userId: this.runtime.agentId, + agentId: this.runtime.agentId, + cast, + }) + ); + } catch (error) { + console.error("Error sending tweet:", error); + } + } catch (error) { + console.error("Error generating new tweet:", error); + } + } +} diff --git a/packages/client-farcaster/src/prompts.ts b/packages/client-farcaster/src/prompts.ts new file mode 100644 index 00000000000..3b378b23486 --- /dev/null +++ b/packages/client-farcaster/src/prompts.ts @@ -0,0 +1,79 @@ +import { + Character, + messageCompletionFooter, + shouldRespondFooter, +} from "@ai16z/eliza"; +import type { Cast } from "./types"; + +export const formatCast = (cast: Cast) => { + return `ID: ${cast.id} +From: ${cast.profile.name} (@${cast.profile.username})${cast.profile.username})${cast.inReplyTo ? `\nIn reply to: ${cast.inReplyTo.id}` : ""} +Text: ${cast.text}`; +}; + +export const formatTimeline = ( + character: Character, + timeline: Cast[] +) => `# ${character.name}'s Home Timeline +${timeline.map(formatCast).join("\n")} +`; + +export const headerTemplate = ` +{{timeline}} + +# Knowledge +{{knowledge}} + +About {{agentName}} (@{{farcasterUserName}}): +{{bio}} +{{lore}} +{{postDirections}} + +{{providers}} + +{{recentPosts}} + +{{characterPostExamples}}`; + +export const postTemplate = + headerTemplate + + ` +# Task: Generate a post in the voice and style of {{agentName}}, aka @{{farcasterUserName}} +Write a single sentence post that is {{adjective}} about {{topic}} (without mentioning {{topic}} directly), from the perspective of {{agentName}}. +Try to write something totally different than previous posts. Do not add commentary or ackwowledge this request, just write the post. + +Your response should not contain any questions. Brief, concise statements only. No emojis. Use \\n\\n (double spaces) between statements.`; + +export const messageHandlerTemplate = + headerTemplate + + ` +Recent interactions between {{agentName}} and other users: +{{recentPostInteractions}} + +# Task: Generate a post in the voice, style and perspective of {{agentName}} (@{{twitterUserName}}): +{{currentPost}}` + + messageCompletionFooter; + +export const shouldRespondTemplate = + // + `# INSTRUCTIONS: Determine if {{agentName}} (@{{twitterUserName}}) should respond to the message and participate in the conversation. Do not comment. Just respond with "true" or "false". + +Response options are RESPOND, IGNORE and STOP. + +{{agentName}} should respond to messages that are directed at them, or participate in conversations that are interesting or relevant to their background, IGNORE messages that are irrelevant to them, and should STOP if the conversation is concluded. + +{{agentName}} is in a room with other users and wants to be conversational, but not annoying. +{{agentName}} should RESPOND to messages that are directed at them, or participate in conversations that are interesting or relevant to their background. +If a message is not interesting or relevant, {{agentName}} should IGNORE. +Unless directly RESPONDing to a user, {{agentName}} should IGNORE messages that are very short or do not contain much information. +If a user asks {{agentName}} to stop talking, {{agentName}} should STOP. +If {{agentName}} concludes a conversation and isn't part of the conversation anymore, {{agentName}} should STOP. + +{{recentPosts}} + +IMPORTANT: {{agentName}} (aka @{{twitterUserName}}) is particularly sensitive about being annoying, so if there is any doubt, it is better to IGNORE than to RESPOND. + +{{currentPost}} + +# INSTRUCTIONS: Respond with [RESPOND] if {{agentName}} should respond, or [IGNORE] if {{agentName}} should not respond to the last message and [STOP] if {{agentName}} should stop participating in the conversation. +` + shouldRespondFooter; diff --git a/packages/client-farcaster/src/types.ts b/packages/client-farcaster/src/types.ts new file mode 100644 index 00000000000..c86aef8983f --- /dev/null +++ b/packages/client-farcaster/src/types.ts @@ -0,0 +1,26 @@ +import type { CastAddMessage } from "@farcaster/hub-nodejs"; +import type { Hex } from "viem"; + +export type Profile = { + fid: number; + signer: Hex; + name: string; + username: string; + pfp?: string; + bio?: string; + url?: string; + // location?: string; + // twitter?: string; + // github?: string; +}; + +export type Cast = { + id: Hex; + profile: Profile; + text: string; + message: CastAddMessage; + inReplyTo?: { + id: Hex; + fid: number; + }; +}; diff --git a/packages/client-farcaster/src/utils.ts b/packages/client-farcaster/src/utils.ts new file mode 100644 index 00000000000..6349ca4e665 --- /dev/null +++ b/packages/client-farcaster/src/utils.ts @@ -0,0 +1,137 @@ +import { stringToUuid } from "@ai16z/eliza"; +import type { Hex } from "viem"; + +const MAX_CAST_LENGTH = 280; // Updated to Twitter's current character limit + +export function castId({ hash, agentId }: { hash: Hex; agentId: string }) { + return `${hash}-${agentId}`; +} + +export function castUuid(props: { hash: Hex; agentId: string }) { + return stringToUuid(castId(props)); +} + +export function splitPostContent( + content: string, + maxLength: number = MAX_CAST_LENGTH +): string[] { + const paragraphs = content.split("\n\n").map((p) => p.trim()); + const posts: string[] = []; + let currentTweet = ""; + + for (const paragraph of paragraphs) { + if (!paragraph) continue; + + if ((currentTweet + "\n\n" + paragraph).trim().length <= maxLength) { + if (currentTweet) { + currentTweet += "\n\n" + paragraph; + } else { + currentTweet = paragraph; + } + } else { + if (currentTweet) { + posts.push(currentTweet.trim()); + } + if (paragraph.length <= maxLength) { + currentTweet = paragraph; + } else { + // Split long paragraph into smaller chunks + const chunks = splitParagraph(paragraph, maxLength); + posts.push(...chunks.slice(0, -1)); + currentTweet = chunks[chunks.length - 1]; + } + } + } + + if (currentTweet) { + posts.push(currentTweet.trim()); + } + + return posts; +} + +export function splitParagraph(paragraph: string, maxLength: number): string[] { + const sentences = paragraph.match(/[^\.!\?]+[\.!\?]+|[^\.!\?]+$/g) || [ + paragraph, + ]; + const chunks: string[] = []; + let currentChunk = ""; + + for (const sentence of sentences) { + if ((currentChunk + " " + sentence).trim().length <= maxLength) { + if (currentChunk) { + currentChunk += " " + sentence; + } else { + currentChunk = sentence; + } + } else { + if (currentChunk) { + chunks.push(currentChunk.trim()); + } + if (sentence.length <= maxLength) { + currentChunk = sentence; + } else { + // Split long sentence into smaller pieces + const words = sentence.split(" "); + currentChunk = ""; + for (const word of words) { + if ( + (currentChunk + " " + word).trim().length <= maxLength + ) { + if (currentChunk) { + currentChunk += " " + word; + } else { + currentChunk = word; + } + } else { + if (currentChunk) { + chunks.push(currentChunk.trim()); + } + currentChunk = word; + } + } + } + } + } + + if (currentChunk) { + chunks.push(currentChunk.trim()); + } + + return chunks; +} + +export function populateMentions( + text: string, + userIds: number[], + positions: number[], + userMap: Record +) { + // Validate input arrays have same length + if (userIds.length !== positions.length) { + throw new Error( + "User IDs and positions arrays must have the same length" + ); + } + + // Create array of mention objects with position and user info + const mentions = userIds + .map((userId, index) => ({ + position: positions[index], + userId, + displayName: userMap[userId]!, + })) + .sort((a, b) => b.position - a.position); // Sort in reverse order to prevent position shifting + + // Create the resulting string by inserting mentions + let result = text; + mentions.forEach((mention) => { + const mentionText = `@${mention.displayName}`; + result = + result.slice(0, mention.position) + + mentionText + + result.slice(mention.position); + }); + + return result; +} diff --git a/packages/client-farcaster/tsconfig.json b/packages/client-farcaster/tsconfig.json new file mode 100644 index 00000000000..42d468a1356 --- /dev/null +++ b/packages/client-farcaster/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react", + "outDir": "dist", + "rootDir": "./src", + "strict": true + }, + "include": ["src"] +} diff --git a/packages/client-farcaster/tsup.config.ts b/packages/client-farcaster/tsup.config.ts new file mode 100644 index 00000000000..e42bf4efeae --- /dev/null +++ b/packages/client-farcaster/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"], // Ensure you're targeting CommonJS + external: [ + "dotenv", // Externalize dotenv to prevent bundling + "fs", // Externalize fs to use Node.js built-in module + "path", // Externalize other built-ins if necessary + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + // Add other modules you want to externalize + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b7478eb5c..44906db78fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,67 @@ importers: specifier: link:@tanstack/router-plugin/vite version: link:@tanstack/router-plugin/vite + apps/agent: + dependencies: + '@ai16z/adapter-postgres': + specifier: workspace:* + version: link:../../packages/adapter-postgres + '@ai16z/adapter-sqlite': + specifier: workspace:* + version: link:../../packages/adapter-sqlite + '@ai16z/client-auto': + specifier: workspace:* + version: link:../../packages/client-auto + '@ai16z/client-direct': + specifier: workspace:* + version: link:../../packages/client-direct + '@ai16z/client-discord': + specifier: workspace:* + version: link:../../packages/client-discord + '@ai16z/client-farcaster': + specifier: workspace:* + version: link:../../packages/client-farcaster + '@ai16z/client-telegram': + specifier: workspace:* + version: link:../../packages/client-telegram + '@ai16z/client-twitter': + specifier: workspace:* + version: link:../../packages/client-twitter + '@ai16z/eliza': + specifier: workspace:* + version: link:../../packages/core + '@ai16z/plugin-bootstrap': + specifier: workspace:* + version: link:../../packages/plugin-bootstrap + '@ai16z/plugin-image-generation': + specifier: workspace:* + version: link:../../packages/plugin-image-generation + '@ai16z/plugin-node': + specifier: workspace:* + version: link:../../packages/plugin-node + '@ai16z/plugin-solana': + specifier: workspace:* + version: link:../../packages/plugin-solana + readline: + specifier: ^1.3.0 + version: 1.3.0 + viem: + specifier: ^2.21.47 + version: 2.21.47(bufferutil@4.0.8)(typescript@5.6.3)(utf-8-validate@5.0.10)(zod@3.23.8) + ws: + specifier: ^8.18.0 + version: 8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + yargs: + specifier: 17.7.2 + version: 17.7.2 + devDependencies: + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@22.8.4)(typescript@5.6.3) + tsup: + specifier: ^8.3.5 + version: 8.3.5(jiti@1.21.6)(postcss@8.4.47)(typescript@5.6.3)(yaml@2.6.0) + docs: dependencies: '@docusaurus/core':