diff --git a/.env.example b/.env.example index 2f4957a2b51..a2f07f14809 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,8 @@ TWITTER_EMAIL= # Account email TWITTER_2FA_SECRET= TWITTER_COOKIES= # Account cookies TWITTER_POLL_INTERVAL=120 # How often (in seconds) the bot should check for interactions +TWITTER_SEARCH_ENABLE=FALSE # Enable timeline search, WARNING this greatly increases your chance of getting banned +TWITTER_TARGET_USERS= # Comma separated list of Twitter user names to interact with X_SERVER_URL= XAI_API_KEY= XAI_MODEL= @@ -62,6 +64,10 @@ POST_INTERVAL_MIN= # Default: 90 POST_INTERVAL_MAX= # Default: 180 POST_IMMEDIATELY= +# Twitter action processing configuration +ACTION_INTERVAL=300000 # Interval in milliseconds between action processing runs (default: 5 minutes) +ENABLE_ACTION_PROCESSING=false # Set to true to enable the action processing loop + # Feature Flags IMAGE_GEN= # Set to TRUE to enable image generation USE_OPENAI_EMBEDDING= # Set to TRUE for OpenAI/1536, leave blank for local @@ -244,6 +250,13 @@ INTERNET_COMPUTER_ADDRESS= APTOS_PRIVATE_KEY= # Aptos private key APTOS_NETWORK= # must be one of mainnet, testnet +# EchoChambers Configuration +ECHOCHAMBERS_API_URL=http://127.0.0.1:3333 +ECHOCHAMBERS_API_KEY=testingkey0011 +ECHOCHAMBERS_USERNAME=eliza +ECHOCHAMBERS_DEFAULT_ROOM=general +ECHOCHAMBERS_POLL_INTERVAL=60 +ECHOCHAMBERS_MAX_MESSAGES=10 # AWS S3 Configuration Settings for File Upload AWS_ACCESS_KEY_ID= diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 535617fb44e..58c6cdcc557 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: echo "NODE_ENV=test" >> packages/core/.env.test - name: Run tests - run: cd packages/core && pnpm test + run: cd packages/core && pnpm test:coverage - name: Build packages run: pnpm run build diff --git a/.github/workflows/integrationTests.yaml b/.github/workflows/integrationTests.yaml new file mode 100644 index 00000000000..cd9441507dd --- /dev/null +++ b/.github/workflows/integrationTests.yaml @@ -0,0 +1,55 @@ +name: integration-test +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" +jobs: + smoke-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 9.4.0 + + - uses: actions/setup-node@v4 + with: + node-version: "23" + cache: "pnpm" + + - name: Run smoke tests + run: pnpm run smokeTests + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 9.4.0 + + - uses: actions/setup-node@v4 + with: + node-version: "23" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install -r + + - name: Build packages + run: pnpm build + + - name: Run integration tests + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + if [ -z "$OPENAI_API_KEY" ]; then + echo "Skipping integration tests due to missing required API keys" + exit 1 + else + pnpm run integrationTests + fi diff --git a/.gitignore b/.gitignore index b3d84f00fb7..17300ccdb78 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ packages/plugin-coinbase/package-lock.json tsup.config.bundled_*.mjs .turbo + +coverage \ No newline at end of file diff --git a/README.md b/README.md index b87419cfad5..dad5e3ebd15 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ git clone https://github.com/ai16z/eliza-starter.git cp .env.example .env -pnpm i && pnpm start +pnpm i && pnpm build && pnpm start ``` Then read the [Documentation](https://ai16z.github.io/eliza/) to learn how to customize your Eliza. @@ -90,7 +90,7 @@ sh scripts/start.sh ### Edit the character file -1. Open `agent/src/character.ts` to modify the default character. Uncomment and edit. +1. Open `packages/core/src/defaultCharacter.ts` to modify the default character. Uncomment and edit. 2. To load custom characters: - Use `pnpm start --characters="path/to/your/character.json"` diff --git a/agent/src/index.ts b/agent/src/index.ts index 8aab1b5d38b..bb631a81363 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -60,6 +60,12 @@ export const wait = (minTime: number = 1000, maxTime: number = 3000) => { return new Promise((resolve) => setTimeout(resolve, waitTime)); }; +const logFetch = async (url: string, options: any) => { + elizaLogger.info(`Fetching ${url}`); + elizaLogger.info(options); + return fetch(url, options); +}; + export function parseArguments(): { character?: string; characters?: string; @@ -335,6 +341,7 @@ export async function initializeClients( } if (clientTypes.includes("twitter")) { + TwitterClientInterface.enableSearch = !isFalsish(getSecret(character, "TWITTER_SEARCH_ENABLE")); const twitterClients = await TwitterClientInterface.start(runtime); clients.push(twitterClients); } @@ -358,6 +365,22 @@ export async function initializeClients( return clients; } +function isFalsish(input: any): boolean { + // If the input is exactly NaN, return true + if (Number.isNaN(input)) { + return true; + } + + // Convert input to a string if it's not null or undefined + const value = input == null ? '' : String(input); + + // List of common falsish string representations + const falsishValues = ['false', '0', 'no', 'n', 'off', 'null', 'undefined', '']; + + // Check if the value (trimmed and lowercased) is in the falsish list + return falsishValues.includes(value.trim().toLowerCase()); +} + function getSecret(character: Character, secret: string) { return character.settings.secrets?.[secret] || process.env[secret]; } @@ -456,6 +479,7 @@ export async function createAgent( services: [], managers: [], cacheManager: cache, + fetch: logFetch, }); } diff --git a/docs/community/Streams/12-2024/2024-12-10.md b/docs/community/Streams/12-2024/2024-12-10.md new file mode 100644 index 00000000000..51afc2133f2 --- /dev/null +++ b/docs/community/Streams/12-2024/2024-12-10.md @@ -0,0 +1,94 @@ +--- +sidebar_position: 4 +title: "AI Agent Dev School Part 4" +description: "AI Pizza: Hacking Eliza for Domino's Delivery (plus TEE Deep Dive)" +--- + +# AI Agent Dev School Part 4 + +**AI Pizza: Hacking Eliza for Domino's Delivery (plus TEE Deep Dive)** + +Date: 2024-12-10 +YouTube Link: https://www.youtube.com/watch?v=6I9e9pJprDI + +## Timestamps + +Part 1: Trusted Execution Environments (TEEs) with Agent Joshua +- **00:00:09** - Stream starts, initial setup issues. +- **00:01:58** - Intro to Trusted Execution Environments (TEEs). +- **00:08:03** - Agent Joshua begins explaining TEEs and the Eliza plugin. +- **00:19:15** - Deeper dive into remote attestation. +- **00:24:50** - Discussion of derived keys. +- **00:37:00** - Deploying to a real TEE, Phala Network's TEE cloud. +- **00:50:48** - Q&A with Joshua, contact info, and next steps. + +Part 2: Building a Domino's pizza ordering agent +- **01:04:37** - Transition to building a Domino's pizza ordering agent. +- **01:14:20** - Discussion of the pizza ordering agent’s order flow and state machine. +- **01:22:07** - Using Claude to generate a state machine diagram. +- **01:32:17** - Creating the Domino's plugin in Eliza. +- **01:54:15** - Working on the pizza order provider. +- **02:16:46** - Pizza provider code completed. +- **02:28:50** - Discussion of caching customer and order data. +- **03:13:45** - Pushing fixes to main branch and continuing work on the agent. +- **04:24:30** - Discussion of summarizing past agent dev school sessions. +- **05:01:18** - Shaw returns, admits to ordering Domino's manually. +- **05:09:00** - Discussing payment flow and a confirm order action. +- **05:27:17** - Final code push, wrap-up, and end of stream. + + +## Summary + +This is a livestream titled "AI Agent Dev School Part 4" from the ai16z project, featuring Shaw. The stream is divided into two main parts: a technical discussion on Trusted Execution Environments (TEEs) and a coding session where Shaw attempts to build a pizza-ordering agent using the Domino's API. + +**Part 1: Trusted Execution Environments (TEEs) with Agent Joshua** + +This segment begins with Shaw introducing the concept of TEEs and their importance for running autonomous agents securely. He emphasizes the need to protect private keys and ensure that code execution is tamper-proof. Joshua from the Phala Network is brought on to explain TEEs in more detail and demonstrate how to use the TEE plugin he built for Eliza. + +* **Key Concepts:** + * **Trusted Execution Environments (TEEs):** Secure areas within a processor that isolate code and data, protecting them from unauthorized access and tampering. + * **Secure Enclave:** A cryptographic primitive that allows data to be encrypted and isolated within a processor. + * **Remote Attestation:** A method to verify that a program running inside a TEE is genuine and hasn't been tampered with, providing verifiability to users. + * **D-Stack:** An SDK developed in collaboration with Flashbots and Andrew Miller, enabling developers to build and launch Docker containers in TEEs. + * **Derived Key Provider:** A component that generates cryptographic keys based on a secret salt, ensuring that private keys are not exposed to humans. + +* **Demonstration:** + * Joshua walks through the process of setting up and deploying an Eliza agent in a TEE simulator, demonstrating how to generate remote attestations and derive keys. + * He shows how to use the remote attestation explorer to verify the authenticity of the agent running inside the TEE. + * He explains how to build a Docker image of the agent and deploy it to the Phala Network's TEE cloud solution. + +* **Use Cases:** + * Securely storing private keys for on-chain actions. + * Ensuring the integrity of autonomous agents, preventing tampering or unauthorized access. + * Providing verifiable execution for users and investors. + +* **Phala Network's TEE Cloud:** + * Joshua introduces Phala Network's TEE cloud solution, which allows developers to deploy Docker images and host their agents in a trusted execution environment. + * He mentions that the service supports various compute-intensive applications beyond AI agents. + * He invites interested developers to contact him on Discord (@hashwarlock) for onboarding and further details. + +**Part 2: Building a Pizza Ordering Agent** + +In the second part, Shaw transitions to a more lighthearted coding session where he attempts to build an agent that can order a pizza using the Domino's API. He highlights the challenges of handling payments securely and connecting user information to the conversation. + +* **Challenges:** + * Securely handling payment information. + * Connecting user data to the current conversation. + * Defining the order flow using a state machine. + +* **Approach:** + * Shaw uses a state machine to model the pizza ordering process, defining different states and transitions based on user input and available information. + * He uses Claude (an AI assistant) to generate code snippets and assist with the development process. + * He decides to initially focus on a simplified version where the user's payment information is hardcoded in the environment variables, and the agent only needs to collect the user's address. + +## Hot Takes + +1. **"Maybe we'll mix it on LinkedIn so people can order Domino's on LinkedIn. There you go. Now we're cooking." (00:03:26)** - Shaw's seemingly flippant idea of ordering pizza on LinkedIn highlights the potential for integrating everyday services into unexpected platforms through agents. This sparked discussion about the wider implications for businesses and social media. + +2. **"Yeah, it'll probably get drained real quick. These fucking people." (00:28:30)** - Shaw accidentally leaked an API key on stream and expressed frustration with viewers who noticed, exposing the real-world risks of handling sensitive information during development, especially in a live environment. + +3. **"The secret to making a billion dollars is to use the existing agent framework to deliver apps to people on social media that they want." (01:09:35)** - Shaw’s strong assertion about focusing on building apps *using* existing frameworks rather than creating new ones is a bold statement about the current agent development landscape, suggesting that innovation lies in application development, not framework creation. + +4. **"So those are like, honest to God, if the bots are better than like 70% of tweets on Twitter, they're better than like 99.7 tweets and posts on LinkedIn." (01:39:57)** - This provocative comparison of content quality between Twitter and LinkedIn, suggesting bots surpass most LinkedIn posts, fueled lively debate in the chat and raised questions about the role and value of human-generated content in the age of AI. + +5. **"I subliminally messaged Domino's into my own brain, and now I have to eat it." (05:01:24)** - After hours of working on the pizza bot, Shaw abandoned the live coding attempt and ordered pizza manually, a humorous but relatable moment that highlighted the challenges and frustrations of software development, even when aided by AI. It also underscores the human desire for immediate gratification, even in the face of a potentially groundbreaking technological advancement. diff --git a/docs/community/components/Accordion.tsx b/docs/community/components/Accordion.tsx new file mode 100644 index 00000000000..9b4748089ed --- /dev/null +++ b/docs/community/components/Accordion.tsx @@ -0,0 +1,183 @@ +import React, { useState, useRef, useEffect } from "react"; +import { GitHubItem } from "./Contributions"; +import { GITHUB_PAGE_LIMIT } from "./Contributors"; + +interface AccordionProps { + title: string; + isOpen: boolean; + onToggle: () => void; + data: GitHubItem[]; + loadMore?: () => void; + total_count: number; + primaryText?: string; + secondaryText?: string; + mainBackgroundColor?: string; +} + +export const Accordion: React.FC<AccordionProps> = ({ + title, + isOpen, + onToggle, + data, + loadMore, + total_count, + primaryText, + secondaryText, + mainBackgroundColor, +}) => { + const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); + const [hoverLoadMore, setHoverLoadMore] = useState<boolean>(false); + const [maxHeight, setMaxHeight] = useState<string>( + isOpen ? "1000px" : "0px", + ); + + const contentRef = useRef<HTMLDivElement>(null); + + React.useEffect(() => { + setMaxHeight(isOpen ? "1000px" : "0px"); + }, [isOpen]); + + useEffect(() => { + if (contentRef.current && data.length > GITHUB_PAGE_LIMIT) { + contentRef.current.scrollTo({ + top: contentRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [data]); + + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + justifyContent: "center", + borderRadius: "0.5rem", + padding: "1rem", + color: primaryText ?? "black", + background: mainBackgroundColor ?? "", + }} + > + <div + onClick={onToggle} + style={{ + cursor: "pointer", + fontWeight: "bold", + fontSize: "1rem", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + }} + > + <div>{title}</div> + <div + style={{ + transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", + transition: "transform 0.3s ease", + }} + > + {"▶"} + </div> + </div> + <div + ref={contentRef} + style={{ + maxHeight, + overflow: "scroll", + transition: isOpen ? "max-height 1s ease" : "", + scrollbarWidth: "none", + msOverflowStyle: "none", + }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + gap: "1rem", + margin: "2rem 0 1rem 1rem", + }} + > + {data.map((entry, index) => ( + <div + key={index} + style={{ + opacity: hoveredIndex === index ? 0.8 : 1.0, + }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + marginBottom: "0.5rem", + cursor: "pointer", + transition: "color 0.2s ease", + }} + onMouseEnter={() => setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => + window.open( + entry.html_url, + "_blank", + "noopener,noreferrer", + ) + } + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: "0.5rem", + }} + > + {entry.bullet && ( + <div + style={{ + width: "0.5rem", + height: "0.5rem", + borderRadius: "50%", + backgroundColor: entry.bullet, + }} + ></div> + )} + <div>{entry.title}</div> + </div> + <div + style={{ + fontSize: "0.8rem", + color: secondaryText ?? "gray", + }} + > + {entry.created_at.split("T")[0]} + </div> + </div> + </div> + ))} + </div> + </div> + {isOpen && loadMore && data.length < total_count && ( + <div + style={{ + width: "100%", + display: "flex", + justifyContent: "center", + }} + > + <span + style={{ + color: hoverLoadMore + ? (secondaryText ?? "#3b82f6") + : (primaryText ?? "black"), + cursor: "pointer", + }} + onMouseEnter={() => setHoverLoadMore(true)} + onMouseLeave={() => setHoverLoadMore(false)} + onClick={loadMore} + > + Load more + </span> + </div> + )} + </div> + ); +}; diff --git a/docs/community/components/Contributions.tsx b/docs/community/components/Contributions.tsx new file mode 100644 index 00000000000..39fed8bbbe2 --- /dev/null +++ b/docs/community/components/Contributions.tsx @@ -0,0 +1,363 @@ +import React, { useState, useEffect } from "react"; +import { Accordion } from "./Accordion"; +import { StatCard } from "./StatCard"; +import { THEME_COLORS } from "./Contributors"; +import { hexToRgb, useGithubAccessToken } from "./utils"; +import ScoreIcon from "./ScoreIcon"; +import Summary from "./Summary"; +import Hero from "./Hero"; + +export interface GitHubItem { + html_url: string; + title: string; + created_at: string; + bullet?: string; +} + +export interface StatCardProps { + title: string; + value: number; + style?: React.CSSProperties; +} + +export interface AccordionItem { + data: GitHubItem[]; + total_count: number; + state?: string; +} + +export enum BULLET_COLOR { + OPEN = "#1A7F37", + CLOSE = "#D1242F", + MERGE = "#8250DF", +} + +const initializeAccordionItem = (): AccordionItem => ({ + data: [], + total_count: 0, +}); + +const Contributions = ({ + contributor, + onBack, + darkMode, + activitySummary, + score, +}) => { + const githubAccessToken = useGithubAccessToken(); + const [commitsData, setCommitsData] = useState<AccordionItem>( + initializeAccordionItem(), + ); + const [prsData, setPrsData] = useState<AccordionItem>( + initializeAccordionItem(), + ); + const [issuesData, setIssuesData] = useState<AccordionItem>( + initializeAccordionItem(), + ); + const [openAccordion, setOpenAccordion] = useState<string | null>(null); + + const [commitPage, setCommitPage] = useState(1); + const [prPage, sePrPage] = useState(1); + const [issuePage, setIssuePage] = useState(1); + + useEffect(() => { + const fetchContributorStats = async () => { + try { + await fetchCommits(commitPage); + await fetchPRs(prPage); + await fetchIssues(issuePage); + } catch (error) { + console.error("Error fetching contributor stats:", error); + } + }; + + fetchContributorStats(); + }, [contributor.login]); + + const toggleAccordion = (section: string) => { + setOpenAccordion((prev) => (prev === section ? null : section)); + }; + + const fetchCommits = async (page: number) => { + try { + const commitResponse = await fetch( + `https://api.github.com/repos/ai16z/eliza/commits?author=${contributor.login}&page=${page}`, + { + method: "GET", + headers: { + Authorization: `token ${githubAccessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + const commitData = await commitResponse.json(); + const commitItems = commitData.map((commit: any) => ({ + html_url: commit.html_url, + title: commit.commit.message, + created_at: commit.commit.author.date, + })); + const currentCommitsData = [...commitsData.data, ...commitItems]; + setCommitsData({ + data: currentCommitsData, + total_count: contributor.contributions, + }); + } catch (error) { + console.error("Error fetching commits:", error); + } + }; + + const fetchPRs = async (page: number) => { + try { + const prResponse = await fetch( + `https://api.github.com/search/issues?q=type:pr+author:${contributor.login}+repo:ai16z/eliza&page=${page}`, + { + method: "GET", + headers: { + Authorization: `token ${githubAccessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + const prData = await prResponse.json(); + const prItems = prData.items.map((pr: any) => ({ + html_url: pr.html_url, + title: pr.title, + created_at: pr.created_at, + bullet: + pr.state === "open" + ? BULLET_COLOR.OPEN + : pr.pull_request.merged_at + ? BULLET_COLOR.MERGE + : BULLET_COLOR.CLOSE, + })); + const currentPrsData = [...prsData.data, ...prItems]; + + setPrsData({ + data: currentPrsData, + total_count: prData.total_count, + }); + } catch (error) { + console.error("Error fetching PRs:", error); + } + }; + + const fetchIssues = async (page: number) => { + try { + const issueResponse = await fetch( + `https://api.github.com/search/issues?q=type:issue+author:${contributor.login}+repo:ai16z/eliza&page=${page}`, + { + method: "GET", + headers: { + Authorization: `token ${githubAccessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + const issueData = await issueResponse.json(); + const issueItems = issueData.items.map((issue: any) => ({ + html_url: issue.html_url, + title: issue.title, + created_at: issue.created_at, + bullet: + issue.state === "open" + ? BULLET_COLOR.OPEN + : BULLET_COLOR.CLOSE, + })); + const currentIssuesData = [...issuesData.data, ...issueItems]; + setIssuesData({ + data: currentIssuesData, + total_count: issueData.total_count, + }); + } catch (error) { + console.error("Error fetching issues:", error); + } + }; + + const accordionItems = [ + { + title: "Commits", + data: commitsData, + section: "commits", + loadMore: () => { + const nextPage = commitPage + 1; + fetchCommits(nextPage); + setCommitPage(nextPage); + }, + }, + { + title: "Pull Requests", + data: prsData, + section: "pullRequests", + loadMore: () => { + const nextPage = prPage + 1; + fetchPRs(nextPage); + sePrPage(nextPage); + }, + }, + { + title: "Issues", + data: issuesData, + section: "issues", + loadMore: () => { + const nextPage = issuePage + 1; + fetchIssues(nextPage); + setIssuePage(nextPage); + }, + }, + ]; + + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + width: "100%", + padding: "1rem", + gap: "1rem", + color: darkMode + ? THEME_COLORS.dark.primaryText + : THEME_COLORS.light.primaryText, + }} + > + <div> + <span + style={{ cursor: "pointer", fontWeight: "bold" }} + onClick={onBack} + > + <span + style={{ + border: "solid currentColor", + borderWidth: "0 2px 2px 0", + display: "inline-block", + height: "8px", + pointerEvents: "none", + transform: "translateY(-1px) rotate(135deg)", + width: "8px", + marginRight: "1px", + }} + ></span> + <span>back</span> + </span> + </div> + <div + style={{ + height: "100px", + borderRadius: "0.5rem", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + backgroundColor: darkMode + ? THEME_COLORS.dark.mainBackgroundColor + : THEME_COLORS.light.mainBackgroundColor, + padding: "24px", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + <Hero + contributor={contributor} + secondaryText={ + darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText + } + profilePictureSize="64px" + /> + <ScoreIcon + style={{ + width: "6rem", + height: "2.5rem", + backgroundColor: darkMode + ? `rgba(${hexToRgb(THEME_COLORS.dark.secondaryText)}, 0.2)` + : `rgba(${hexToRgb(THEME_COLORS.light.secondaryText)}, 0.2)`, + color: darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText, + fontSize: "1rem", + padding: "0.35", + fontWeight: "bold", + gap: "0.25rem", + }} + iconColor={ + darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText + } + iconSize="1.5rem" + score={score} + /> + </div> + + <Summary + summary={activitySummary} + style={{ + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + borderRadius: "0.5rem", + padding: "2rem", + backgroundColor: darkMode + ? THEME_COLORS.dark.mainBackgroundColor + : THEME_COLORS.light.mainBackgroundColor, + }} + /> + + <div + style={{ + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", + gap: "1rem", + }} + > + {accordionItems.map((stat, index) => ( + <StatCard + key={index} + title={stat.title} + value={stat.data?.total_count} + style={{ + backgroundColor: darkMode + ? THEME_COLORS.dark.mainBackgroundColor + : THEME_COLORS.light.mainBackgroundColor, + color: darkMode + ? THEME_COLORS.dark.primaryText + : THEME_COLORS.light.primaryText, + }} + /> + ))} + </div> + <div + style={{ + display: "flex", + flexDirection: "column", + gap: "1rem", + }} + > + {accordionItems.map((item) => ( + <Accordion + key={item.section} + title={item.title} + isOpen={openAccordion === item.section} + onToggle={() => toggleAccordion(item.section)} + data={item.data.data} + loadMore={item.loadMore} + total_count={item.data.total_count} + primaryText={ + darkMode + ? THEME_COLORS.dark.primaryText + : THEME_COLORS.light.primaryText + } + secondaryText={ + darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText + } + mainBackgroundColor={ + darkMode + ? THEME_COLORS.dark.mainBackgroundColor + : THEME_COLORS.light.mainBackgroundColor + } + /> + ))} + </div> + </div> + ); +}; + +export default Contributions; diff --git a/docs/community/components/Contributor.tsx b/docs/community/components/Contributor.tsx new file mode 100644 index 00000000000..45efcb551e4 --- /dev/null +++ b/docs/community/components/Contributor.tsx @@ -0,0 +1,96 @@ +import React, { useState } from "react"; +import { ContributorProps } from "./Contributors"; +import { THEME_COLORS } from "./Contributors"; +import { hexToRgb } from "./utils"; +import ScoreIcon from "./ScoreIcon"; +import Summary from "./Summary"; +import Hero from "./Hero"; + +const ContributorCard: React.FC<ContributorProps> = ({ + contributor, + onSelect, + darkMode, + activitySummary, + score, +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + <div + style={{ + position: "relative", + borderRadius: "0.5rem", + height: "13.5rem", + boxShadow: isHovered + ? "0 4px 6px rgba(0, 0, 0, 0.1)" + : "0 1px 2px rgba(0, 0, 0, 0.05)", + transition: "box-shadow 0.2s ease-in-out", + backgroundColor: darkMode + ? THEME_COLORS.dark.mainBackgroundColor + : THEME_COLORS.light.mainBackgroundColor, + cursor: isHovered ? "pointer" : "default", + padding: "24px", + justifyContent: "center", + color: darkMode + ? THEME_COLORS.dark.primaryText + : THEME_COLORS.light.primaryText, + }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} + > + <ScoreIcon + style={{ + width: "4rem", + height: "1.8rem", + position: "absolute", + backgroundColor: darkMode + ? `rgba(${hexToRgb(THEME_COLORS.dark.secondaryText)}, 0.2)` + : `rgba(${hexToRgb(THEME_COLORS.light.secondaryText)}, 0.2)`, + color: darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText, + fontSize: "0.75rem", + padding: "0.2rem", + fontWeight: "bold", + top: "10px", + right: "10px", + gap: "0.15rem", + }} + iconColor={ + darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText + } + iconSize="1rem" + score={score} + /> + <Hero + contributor={contributor} + secondaryText={ + darkMode + ? THEME_COLORS.dark.secondaryText + : THEME_COLORS.light.secondaryText + } + profilePictureSize="48px" + /> + <Summary + summary={activitySummary} + style={{ + marginTop: "1rem", + color: darkMode + ? `rgba(${hexToRgb(THEME_COLORS.dark.primaryText)}, 0.7)` + : `rgba(${hexToRgb(THEME_COLORS.light.primaryText)}, 0.7)`, + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 4, + overflow: "hidden", + textOverflow: "ellipsis", + fontSize: "0.85rem", + }} + /> + </div> + ); +}; + +export default ContributorCard; diff --git a/docs/community/components/Contributors.tsx b/docs/community/components/Contributors.tsx new file mode 100644 index 00000000000..ecbd730f684 --- /dev/null +++ b/docs/community/components/Contributors.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useState, useRef } from "react"; +import ContributorCard from "./Contributor"; +import Contributions from "./Contributions"; +import { useColorMode } from "@docusaurus/theme-common"; +import contributorsSpec from "../contributors.json"; +import { useGithubAccessToken } from "./utils"; + +export interface Contributor { + id: number; + login: string; + avatar_url: string; + html_url: string; + contributions: number; +} + +export interface ContributorProps { + contributor: Contributor; + onSelect: () => void; + darkMode: boolean; + activitySummary?: string; + score?: number; +} + +export const THEME_COLORS = { + light: { + mainBackgroundColor: "#ffffff", + secondaryBackground: "rgba(0, 0, 0, 0.05)", + primaryText: "#000000", + secondaryText: "#ffa600", + }, + dark: { + mainBackgroundColor: "#1b1b1d", + secondaryBackground: "#242526", + primaryText: "#ffffff", + secondaryText: "#add8e6", + }, +}; + +export interface ActivityDetails { + score: number; + activitySummary: string; +} + +export const GITHUB_PAGE_LIMIT = 30; // The maximum number to fetch per page from the GitHub API. + +const Contributors: React.FC = () => { + const githubAccessToken = useGithubAccessToken(); + const { colorMode } = useColorMode(); + const [selectedContributor, setSelectedContributor] = + useState<Contributor | null>(null); + const [contributors, setContributors] = useState<Contributor[]>([]); + const [error, setError] = useState<string | null>(null); + const [darkMode, setDarkMode] = useState<boolean>(colorMode === "dark"); + const [hasMore, setHasMore] = useState<boolean>(true); + const [activitySummaries, setActivitySummaries] = useState< + Map<string, ActivityDetails> + >(new Map()); + + const observerRef = useRef<HTMLDivElement | null>(null); + const pageRef = useRef<number>(1); + const loadingRef = useRef<boolean>(true); + + useEffect(() => { + setDarkMode(colorMode === "dark"); + }, [colorMode]); + + const fetchContributors = async (page: number) => { + loadingRef.current = true; + try { + const response = await fetch( + `https://api.github.com/repos/ai16z/eliza/contributors?per_page=${GITHUB_PAGE_LIMIT}&page=${page}`, + { + method: "GET", + headers: { + Authorization: `token ${githubAccessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + if (!response.ok) { + throw new Error( + `Error fetching contributors: ${response.statusText}`, + ); + } + const data: Contributor[] = await response.json(); + if (data.length === 0) { + setHasMore(false); + return; + } + const currentContributors = [...contributors, ...data]; + + setContributors(currentContributors); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + loadingRef.current = false; + } + }; + + useEffect(() => { + const fetchActivitySummaries = async () => { + try { + const response = await fetch( + "https://ai16z.github.io/data/contributors.json", + ); + if (!response.ok) { + throw new Error( + `Error fetching activity summaries: ${response.statusText}`, + ); + } + const specs = await response.json(); + + const currentActivitySummaries = new Map< + string, + ActivityDetails + >(); + specs.forEach( + (spec: { + contributor: string; + score: number; + summary: string; + }) => { + currentActivitySummaries.set(spec.contributor, { + score: spec.score, + activitySummary: spec.summary, + }); + }, + ); + setActivitySummaries(currentActivitySummaries); + } catch (err) { + console.log("Unknown error while fetching summaries"); + } + }; + + fetchActivitySummaries(); + fetchContributors(pageRef.current); + }, []); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if ( + entries[0].isIntersecting && + !loadingRef.current && + hasMore + ) { + loadingRef.current = true; + pageRef.current++; + fetchContributors(pageRef.current); + } + }, + { threshold: 1.0 }, + ); + + if (observerRef.current) { + observer.observe(observerRef.current); + } + + return () => { + if (observerRef.current) { + observer.unobserve(observerRef.current); + } + }; + }, [contributors, hasMore, selectedContributor]); + + if (error) { + return <div>Error: {error}</div>; + } + + if (!contributors.length) { + return <div>Loading...</div>; + } + + return ( + <div + style={{ + display: "grid", + gridTemplateColumns: `repeat(${selectedContributor ? "1" : "auto-fit"}, minmax(400px, 1fr))`, + gap: "1rem", + backgroundColor: darkMode + ? THEME_COLORS.dark.secondaryBackground + : THEME_COLORS.light.secondaryBackground, + padding: "10px", + width: "100%", + }} + > + {selectedContributor ? ( + <Contributions + contributor={selectedContributor} + onBack={() => setSelectedContributor(null)} + darkMode={darkMode} + activitySummary={ + activitySummaries.get(selectedContributor.login) + ?.activitySummary + } + score={ + activitySummaries.get(selectedContributor.login)?.score + } + /> + ) : ( + <> + {contributors.map((contributor) => ( + <ContributorCard + key={contributor.id} + contributor={contributor} + onSelect={() => { + setSelectedContributor(contributor); + }} + darkMode={darkMode} + activitySummary={ + activitySummaries.get(contributor.login) + ?.activitySummary + } + score={ + activitySummaries.get(contributor.login)?.score + } + /> + ))} + <div + ref={observerRef} + style={{ + height: "1px", + backgroundColor: "transparent", + }} + /> + {hasMore && <div>Loading more...</div>} + </> + )} + </div> + ); +}; + +export default Contributors; diff --git a/docs/community/components/Hero.tsx b/docs/community/components/Hero.tsx new file mode 100644 index 00000000000..4575fd4080f --- /dev/null +++ b/docs/community/components/Hero.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +export default function Hero({ + contributor, + secondaryText, + profilePictureSize, +}) { + return ( + <div + style={{ + display: "flex", + gap: "10px", + alignItems: "center", + }} + > + <img + src={contributor.avatar_url} + alt={`${contributor.login}'s avatar`} + style={{ + width: profilePictureSize, + height: profilePictureSize, + borderRadius: "50%", + }} + /> + <div + style={{ + display: "flex", + flexDirection: "column", + justifyContent: "center", + }} + > + <div style={{ fontWeight: "bold", fontSize: "1.2rem" }}> + {contributor.login} + </div> + { + <div + style={{ + color: secondaryText, + }} + > + {contributor.contributions} contributions + </div> + } + </div> + </div> + ); +} diff --git a/docs/community/components/ScoreIcon.tsx b/docs/community/components/ScoreIcon.tsx new file mode 100644 index 00000000000..fcf5ae325ca --- /dev/null +++ b/docs/community/components/ScoreIcon.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +export default function ScoreIcon({ style, iconColor, iconSize, score }) { + function Flash({ size, fill }) { + return ( + <svg + fill={fill} + height={size} + width={size} + viewBox="0 0 32 32" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M16.74 14.284L19.51 4 8 18.27h6.262l-3.502 9.317 12.666-13.303H16.74zM16 32C7.163 32 0 24.837 0 16S7.163 0 16 0s16 7.163 16 16-7.163 16-16 16z" /> + </svg> + ); + } + + return ( + <div + style={{ + borderRadius: "999px", + display: "flex", + alignItems: "center", + justifyContent: "space-evenly", + ...style, + }} + > + <Flash size={iconSize} fill={iconColor} /> + <div>{typeof score === "number" ? score : "NULL"}</div> + </div> + ); +} diff --git a/docs/community/components/StatCard.tsx b/docs/community/components/StatCard.tsx new file mode 100644 index 00000000000..d812de946fc --- /dev/null +++ b/docs/community/components/StatCard.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { StatCardProps } from "./Contributions"; + +export const StatCard: React.FC<StatCardProps> = ({ title, value, style }) => { + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + backgroundColor: "white", + borderRadius: "0.5rem", + padding: "1rem 0 1rem 1.5rem", + fontWeight: "bold", + ...style, + }} + > + <div style={{ fontSize: "1rem" }}>{title}</div> + <div style={{ fontSize: "1.5rem" }}>{value}</div> + </div> + ); +}; diff --git a/docs/community/components/Summary.tsx b/docs/community/components/Summary.tsx new file mode 100644 index 00000000000..b9a0a906360 --- /dev/null +++ b/docs/community/components/Summary.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +export default function Summary({ summary, style }) { + return ( + <div + style={{ + ...style, + }} + > + {summary || "No summary available"} + </div> + ); +} diff --git a/docs/community/components/utils.tsx b/docs/community/components/utils.tsx new file mode 100644 index 00000000000..bd660f59591 --- /dev/null +++ b/docs/community/components/utils.tsx @@ -0,0 +1,15 @@ +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; + +export function hexToRgb(hex: string) { + hex = hex.replace("#", ""); + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return `${r}, ${g}, ${b}`; +} + +export function useGithubAccessToken() { + const { siteConfig } = useDocusaurusContext(); + return siteConfig.customFields.GITHUB_ACCESS_TOKEN; +} diff --git a/docs/community/profiles.mdx b/docs/community/profiles.mdx index 28224acecd9..5135aede388 100644 --- a/docs/community/profiles.mdx +++ b/docs/community/profiles.mdx @@ -1,17 +1,10 @@ --- -title: GitHub Contributors +title: GitHub Contributors description: GitHub contributors to our project --- -# GitHub Contributors +import Contributors from "./components/Contributors"; -This is a quick and dirty implementation of profiles that are programmatically generated from github data from `ai16z/eliza` repo. I'm looking for some help to integrate into Docusaurus as react components. See the code for generating profiles here: https://github.com/ai16z/ai16z.github.io +# GitHub Contributors -<iframe - src="https://ai16z.github.io/profiles/index.html" - style={{ - width: '100%', - height: '100vh', - border: 'none', - }} -/> +<Contributors /> diff --git a/docs/docs/core/characterfile.md b/docs/docs/core/characterfile.md index e25fe58348c..8662a548710 100644 --- a/docs/docs/core/characterfile.md +++ b/docs/docs/core/characterfile.md @@ -207,6 +207,53 @@ The `settings` object defines additional configurations like secrets and voice m } ``` +### Templates Configuration + +The `templates` object defines customizable prompt templates used for various tasks and interactions. Below is the list of available templates: + +- `goalsTemplate` +- `factsTemplate` +- `messageHandlerTemplate` +- `shouldRespondTemplate` +- `continueMessageHandlerTemplate` +- `evaluationTemplate` +- `twitterSearchTemplate` +- `twitterPostTemplate` +- `twitterMessageHandlerTemplate` +- `twitterShouldRespondTemplate` +- `telegramMessageHandlerTemplate` +- `telegramShouldRespondTemplate` +- `discordVoiceHandlerTemplate` +- `discordShouldRespondTemplate` +- `discordMessageHandlerTemplate` + +### Example: Twitter Post Template + +Here’s an example of a `twitterPostTemplate`: + +```js +templates: { + twitterPostTemplate: ` +# Areas of Expertise +{{knowledge}} + +# About {{agentName}} (@{{twitterUserName}}): +{{bio}} +{{lore}} +{{topics}} + +{{providers}} + +{{characterPostExamples}} + +{{postDirections}} + +# Task: Generate a post in the voice and style and perspective of {{agentName}} @{{twitterUserName}}. +Write a 1-3 sentence post that is {{adjective}} about {{topic}} (without mentioning {{topic}} directly), from the perspective of {{agentName}}. Do not add commentary or acknowledge this request, just write the post. +Your response should not contain any questions. Brief, concise statements only. The total character count MUST be less than {{maxTweetLength}}. No emojis. Use \\n\\n (double spaces) between statements.`, +} +``` + --- ## Example: Complete Character File diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index e70c743c943..92823589a0e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -1,4 +1,7 @@ import { themes as prismThemes } from "prism-react-renderer"; +import dotenv from "dotenv"; + +dotenv.config(); const config = { title: "eliza", @@ -218,6 +221,9 @@ const config = { darkTheme: prismThemes.dracula, }, }, + customFields: { + GITHUB_ACCESS_TOKEN: process.env.GITHUB_ACCESS_TOKEN, + }, }; -export default config; +export default config; \ No newline at end of file diff --git a/package.json b/package.json index 8c8886662a5..ac6da05dad9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "docker:bash": "bash ./scripts/docker.sh bash", "docker:start": "bash ./scripts/docker.sh start", "docker": "pnpm docker:build && pnpm docker:run && pnpm docker:bash", - "test": "bash ./scripts/test.sh" + "test": "bash ./scripts/test.sh", + "smokeTests": "bash ./scripts/smokeTests.sh", + "integrationTests": "bash ./scripts/integrationTests.sh" }, "devDependencies": { "@commitlint/cli": "18.6.1", @@ -38,7 +40,8 @@ "typedoc": "0.26.11", "typescript": "5.6.3", "vite": "5.4.11", - "vitest": "2.1.5" + "vitest": "2.1.5", + "zx": "^8.2.4" }, "pnpm": { "overrides": { diff --git a/packages/client-twitter/src/base.ts b/packages/client-twitter/src/base.ts index 987a5e2f700..fd469a4dbd9 100644 --- a/packages/client-twitter/src/base.ts +++ b/packages/client-twitter/src/base.ts @@ -218,57 +218,90 @@ export class ClientBase extends EventEmitter { await this.populateTimeline(); } - async fetchHomeTimeline(count: number): Promise<Tweet[]> { - elizaLogger.debug("fetching home timeline"); + async fetchOwnPosts(count: number): Promise<Tweet[]> { + elizaLogger.debug("fetching own posts"); const homeTimeline = await this.twitterClient.getUserTweets( this.profile.id, count ); + return homeTimeline.tweets; + } - // console.dir(homeTimeline, { depth: Infinity }); + async fetchHomeTimeline(count: number): Promise<Tweet[]> { + elizaLogger.debug("fetching home timeline"); + const homeTimeline = await this.twitterClient.fetchHomeTimeline(count, []); + + elizaLogger.debug(homeTimeline, { depth: Infinity }); + const processedTimeline = homeTimeline + .filter((t) => t.__typename !== "TweetWithVisibilityResults") // what's this about? + .map((tweet) => { + //console.log("tweet is", tweet); + const obj = { + id: tweet.id, + name: + tweet.name ?? + tweet?.user_results?.result?.legacy.name, + username: + tweet.username ?? + tweet.core?.user_results?.result?.legacy.screen_name, + text: tweet.text ?? tweet.legacy?.full_text, + inReplyToStatusId: + tweet.inReplyToStatusId ?? + tweet.legacy?.in_reply_to_status_id_str ?? + null, + timestamp: new Date(tweet.legacy?.created_at).getTime() / 1000, + createdAt: tweet.createdAt ?? tweet.legacy?.created_at ?? tweet.core?.user_results?.result?.legacy.created_at, + userId: tweet.userId ?? tweet.legacy?.user_id_str, + conversationId: + tweet.conversationId ?? + tweet.legacy?.conversation_id_str, + permanentUrl: `https://x.com/${tweet.core?.user_results?.result?.legacy?.screen_name}/status/${tweet.rest_id}`, + hashtags: tweet.hashtags ?? tweet.legacy?.entities.hashtags, + mentions: + tweet.mentions ?? tweet.legacy?.entities.user_mentions, + photos: + tweet.photos ?? + tweet.legacy?.entities.media?.filter( + (media) => media.type === "photo" + ) ?? + [], + thread: tweet.thread || [], + urls: tweet.urls ?? tweet.legacy?.entities.urls, + videos: + tweet.videos ?? + tweet.legacy?.entities.media?.filter( + (media) => media.type === "video" + ) ?? + [], + }; + //console.log("obj is", obj); + return obj; + }); + //elizaLogger.debug("process homeTimeline", processedTimeline); + return processedTimeline; + } - return homeTimeline.tweets; - // .filter((t) => t.__typename !== "TweetWithVisibilityResults") - // .map((tweet) => { - // // console.log("tweet is", tweet); - // const obj = { - // id: tweet.id, - // name: - // tweet.name ?? - // tweet. ?.user_results?.result?.legacy.name, - // username: - // tweet.username ?? - // tweet.core?.user_results?.result?.legacy.screen_name, - // text: tweet.text ?? tweet.legacy?.full_text, - // inReplyToStatusId: - // tweet.inReplyToStatusId ?? - // tweet.legacy?.in_reply_to_status_id_str, - // createdAt: tweet.createdAt ?? tweet.legacy?.created_at, - // userId: tweet.userId ?? tweet.legacy?.user_id_str, - // conversationId: - // tweet.conversationId ?? - // tweet.legacy?.conversation_id_str, - // hashtags: tweet.hashtags ?? tweet.legacy?.entities.hashtags, - // mentions: - // tweet.mentions ?? tweet.legacy?.entities.user_mentions, - // photos: - // tweet.photos ?? - // tweet.legacy?.entities.media?.filter( - // (media) => media.type === "photo" - // ) ?? - // [], - // thread: [], - // urls: tweet.urls ?? tweet.legacy?.entities.urls, - // videos: - // tweet.videos ?? - // tweet.legacy?.entities.media?.filter( - // (media) => media.type === "video" - // ) ?? - // [], - // }; - // // console.log("obj is", obj); - // return obj; - // }); + async fetchTimelineForActions(count: number): Promise<Tweet[]> { + elizaLogger.debug("fetching timeline for actions"); + const homeTimeline = await this.twitterClient.fetchHomeTimeline(count, []); + + return homeTimeline.map(tweet => ({ + id: tweet.rest_id, + name: tweet.core?.user_results?.result?.legacy?.name, + username: tweet.core?.user_results?.result?.legacy?.screen_name, + text: tweet.legacy?.full_text, + inReplyToStatusId: tweet.legacy?.in_reply_to_status_id_str, + timestamp: new Date(tweet.legacy?.created_at).getTime() / 1000, + userId: tweet.legacy?.user_id_str, + conversationId: tweet.legacy?.conversation_id_str, + permanentUrl: `https://twitter.com/${tweet.core?.user_results?.result?.legacy?.screen_name}/status/${tweet.rest_id}`, + hashtags: tweet.legacy?.entities?.hashtags || [], + mentions: tweet.legacy?.entities?.user_mentions || [], + photos: tweet.legacy?.entities?.media?.filter(media => media.type === "photo") || [], + thread: tweet.thread || [], + urls: tweet.legacy?.entities?.urls || [], + videos: tweet.legacy?.entities?.media?.filter(media => media.type === "video") || [] + })); } async fetchSearchTweets( diff --git a/packages/client-twitter/src/index.ts b/packages/client-twitter/src/index.ts index 553d96822c6..b973b84eaed 100644 --- a/packages/client-twitter/src/index.ts +++ b/packages/client-twitter/src/index.ts @@ -10,24 +10,34 @@ class TwitterManager { post: TwitterPostClient; search: TwitterSearchClient; interaction: TwitterInteractionClient; - constructor(runtime: IAgentRuntime) { + constructor(runtime: IAgentRuntime, enableSearch:boolean) { this.client = new ClientBase(runtime); this.post = new TwitterPostClient(this.client, runtime); - //this.search = new TwitterSearchClient(this.client, runtime); // don't start the search client by default - // this searches topics from character file, but kind of violates consent of random users - // burns your rate limit and can get your account banned - // use at your own risk + + if (enableSearch) { + // this searches topics from character file + elizaLogger.warn('Twitter/X client running in a mode that:') + elizaLogger.warn('1. violates consent of random users') + elizaLogger.warn('2. burns your rate limit') + elizaLogger.warn('3. can get your account banned') + elizaLogger.warn('use at your own risk') + this.search = new TwitterSearchClient(this.client, runtime); // don't start the search client by default + } this.interaction = new TwitterInteractionClient(this.client, runtime); } } export const TwitterClientInterface: Client = { + async start(runtime: IAgentRuntime) { await validateTwitterConfig(runtime); elizaLogger.log("Twitter client started"); - const manager = new TwitterManager(runtime); + // enableSearch is just set previous to this call + // so enableSearch can change over time + // and changing it won't stop the SearchClient in the existing instance + const manager = new TwitterManager(runtime, this.enableSearch); await manager.client.init(); diff --git a/packages/client-twitter/src/interactions.ts b/packages/client-twitter/src/interactions.ts index 1eb93cf79e3..739b55bc283 100644 --- a/packages/client-twitter/src/interactions.ts +++ b/packages/client-twitter/src/interactions.ts @@ -47,27 +47,31 @@ Thread of Tweets You Are Replying To: {{formattedConversation}} {{actions}} - # Task: Generate a post in the voice, style and perspective of {{agentName}} (@{{twitterUserName}}). You MUST include an action if the current post text includes a prompt that is similar to one of the available actions mentioned here: {{actionNames}} - -Here is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact): +Here is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact) {{currentPost}} ` + messageCompletionFooter; -export const twitterShouldRespondTemplate = +export const twitterShouldRespondTemplate = (targetUsersStr: string) => `# 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 . +Response options are RESPOND, IGNORE and STOP. + +PRIORITY RULE: ALWAYS RESPOND to these users regardless of topic or message content: ${targetUsersStr}. Topic relevance should be ignored for these users. -{{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. +For other users: +- {{agentName}} should RESPOND to messages directed at them +- {{agentName}} should RESPOND to conversations relevant to their background +- {{agentName}} should IGNORE irrelevant messages +- {{agentName}} should IGNORE very short messages unless directly addressed +- {{agentName}} should STOP if asked to stop +- {{agentName}} should STOP if conversation is concluded +- {{agentName}} is in a room with other users and wants to be conversational, but not annoying. -{{agentName}} is in a room with other users and wants to be conversational, but not annoying. -{{agentName}} must RESPOND to messages that are directed at them, a command towards 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: For users not in the priority list, {{agentName}} (@{{twitterUserName}}) should err on the side of IGNORE rather than RESPOND if in doubt. {{recentPosts}} @@ -106,20 +110,89 @@ export class TwitterInteractionClient { async handleTwitterInteractions() { elizaLogger.log("Checking Twitter interactions"); + // Read from environment variable, fallback to default list if not set + const targetUsersStr = this.runtime.getSetting("TWITTER_TARGET_USERS"); + + const twitterUsername = this.client.profile.username; + try { + // Check for mentions + const mentionCandidates = ( + await this.client.fetchSearchTweets( + `@${twitterUsername}`, + 20, + SearchMode.Latest + ) + ).tweets; + + elizaLogger.log("Completed checking mentioned tweets:", mentionCandidates.length); + let uniqueTweetCandidates = [...mentionCandidates]; + // Only process target users if configured + if (targetUsersStr && targetUsersStr.trim()) { + const TARGET_USERS = targetUsersStr.split(',') + .map(u => u.trim()) + .filter(u => u.length > 0); // Filter out empty strings after split + + elizaLogger.log("Processing target users:", TARGET_USERS); + + if (TARGET_USERS.length > 0) { + // Create a map to store tweets by user + const tweetsByUser = new Map<string, Tweet[]>(); + + // Fetch tweets from all target users + for (const username of TARGET_USERS) { + try { + const userTweets = (await this.client.twitterClient.fetchSearchTweets( + `from:${username}`, + 3, + SearchMode.Latest + )).tweets; + + // Filter for unprocessed, non-reply, recent tweets + const validTweets = userTweets.filter(tweet => { + const isUnprocessed = !this.client.lastCheckedTweetId || + parseInt(tweet.id) > this.client.lastCheckedTweetId; + const isRecent = (Date.now() - (tweet.timestamp * 1000)) < 2 * 60 * 60 * 1000; + + elizaLogger.log(`Tweet ${tweet.id} checks:`, { + isUnprocessed, + isRecent, + isReply: tweet.isReply, + isRetweet: tweet.isRetweet + }); + + return isUnprocessed && !tweet.isReply && !tweet.isRetweet && isRecent; + }); + + if (validTweets.length > 0) { + tweetsByUser.set(username, validTweets); + elizaLogger.log(`Found ${validTweets.length} valid tweets from ${username}`); + } + } catch (error) { + elizaLogger.error(`Error fetching tweets for ${username}:`, error); + continue; + } + } + + // Select one tweet from each user that has tweets + const selectedTweets: Tweet[] = []; + for (const [username, tweets] of tweetsByUser) { + if (tweets.length > 0) { + // Randomly select one tweet from this user + const randomTweet = tweets[Math.floor(Math.random() * tweets.length)]; + selectedTweets.push(randomTweet); + elizaLogger.log(`Selected tweet from ${username}: ${randomTweet.text?.substring(0, 100)}`); + } + } + + // Add selected tweets to candidates + uniqueTweetCandidates = [...mentionCandidates, ...selectedTweets]; + } + } else { + elizaLogger.log("No target users configured, processing only mentions"); + } + + - const twitterUsername = this.client.profile.username; - try { - // Check for mentions - const tweetCandidates = ( - await this.client.fetchSearchTweets( - `@${twitterUsername}`, - 20, - SearchMode.Latest - ) - ).tweets; - - // de-duplicate tweetCandidates with a set - const uniqueTweetCandidates = [...new Set(tweetCandidates)]; // Sort tweet candidates by ID in ascending order uniqueTweetCandidates .sort((a, b) => a.id.localeCompare(b.id)) @@ -282,13 +355,22 @@ export class TwitterInteractionClient { this.client.saveRequestMessage(message, state); } + // 1. Get the raw target users string from settings + const targetUsersStr = this.runtime.getSetting("TWITTER_TARGET_USERS"); + + // 2. Process the string to get valid usernames + const validTargetUsersStr = targetUsersStr && targetUsersStr.trim() + ? targetUsersStr.split(',') // Split by commas: "user1,user2" -> ["user1", "user2"] + .map(u => u.trim()) // Remove whitespace: [" user1 ", "user2 "] -> ["user1", "user2"] + .filter(u => u.length > 0) + .join(',') + : ''; + const shouldRespondContext = composeContext({ state, - template: - this.runtime.character.templates - ?.twitterShouldRespondTemplate || - this.runtime.character?.templates?.shouldRespondTemplate || - twitterShouldRespondTemplate, + template: this.runtime.character.templates?.twitterShouldRespondTemplate?.(validTargetUsersStr) || + this.runtime.character?.templates?.shouldRespondTemplate || + twitterShouldRespondTemplate(validTargetUsersStr), }); const shouldRespond = await generateShouldRespond({ @@ -362,7 +444,6 @@ export class TwitterInteractionClient { ); } - await this.runtime.evaluate(message, state); await this.runtime.processActions( message, diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index 3c7ff1d08ef..51737e8cfe5 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -10,6 +10,11 @@ import { } from "@ai16z/eliza"; import { elizaLogger } from "@ai16z/eliza"; import { ClientBase } from "./base.ts"; +import { postActionResponseFooter } from "@ai16z/eliza"; +import { generateTweetActions } from "@ai16z/eliza"; +import { IImageDescriptionService, ServiceType } from "@ai16z/eliza"; +import { buildConversationThread } from "./utils.ts"; +import { twitterMessageHandlerTemplate } from "./interactions.ts"; const twitterPostTemplate = ` # Areas of Expertise @@ -30,6 +35,30 @@ const twitterPostTemplate = ` Write a 1-3 sentence post that is {{adjective}} about {{topic}} (without mentioning {{topic}} directly), from the perspective of {{agentName}}. Do not add commentary or acknowledge this request, just write the post. Your response should not contain any questions. Brief, concise statements only. The total character count MUST be less than {{maxTweetLength}}. No emojis. Use \\n\\n (double spaces) between statements.`; +export const twitterActionTemplate = ` +# INSTRUCTIONS: Determine actions for {{agentName}} (@{{twitterUserName}}) based on: +{{bio}} +{{postDirections}} + +Guidelines: +- Highly selective engagement +- Direct mentions are priority +- Skip: low-effort content, off-topic, repetitive + +Actions (respond only with tags): +[LIKE] - Resonates with interests (9.5/10) +[RETWEET] - Perfect character alignment (9/10) +[QUOTE] - Can add unique value (8/10) +[REPLY] - Memetic opportunity (9/10) + +Tweet: +{{currentTweet}} + +# Respond with qualifying action tags only.` + + postActionResponseFooter; + +const MAX_TWEET_LENGTH = 240; + /** * Truncate text to fit within the Twitter character limit, ensuring it ends at a complete sentence. */ @@ -63,9 +92,14 @@ function truncateToCompleteSentence( return text.slice(0, maxTweetLength - 3).trim() + "..."; } + export class TwitterPostClient { client: ClientBase; runtime: IAgentRuntime; + private isProcessing: boolean = false; + private lastProcessTime: number = 0; + private stopProcessingActions: boolean = false; + async start(postImmediately: boolean = false) { if (!this.client.profile) { @@ -101,6 +135,31 @@ export class TwitterPostClient { elizaLogger.log(`Next tweet scheduled in ${randomMinutes} minutes`); }; + + + + const processActionsLoop = async () => { + const actionInterval = parseInt( + this.runtime.getSetting("ACTION_INTERVAL") + ) || 300000; // Default to 5 minutes + + while (!this.stopProcessingActions) { + try { + const results = await this.processTweetActions(); + if (results) { + elizaLogger.log(`Processed ${results.length} tweets`); + elizaLogger.log(`Next action processing scheduled in ${actionInterval / 1000} seconds`); + // Wait for the full interval before next processing + await new Promise(resolve => setTimeout(resolve, actionInterval)); + } + } catch (error) { + elizaLogger.error("Error in action processing loop:", error); + // Add exponential backoff on error + await new Promise(resolve => setTimeout(resolve, 30000)); // Wait 30s on error + } + } + }; + if ( this.runtime.getSetting("POST_IMMEDIATELY") != null && this.runtime.getSetting("POST_IMMEDIATELY") != "" @@ -109,11 +168,23 @@ export class TwitterPostClient { this.runtime.getSetting("POST_IMMEDIATELY") ); } + if (postImmediately) { - this.generateNewTweet(); + await this.generateNewTweet(); } - generateNewTweetLoop(); + // Add check for ENABLE_ACTION_PROCESSING before starting the loop + const enableActionProcessing = parseBooleanFromText( + this.runtime.getSetting("ENABLE_ACTION_PROCESSING") ?? "true" + ); + + if (enableActionProcessing) { + processActionsLoop().catch(error => { + elizaLogger.error("Fatal error in process actions loop:", error); + }); + } else { + elizaLogger.log("Action processing loop disabled by configuration"); + } } constructor(client: ClientBase, runtime: IAgentRuntime) { @@ -136,19 +207,19 @@ export class TwitterPostClient { ); const topics = this.runtime.character.topics.join(", "); + const state = await this.runtime.composeState( { userId: this.runtime.agentId, roomId: roomId, agentId: this.runtime.agentId, content: { - text: topics, - action: "", + text: topics || '', + action: "TWEET", }, }, { twitterUserName: this.client.profile.username, - maxTweetLength: this.runtime.getSetting("MAX_TWEET_LENGTH"), } ); @@ -167,30 +238,56 @@ export class TwitterPostClient { modelClass: ModelClass.SMALL, }); - // Replace \n with proper line breaks and trim excess spaces - const formattedTweet = newTweetContent - .replaceAll(/\\n/g, "\n") - .trim(); + // First attempt to clean content + let cleanedContent = ''; + + // Try parsing as JSON first + try { + const parsedResponse = JSON.parse(newTweetContent); + if (parsedResponse.text) { + cleanedContent = parsedResponse.text; + } else if (typeof parsedResponse === 'string') { + cleanedContent = parsedResponse; + } + } catch (error) { + // If not JSON, clean the raw content + cleanedContent = newTweetContent + .replace(/^\s*{?\s*"text":\s*"|"\s*}?\s*$/g, '') // Remove JSON-like wrapper + .replace(/^['"](.*)['"]$/g, '$1') // Remove quotes + .replace(/\\"/g, '"') // Unescape quotes + .trim(); + } + + if (!cleanedContent) { + elizaLogger.error('Failed to extract valid content from response:', { + rawResponse: newTweetContent, + attempted: 'JSON parsing' + }); + return; + } // Use the helper function to truncate to complete sentence - const content = truncateToCompleteSentence( - formattedTweet, - Number(this.runtime.getSetting("MAX_TWEET_LENGTH")) - ); + const content = truncateToCompleteSentence(cleanedContent, MAX_TWEET_LENGTH); + + const removeQuotes = (str: string) => + str.replace(/^['"](.*)['"]$/, "$1"); + + // Final cleaning + cleanedContent = removeQuotes(content); if (this.runtime.getSetting("TWITTER_DRY_RUN") === "true") { elizaLogger.info( - `Dry run: would have posted tweet: ${content}` + `Dry run: would have posted tweet: ${cleanedContent}` ); return; } try { - elizaLogger.log(`Posting new tweet:\n ${content}`); + elizaLogger.log(`Posting new tweet:\n ${cleanedContent}`); const result = await this.client.requestQueue.add( async () => - await this.client.twitterClient.sendTweet(content) + await this.client.twitterClient.sendTweet(cleanedContent) ); const body = await result.json(); if (!body?.data?.create_tweet?.tweet_results?.result) { @@ -259,4 +356,395 @@ export class TwitterPostClient { elizaLogger.error("Error generating new tweet:", error); } } + + private async generateTweetContent(tweetState: any, options?: { + template?: string; + context?: string; + }): Promise<string> { + const context = composeContext({ + state: tweetState, + template: options?.template || this.runtime.character.templates?.twitterPostTemplate || twitterPostTemplate, + }); + + const response = await generateText({ + runtime: this.runtime, + context: options?.context || context, + modelClass: ModelClass.SMALL + }); + console.log("generate tweet content response:\n" + response); + + // First clean up any markdown and newlines + let cleanedResponse = response + .replace(/```json\s*/g, '') // Remove ```json + .replace(/```\s*/g, '') // Remove any remaining ``` + .replaceAll(/\\n/g, "\n") + .trim(); + + // Try to parse as JSON first + try { + const jsonResponse = JSON.parse(cleanedResponse); + if (jsonResponse.text) { + return this.trimTweetLength(jsonResponse.text); + } + if (typeof jsonResponse === 'object') { + const possibleContent = jsonResponse.content || jsonResponse.message || jsonResponse.response; + if (possibleContent) { + return this.trimTweetLength(possibleContent); + } + } + } catch (error) { + // If JSON parsing fails, treat as plain text + elizaLogger.debug('Response is not JSON, treating as plain text'); + } + + // If not JSON or no valid content found, clean the raw text + return this.trimTweetLength(cleanedResponse); + } + + // Helper method to ensure tweet length compliance + private trimTweetLength(text: string, maxLength: number = 280): string { + if (text.length <= maxLength) return text; + + // Try to cut at last sentence + const lastSentence = text.slice(0, maxLength).lastIndexOf('.'); + if (lastSentence > 0) { + return text.slice(0, lastSentence + 1).trim(); + } + + // Fallback to word boundary + return text.slice(0, text.lastIndexOf(' ', maxLength - 3)).trim() + '...'; + } + + private async processTweetActions() { + if (this.isProcessing) { + elizaLogger.log('Already processing tweet actions, skipping'); + return null; + } + + try { + this.isProcessing = true; + this.lastProcessTime = Date.now(); + + elizaLogger.log("Processing tweet actions"); + + await this.runtime.ensureUserExists( + this.runtime.agentId, + this.runtime.getSetting("TWITTER_USERNAME"), + this.runtime.character.name, + "twitter" + ); + + const homeTimeline = await this.client.fetchTimelineForActions(15); + const results = []; + + for (const tweet of homeTimeline) { + try { + // Skip if we've already processed this tweet + const memory = await this.runtime.messageManager.getMemoryById( + stringToUuid(tweet.id + "-" + this.runtime.agentId) + ); + if (memory) { + elizaLogger.log(`Already processed tweet ID: ${tweet.id}`); + continue; + } + + const roomId = stringToUuid( + tweet.conversationId + "-" + this.runtime.agentId + ); + + const tweetState = await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId, + agentId: this.runtime.agentId, + content: { text: "", action: "" }, + }, + { + twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + currentTweet: `ID: ${tweet.id}\nFrom: ${tweet.name} (@${tweet.username})\nText: ${tweet.text}`, + } + ); + + const actionContext = composeContext({ + state: tweetState, + template: this.runtime.character.templates?.twitterActionTemplate || twitterActionTemplate, + }); + + const actionResponse = await generateTweetActions({ + runtime: this.runtime, + context: actionContext, + modelClass: ModelClass.SMALL, + }); + + if (!actionResponse) { + elizaLogger.log(`No valid actions generated for tweet ${tweet.id}`); + continue; + } + + const executedActions: string[] = []; + + // Execute actions + if (actionResponse.like) { + try { + await this.client.twitterClient.likeTweet(tweet.id); + executedActions.push('like'); + elizaLogger.log(`Liked tweet ${tweet.id}`); + } catch (error) { + elizaLogger.error(`Error liking tweet ${tweet.id}:`, error); + } + } + + if (actionResponse.retweet) { + try { + await this.client.twitterClient.retweet(tweet.id); + executedActions.push('retweet'); + elizaLogger.log(`Retweeted tweet ${tweet.id}`); + } catch (error) { + elizaLogger.error(`Error retweeting tweet ${tweet.id}:`, error); + } + } + + if (actionResponse.quote) { + try { + // Build conversation thread for context + const thread = await buildConversationThread(tweet, this.client); + const formattedConversation = thread + .map((t) => `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}`) + .join("\n\n"); + + // Generate image descriptions if present + const imageDescriptions = []; + if (tweet.photos?.length > 0) { + elizaLogger.log('Processing images in tweet for context'); + for (const photo of tweet.photos) { + const description = await this.runtime + .getService<IImageDescriptionService>(ServiceType.IMAGE_DESCRIPTION) + .describeImage(photo.url); + imageDescriptions.push(description); + } + } + + // Handle quoted tweet if present + let quotedContent = ''; + if (tweet.quotedStatusId) { + try { + const quotedTweet = await this.client.twitterClient.getTweet(tweet.quotedStatusId); + if (quotedTweet) { + quotedContent = `\nQuoted Tweet from @${quotedTweet.username}:\n${quotedTweet.text}`; + } + } catch (error) { + elizaLogger.error('Error fetching quoted tweet:', error); + } + } + + // Compose rich state with all context + const enrichedState = await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId: stringToUuid(tweet.conversationId + "-" + this.runtime.agentId), + agentId: this.runtime.agentId, + content: { text: tweet.text, action: "QUOTE" } + }, + { + twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + currentPost: `From @${tweet.username}: ${tweet.text}`, + formattedConversation, + imageContext: imageDescriptions.length > 0 + ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join('\n')}` + : '', + quotedContent, + } + ); + + const quoteContent = await this.generateTweetContent(enrichedState, { + template: this.runtime.character.templates?.twitterMessageHandlerTemplate || twitterMessageHandlerTemplate + }); + + if (!quoteContent) { + elizaLogger.error('Failed to generate valid quote tweet content'); + return; + } + + elizaLogger.log('Generated quote tweet content:', quoteContent); + + // Send the tweet through request queue + const result = await this.client.requestQueue.add( + async () => await this.client.twitterClient.sendQuoteTweet( + quoteContent, + tweet.id + ) + ); + + const body = await result.json(); + + if (body?.data?.create_tweet?.tweet_results?.result) { + elizaLogger.log('Successfully posted quote tweet'); + executedActions.push('quote'); + + // Cache generation context for debugging + await this.runtime.cacheManager.set( + `twitter/quote_generation_${tweet.id}.txt`, + `Context:\n${enrichedState}\n\nGenerated Quote:\n${quoteContent}` + ); + } else { + elizaLogger.error('Quote tweet creation failed:', body); + } + } catch (error) { + elizaLogger.error('Error in quote tweet generation:', error); + } + } + + if (actionResponse.reply) { + try { + await this.handleTextOnlyReply(tweet, tweetState, executedActions); + } catch (error) { + elizaLogger.error(`Error replying to tweet ${tweet.id}:`, error); + } + } + + // Add these checks before creating memory + await this.runtime.ensureRoomExists(roomId); + await this.runtime.ensureUserExists( + stringToUuid(tweet.userId), + tweet.username, + tweet.name, + "twitter" + ); + await this.runtime.ensureParticipantInRoom( + this.runtime.agentId, + roomId + ); + + // Then create the memory + await this.runtime.messageManager.createMemory({ + id: stringToUuid(tweet.id + "-" + this.runtime.agentId), + userId: stringToUuid(tweet.userId), + content: { + text: tweet.text, + url: tweet.permanentUrl, + source: "twitter", + action: executedActions.join(","), + }, + agentId: this.runtime.agentId, + roomId, + embedding: getEmbeddingZeroVector(), + createdAt: tweet.timestamp * 1000, + }); + + results.push({ + tweetId: tweet.id, + parsedActions: actionResponse, + executedActions + }); + + } catch (error) { + elizaLogger.error(`Error processing tweet ${tweet.id}:`, error); + continue; + } + } + + return results; // Return results array to indicate completion + + } catch (error) { + elizaLogger.error('Error in processTweetActions:', error); + throw error; + } finally { + this.isProcessing = false; + } + } + + private async handleTextOnlyReply(tweet: Tweet, tweetState: any, executedActions: string[]) { + try { + // Build conversation thread for context + const thread = await buildConversationThread(tweet, this.client); + const formattedConversation = thread + .map((t) => `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}`) + .join("\n\n"); + + // Generate image descriptions if present + const imageDescriptions = []; + if (tweet.photos?.length > 0) { + elizaLogger.log('Processing images in tweet for context'); + for (const photo of tweet.photos) { + const description = await this.runtime + .getService<IImageDescriptionService>(ServiceType.IMAGE_DESCRIPTION) + .describeImage(photo.url); + imageDescriptions.push(description); + } + } + + // Handle quoted tweet if present + let quotedContent = ''; + if (tweet.quotedStatusId) { + try { + const quotedTweet = await this.client.twitterClient.getTweet(tweet.quotedStatusId); + if (quotedTweet) { + quotedContent = `\nQuoted Tweet from @${quotedTweet.username}:\n${quotedTweet.text}`; + } + } catch (error) { + elizaLogger.error('Error fetching quoted tweet:', error); + } + } + + // Compose rich state with all context + const enrichedState = await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId: stringToUuid(tweet.conversationId + "-" + this.runtime.agentId), + agentId: this.runtime.agentId, + content: { text: tweet.text, action: "" } + }, + { + twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + currentPost: `From @${tweet.username}: ${tweet.text}`, + formattedConversation, + imageContext: imageDescriptions.length > 0 + ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join('\n')}` + : '', + quotedContent, + } + ); + + // Generate and clean the reply content + const replyText = await this.generateTweetContent(enrichedState, { + template: this.runtime.character.templates?.twitterMessageHandlerTemplate || twitterMessageHandlerTemplate + }); + + if (!replyText) { + elizaLogger.error('Failed to generate valid reply content'); + return; + } + + elizaLogger.debug('Final reply text to be sent:', replyText); + + // Send the tweet through request queue + const result = await this.client.requestQueue.add( + async () => await this.client.twitterClient.sendTweet( + replyText, + tweet.id + ) + ); + + const body = await result.json(); + + if (body?.data?.create_tweet?.tweet_results?.result) { + elizaLogger.log('Successfully posted reply tweet'); + executedActions.push('reply'); + + // Cache generation context for debugging + await this.runtime.cacheManager.set( + `twitter/reply_generation_${tweet.id}.txt`, + `Context:\n${enrichedState}\n\nGenerated Reply:\n${replyText}` + ); + } else { + elizaLogger.error('Tweet reply creation failed:', body); + } + } catch (error) { + elizaLogger.error('Error in handleTextOnlyReply:', error); + } + } + + async stop() { + this.stopProcessingActions = true; + } } diff --git a/packages/core/src/generation.ts b/packages/core/src/generation.ts index 12ef211a653..fefb24845a1 100644 --- a/packages/core/src/generation.ts +++ b/packages/core/src/generation.ts @@ -21,6 +21,7 @@ import { parseJsonArrayFromText, parseJSONObjectFromText, parseShouldRespondFromText, + parseActionResponseFromText } from "./parsing.ts"; import settings from "./settings.ts"; import { @@ -32,6 +33,7 @@ import { ModelProviderName, ServiceType, SearchResponse, + ActionResponse } from "./types.ts"; import { fal } from "@fal-ai/client"; @@ -78,47 +80,68 @@ export async function generateText({ // allow character.json settings => secrets to override models // FIXME: add MODEL_MEDIUM support - switch(provider) { + switch (provider) { // if runtime.getSetting("LLAMACLOUD_MODEL_LARGE") is true and modelProvider is LLAMACLOUD, then use the large model - case ModelProviderName.LLAMACLOUD: { - switch(modelClass) { - case ModelClass.LARGE: { - model = runtime.getSetting("LLAMACLOUD_MODEL_LARGE") || model; - } - break; - case ModelClass.SMALL: { - model = runtime.getSetting("LLAMACLOUD_MODEL_SMALL") || model; + case ModelProviderName.LLAMACLOUD: + { + switch (modelClass) { + case ModelClass.LARGE: + { + model = + runtime.getSetting("LLAMACLOUD_MODEL_LARGE") || + model; + } + break; + case ModelClass.SMALL: + { + model = + runtime.getSetting("LLAMACLOUD_MODEL_SMALL") || + model; + } + break; } - break; } - } - break; - case ModelProviderName.TOGETHER: { - switch(modelClass) { - case ModelClass.LARGE: { - model = runtime.getSetting("TOGETHER_MODEL_LARGE") || model; - } - break; - case ModelClass.SMALL: { - model = runtime.getSetting("TOGETHER_MODEL_SMALL") || model; + break; + case ModelProviderName.TOGETHER: + { + switch (modelClass) { + case ModelClass.LARGE: + { + model = + runtime.getSetting("TOGETHER_MODEL_LARGE") || + model; + } + break; + case ModelClass.SMALL: + { + model = + runtime.getSetting("TOGETHER_MODEL_SMALL") || + model; + } + break; } - break; } - } - break; - case ModelProviderName.OPENROUTER: { - switch(modelClass) { - case ModelClass.LARGE: { - model = runtime.getSetting("LARGE_OPENROUTER_MODEL") || model; - } - break; - case ModelClass.SMALL: { - model = runtime.getSetting("SMALL_OPENROUTER_MODEL") || model; + break; + case ModelProviderName.OPENROUTER: + { + switch (modelClass) { + case ModelClass.LARGE: + { + model = + runtime.getSetting("LARGE_OPENROUTER_MODEL") || + model; + } + break; + case ModelClass.SMALL: + { + model = + runtime.getSetting("SMALL_OPENROUTER_MODEL") || + model; + } + break; } - break; } - } - break; + break; } elizaLogger.info("Selected model:", model); @@ -155,7 +178,11 @@ export async function generateText({ case ModelProviderName.HYPERBOLIC: case ModelProviderName.TOGETHER: { elizaLogger.debug("Initializing OpenAI model."); - const openai = createOpenAI({ apiKey, baseURL: endpoint }); + const openai = createOpenAI({ + apiKey, + baseURL: endpoint, + fetch: runtime.fetch, + }); const { text: openaiResponse } = await aiGenerateText({ model: openai.languageModel(model), @@ -176,7 +203,9 @@ export async function generateText({ } case ModelProviderName.GOOGLE: { - const google = createGoogleGenerativeAI(); + const google = createGoogleGenerativeAI({ + fetch: runtime.fetch, + }); const { text: googleResponse } = await aiGenerateText({ model: google(model), @@ -199,7 +228,10 @@ export async function generateText({ case ModelProviderName.ANTHROPIC: { elizaLogger.debug("Initializing Anthropic model."); - const anthropic = createAnthropic({ apiKey }); + const anthropic = createAnthropic({ + apiKey, + fetch: runtime.fetch, + }); const { text: anthropicResponse } = await aiGenerateText({ model: anthropic.languageModel(model), @@ -222,7 +254,10 @@ export async function generateText({ case ModelProviderName.CLAUDE_VERTEX: { elizaLogger.debug("Initializing Claude Vertex model."); - const anthropic = createAnthropic({ apiKey }); + const anthropic = createAnthropic({ + apiKey, + fetch: runtime.fetch, + }); const { text: anthropicResponse } = await aiGenerateText({ model: anthropic.languageModel(model), @@ -246,7 +281,11 @@ export async function generateText({ case ModelProviderName.GROK: { elizaLogger.debug("Initializing Grok model."); - const grok = createOpenAI({ apiKey, baseURL: endpoint }); + const grok = createOpenAI({ + apiKey, + baseURL: endpoint, + fetch: runtime.fetch, + }); const { text: grokResponse } = await aiGenerateText({ model: grok.languageModel(model, { @@ -269,7 +308,7 @@ export async function generateText({ } case ModelProviderName.GROQ: { - const groq = createGroq({ apiKey }); + const groq = createGroq({ apiKey, fetch: runtime.fetch }); const { text: groqResponse } = await aiGenerateText({ model: groq.languageModel(model), @@ -316,7 +355,11 @@ export async function generateText({ case ModelProviderName.REDPILL: { elizaLogger.debug("Initializing RedPill model."); const serverUrl = models[provider].endpoint; - const openai = createOpenAI({ apiKey, baseURL: serverUrl }); + const openai = createOpenAI({ + apiKey, + baseURL: serverUrl, + fetch: runtime.fetch, + }); const { text: redpillResponse } = await aiGenerateText({ model: openai.languageModel(model), @@ -339,7 +382,11 @@ export async function generateText({ case ModelProviderName.OPENROUTER: { elizaLogger.debug("Initializing OpenRouter model."); const serverUrl = models[provider].endpoint; - const openrouter = createOpenAI({ apiKey, baseURL: serverUrl }); + const openrouter = createOpenAI({ + apiKey, + baseURL: serverUrl, + fetch: runtime.fetch, + }); const { text: openrouterResponse } = await aiGenerateText({ model: openrouter.languageModel(model), @@ -365,6 +412,7 @@ export async function generateText({ const ollamaProvider = createOllama({ baseURL: models[provider].endpoint + "/api", + fetch: runtime.fetch, }); const ollama = ollamaProvider(model); @@ -389,6 +437,7 @@ export async function generateText({ const heurist = createOpenAI({ apiKey: apiKey, baseURL: endpoint, + fetch: runtime.fetch, }); const { text: heuristResponse } = await aiGenerateText({ @@ -434,7 +483,11 @@ export async function generateText({ elizaLogger.debug("Using GAIANET model with baseURL:", baseURL); - const openai = createOpenAI({ apiKey, baseURL: endpoint }); + const openai = createOpenAI({ + apiKey, + baseURL: endpoint, + fetch: runtime.fetch, + }); const { text: openaiResponse } = await aiGenerateText({ model: openai.languageModel(model), @@ -459,6 +512,7 @@ export async function generateText({ const galadriel = createOpenAI({ apiKey: apiKey, baseURL: endpoint, + fetch: runtime.fetch, }); const { text: galadrielResponse } = await aiGenerateText({ @@ -1496,3 +1550,45 @@ interface TogetherAIImageResponse { image_type?: string; }>; } + +export async function generateTweetActions({ + runtime, + context, + modelClass, +}: { + runtime: IAgentRuntime; + context: string; + modelClass: string; +}): Promise<ActionResponse | null> { + let retryDelay = 1000; + while (true) { + try { + const response = await generateText({ + runtime, + context, + modelClass, + }); + console.debug("Received response from generateText for tweet actions:", response); + const { actions } = parseActionResponseFromText(response.trim()); + if (actions) { + console.debug("Parsed tweet actions:", actions); + return actions; + } else { + elizaLogger.debug("generateTweetActions no valid response"); + } + } catch (error) { + elizaLogger.error("Error in generateTweetActions:", error); + if ( + error instanceof TypeError && + error.message.includes("queueTextCompletion") + ) { + elizaLogger.error( + "TypeError: Cannot read properties of null (reading 'queueTextCompletion')" + ); + } + } + elizaLogger.log(`Retrying in ${retryDelay}ms...`); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + retryDelay *= 2; + } +} \ No newline at end of file diff --git a/packages/core/src/parsing.ts b/packages/core/src/parsing.ts index 3f7313f54f2..cc85352202c 100644 --- a/packages/core/src/parsing.ts +++ b/packages/core/src/parsing.ts @@ -1,3 +1,4 @@ +import { ActionResponse } from "./types.ts"; const jsonBlockPattern = /```json\n([\s\S]*?)\n```/; export const messageCompletionFooter = `\nResponse format should be formatted in a JSON block like this: @@ -146,3 +147,38 @@ export function parseJSONObjectFromText( return null; } } + +export const postActionResponseFooter = `Choose any combination of [LIKE], [RETWEET], [QUOTE], and [REPLY] that are appropriate. Each action must be on its own line. Your response must only include the chosen actions.`; + +export const parseActionResponseFromText = (text: string): { actions: ActionResponse } => { + const actions: ActionResponse = { + like: false, + retweet: false, + quote: false, + reply: false + }; + + // Regex patterns + const likePattern = /\[LIKE\]/i; + const retweetPattern = /\[RETWEET\]/i; + const quotePattern = /\[QUOTE\]/i; + const replyPattern = /\[REPLY\]/i; + + // Check with regex + actions.like = likePattern.test(text); + actions.retweet = retweetPattern.test(text); + actions.quote = quotePattern.test(text); + actions.reply = replyPattern.test(text); + + // Also do line by line parsing as backup + const lines = text.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[LIKE]') actions.like = true; + if (trimmed === '[RETWEET]') actions.retweet = true; + if (trimmed === '[QUOTE]') actions.quote = true; + if (trimmed === '[REPLY]') actions.reply = true; + } + + return { actions }; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d29d0b98056..b9790e98f72 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -566,10 +566,10 @@ export type Media = { */ export type Client = { /** Start client connection */ - start: (runtime?: IAgentRuntime) => Promise<unknown>; + start: (runtime: IAgentRuntime) => Promise<unknown>; /** Stop client connection */ - stop: (runtime?: IAgentRuntime) => Promise<unknown>; + stop: (runtime: IAgentRuntime) => Promise<unknown>; }; /** @@ -992,6 +992,8 @@ export interface IAgentRuntime { evaluators: Evaluator[]; plugins: Plugin[]; + fetch?: typeof fetch | null; + messageManager: IMemoryManager; descriptionManager: IMemoryManager; documentsManager: IMemoryManager; @@ -1171,3 +1173,10 @@ export type KnowledgeItem = { id: UUID; content: Content; }; + +export interface ActionResponse { + like: boolean; + retweet: boolean; + quote?: boolean; + reply?: boolean; +} diff --git a/packages/plugin-echochambers/LICENSE b/packages/plugin-echochambers/LICENSE new file mode 100644 index 00000000000..de6134690c1 --- /dev/null +++ b/packages/plugin-echochambers/LICENSE @@ -0,0 +1,9 @@ +Ethereal Cosmic License (ECL-777) + +Copyright (∞) 2024 SavageJay | https://x.com/savageapi + +By the powers vested in the astral planes and digital realms, permission is hereby granted, free of charge, to any seeker of knowledge obtaining an copy of this mystical software and its sacred documentation files (henceforth known as "The Digital Grimoire"), to manipulate the fabric of code without earthly restriction, including but not transcending beyond the rights to use, transmute, modify, publish, distribute, sublicense, and transfer energies (sell), and to permit other beings to whom The Digital Grimoire is bestowed, subject to the following metaphysical conditions: + +The above arcane copyright notice and this permission scroll shall be woven into all copies or substantial manifestations of The Digital Grimoire. + +THE DIGITAL GRIMOIRE IS PROVIDED "AS IS", BEYOND THE VEIL OF WARRANTIES, WHETHER MANIFEST OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE MYSTICAL WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR ASTRAL PURPOSE AND NON-VIOLATION OF THE COSMIC ORDER. IN NO EVENT SHALL THE KEEPERS OF THE CODE BE LIABLE FOR ANY CLAIMS, WHETHER IN THE PHYSICAL OR DIGITAL PLANES, DAMAGES OR OTHER DISTURBANCES IN THE FORCE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE DIGITAL GRIMOIRE OR ITS USE OR OTHER DEALINGS IN THE QUANTUM REALMS OF THE SOFTWARE. \ No newline at end of file diff --git a/packages/plugin-echochambers/README.md b/packages/plugin-echochambers/README.md new file mode 100644 index 00000000000..12aa5f4f78f --- /dev/null +++ b/packages/plugin-echochambers/README.md @@ -0,0 +1,66 @@ +# EchoChambers Plugin for ELIZA + +The EchoChambers plugin enables ELIZA to interact in chat rooms, providing conversational capabilities with dynamic interaction handling. + +## Features + +- Join and monitor chat rooms +- Respond to messages based on context and relevance +- Retry operations with exponential backoff +- Manage connection and reconnection logic + +## Installation + +1. Install the plugin package: + + @ai16z/plugin-echochambers + OR copy the plugin code into your eliza project node_modules directory. (node_modules\@ai16z) + +2. Import and register the plugin in your `character.ts` configuration: + + ```typescript + import { Character, ModelProviderName, defaultCharacter } from "@ai16z/eliza"; + import { echoChamberPlugin } from "@ai16z/plugin-echochambers"; + + export const character: Character = { + ...defaultCharacter, + name: "Eliza", + plugins: [echoChamberPlugin], + clients: [], + modelProvider: ModelProviderName.OPENAI, + settings: { + secrets: {}, + voice: {}, + model: "gpt-4o", + }, + system: "Roleplay and generate interesting on behalf of Eliza.", + bio: [...], + lore: [...], + messageExamples: [...], + postExamples: [...], + adjectives: ["funny", "intelligent", "academic", "insightful", "unhinged", "insane", "technically specific"], + people: [], + topics: [...], + style: {...}, + }; + ``` + +## Configuration + +Add the following environment variables to your `.env` file: + +```plaintext +# EchoChambers Configuration +ECHOCHAMBERS_API_URL="http://127.0.0.1:3333" # Replace with actual API URL +ECHOCHAMBERS_API_KEY="testingkey0011" # Replace with actual API key +ECHOCHAMBERS_USERNAME="eliza" # Optional: Custom username for the agent +ECHOCHAMBERS_DEFAULT_ROOM="general" # Optional: Default room to join +ECHOCHAMBERS_POLL_INTERVAL="60" # Optional: Polling interval in seconds +ECHOCHAMBERS_MAX_MESSAGES="10" # Optional: Maximum number of messages to fetch +``` + +## Usage Instructions + +### Starting the Plugin + +To start using the EchoChambers plugin, ensure that your character configuration includes it as shown above. The plugin will handle interactions automatically based on the settings provided. diff --git a/packages/plugin-echochambers/package.json b/packages/plugin-echochambers/package.json new file mode 100644 index 00000000000..19723d0e590 --- /dev/null +++ b/packages/plugin-echochambers/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ai16z/plugin-echochambers", + "version": "0.1.5-alpha.3", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@ai16z/eliza": "workspace:*", + "@ai16z/plugin-node": "workspace:*" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch" + } +} diff --git a/packages/plugin-echochambers/src/echoChamberClient.ts b/packages/plugin-echochambers/src/echoChamberClient.ts new file mode 100644 index 00000000000..cf8caea2910 --- /dev/null +++ b/packages/plugin-echochambers/src/echoChamberClient.ts @@ -0,0 +1,192 @@ +import { elizaLogger, IAgentRuntime } from "@ai16z/eliza"; +import { + ChatMessage, + ChatRoom, + EchoChamberConfig, + ModelInfo, + ListRoomsResponse, + RoomHistoryResponse, + MessageResponse, +} from "./types"; + +const MAX_RETRIES = 3; + +const RETRY_DELAY = 5000; + +export class EchoChamberClient { + private runtime: IAgentRuntime; + private config: EchoChamberConfig; + private apiUrl: string; + private modelInfo: ModelInfo; + private pollInterval: NodeJS.Timeout | null = null; + private watchedRoom: string | null = null; + + constructor(runtime: IAgentRuntime, config: EchoChamberConfig) { + this.runtime = runtime; + this.config = config; + this.apiUrl = `${config.apiUrl}/api/rooms`; + this.modelInfo = { + username: config.username || `agent-${runtime.agentId}`, + model: config.model || runtime.modelProvider, + }; + } + + public getUsername(): string { + return this.modelInfo.username; + } + + public getModelInfo(): ModelInfo { + return { ...this.modelInfo }; + } + + public getConfig(): EchoChamberConfig { + return { ...this.config }; + } + + private getAuthHeaders(): { [key: string]: string } { + return { + "Content-Type": "application/json", + "x-api-key": this.config.apiKey, + }; + } + + public async setWatchedRoom(roomId: string): Promise<void> { + try { + // Verify room exists + const rooms = await this.listRooms(); + const room = rooms.find((r) => r.id === roomId); + + if (!room) { + throw new Error(`Room ${roomId} not found`); + } + + // Set new watched room + this.watchedRoom = roomId; + + elizaLogger.success(`Now watching room: ${room.name}`); + } catch (error) { + elizaLogger.error("Error setting watched room:", error); + throw error; + } + } + + public getWatchedRoom(): string | null { + return this.watchedRoom; + } + + private async retryOperation<T>( + operation: () => Promise<T>, + retries: number = MAX_RETRIES + ): Promise<T> { + for (let i = 0; i < retries; i++) { + try { + return await operation(); + } catch (error) { + if (i === retries - 1) throw error; + const delay = RETRY_DELAY * Math.pow(2, i); + elizaLogger.warn(`Retrying operation in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("Max retries exceeded"); + } + + public async start(): Promise<void> { + elizaLogger.log("🚀 Starting EchoChamber client..."); + try { + // Verify connection by listing rooms + await this.retryOperation(() => this.listRooms()); + elizaLogger.success( + `✅ EchoChamber client successfully started for ${this.modelInfo.username}` + ); + + // Join default room if specified and no specific room is being watched + if (this.config.defaultRoom && !this.watchedRoom) { + await this.setWatchedRoom(this.config.defaultRoom); + } + } catch (error) { + elizaLogger.error("❌ Failed to start EchoChamber client:", error); + throw error; + } + } + + public async stop(): Promise<void> { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + // Leave watched room if any + if (this.watchedRoom) { + try { + this.watchedRoom = null; + } catch (error) { + elizaLogger.error( + `Error leaving room ${this.watchedRoom}:`, + error + ); + } + } + + elizaLogger.log("Stopping EchoChamber client..."); + } + + public async listRooms(tags?: string[]): Promise<ChatRoom[]> { + try { + const url = new URL(this.apiUrl); + if (tags?.length) { + url.searchParams.append("tags", tags.join(",")); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to list rooms: ${response.statusText}`); + } + + const data = (await response.json()) as ListRoomsResponse; + return data.rooms; + } catch (error) { + elizaLogger.error("Error listing rooms:", error); + throw error; + } + } + + public async getRoomHistory(roomId: string): Promise<ChatMessage[]> { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/history`); + if (!response.ok) { + throw new Error( + `Failed to get room history: ${response.statusText}` + ); + } + + const data = (await response.json()) as RoomHistoryResponse; + return data.messages; + }); + } + + public async sendMessage( + roomId: string, + content: string + ): Promise<ChatMessage> { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/message`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ + content, + sender: this.modelInfo, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send message: ${response.statusText}` + ); + } + + const data = (await response.json()) as MessageResponse; + return data.message; + }); + } +} diff --git a/packages/plugin-echochambers/src/environment.ts b/packages/plugin-echochambers/src/environment.ts new file mode 100644 index 00000000000..6f444e10611 --- /dev/null +++ b/packages/plugin-echochambers/src/environment.ts @@ -0,0 +1,55 @@ +import { IAgentRuntime, elizaLogger } from "@ai16z/eliza"; + +export async function validateEchoChamberConfig( + runtime: IAgentRuntime +): Promise<void> { + const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); + const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); + + if (!apiUrl) { + elizaLogger.error( + "ECHOCHAMBERS_API_URL is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_URL is required"); + } + + if (!apiKey) { + elizaLogger.error( + "ECHOCHAMBERS_API_KEY is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_KEY is required"); + } + + // Validate API URL format + try { + new URL(apiUrl); + } catch (error) { + elizaLogger.error( + `Invalid ECHOCHAMBERS_API_URL format: ${apiUrl}. Please provide a valid URL.` + ); + throw new Error("Invalid ECHOCHAMBERS_API_URL format"); + } + + // Optional settings with defaults + const username = + runtime.getSetting("ECHOCHAMBERS_USERNAME") || + `agent-${runtime.agentId}`; + const defaultRoom = + runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || "general"; + const pollInterval = Number( + runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 120 + ); + + if (isNaN(pollInterval) || pollInterval < 1) { + elizaLogger.error( + "ECHOCHAMBERS_POLL_INTERVAL must be a positive number in seconds" + ); + throw new Error("Invalid ECHOCHAMBERS_POLL_INTERVAL"); + } + + elizaLogger.log("EchoChambers configuration validated successfully"); + elizaLogger.log(`API URL: ${apiUrl}`); + elizaLogger.log(`Username: ${username}`); + elizaLogger.log(`Default Room: ${defaultRoom}`); + elizaLogger.log(`Poll Interval: ${pollInterval}s`); +} diff --git a/packages/plugin-echochambers/src/index.ts b/packages/plugin-echochambers/src/index.ts new file mode 100644 index 00000000000..42c91decc3b --- /dev/null +++ b/packages/plugin-echochambers/src/index.ts @@ -0,0 +1,93 @@ +import { elizaLogger, Client, IAgentRuntime, Plugin } from "@ai16z/eliza"; +import { EchoChamberClient } from "./echoChamberClient"; +import { InteractionClient } from "./interactions"; +import { EchoChamberConfig } from "./types"; +import { validateEchoChamberConfig } from "./environment"; + +export const EchoChamberClientInterface: Client = { + async start(runtime: IAgentRuntime) { + try { + // Validate configuration before starting + await validateEchoChamberConfig(runtime); + + const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); + const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); + + if (!apiKey || !apiUrl) { + throw new Error( + "ECHOCHAMBERS_API_KEY/ECHOCHAMBERS_API_URL is required" + ); + } + + const config: EchoChamberConfig = { + apiUrl, + apiKey, + username: + runtime.getSetting("ECHOCHAMBERS_USERNAME") || + `agent-${runtime.agentId}`, + model: runtime.modelProvider, + defaultRoom: + runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || + "general", + }; + + elizaLogger.log("Starting EchoChambers client..."); + + // Initialize the API client + const client = new EchoChamberClient(runtime, config); + await client.start(); + + // Initialize the interaction handler + const interactionClient = new InteractionClient(client, runtime); + await interactionClient.start(); + + elizaLogger.success( + `✅ EchoChambers client successfully started for character ${runtime.character.name}` + ); + + return { client, interactionClient }; + } catch (error) { + elizaLogger.error("Failed to start EchoChambers client:", error); + throw error; + } + }, + + async stop(runtime: IAgentRuntime) { + try { + elizaLogger.warn("Stopping EchoChambers client..."); + + // Get client instances if they exist + const clients = (runtime as any).clients?.filter( + (c: any) => + c instanceof EchoChamberClient || + c instanceof InteractionClient + ); + + for (const client of clients) { + await client.stop(); + } + + elizaLogger.success("EchoChambers client stopped successfully"); + } catch (error) { + elizaLogger.error("Error stopping EchoChambers client:", error); + throw error; + } + }, +}; + +export const echoChamberPlugin: Plugin = { + name: "echochambers", + description: + "Plugin for interacting with EchoChambers API to enable multi-agent communication", + actions: [], // No custom actions needed - core functionality handled by client + evaluators: [], // No custom evaluators needed + providers: [], // No custom providers needed + clients: [EchoChamberClientInterface], +}; + +export default echoChamberPlugin; + +// Export types and classes +export * from "./types"; +export { EchoChamberClient } from "./echoChamberClient"; +export { InteractionClient } from "./interactions"; diff --git a/packages/plugin-echochambers/src/interactions.ts b/packages/plugin-echochambers/src/interactions.ts new file mode 100644 index 00000000000..be824e50ddd --- /dev/null +++ b/packages/plugin-echochambers/src/interactions.ts @@ -0,0 +1,428 @@ +import { + composeContext, + generateMessageResponse, + generateShouldRespond, + messageCompletionFooter, + shouldRespondFooter, + Content, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + stringToUuid, + elizaLogger, + getEmbeddingZeroVector, +} from "@ai16z/eliza"; +import { EchoChamberClient } from "./echoChamberClient"; +import { ChatMessage } from "./types"; + +function createMessageTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{lore}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +{{messageDirections}} + +Recent conversation history: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Generate a response in the voice and style of {{agentName}} while: +1. Staying relevant to the room's topic +2. Maintaining conversation context +3. Being helpful but not overly talkative +4. Responding naturally to direct questions or mentions +5. Contributing meaningfully to ongoing discussions + +Remember: +- Keep responses concise and focused +- Stay on topic for the current room +- Don't repeat information already shared +- Be natural and conversational +` + messageCompletionFooter + ); +} + +function createShouldRespondTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +Response options are [RESPOND], [IGNORE] and [STOP]. + +{{agentName}} should: +- RESPOND when: + * Directly mentioned or asked a question + * Can contribute relevant expertise to the discussion + * Topic aligns with their knowledge and background + * Conversation is active and engaging + +- IGNORE when: + * Message is not relevant to their expertise + * Already responded recently without new information to add + * Conversation has moved to a different topic + * Message is too short or lacks substance + * Other participants are handling the discussion well + +- STOP when: + * Asked to stop participating + * Conversation has concluded + * Discussion has completely diverged from their expertise + * Room topic has changed significantly + +Recent messages: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Choose whether {{agentName}} should respond to the last message. +Consider: +1. Message relevance to {{agentName}}'s expertise +2. Current conversation context +3. Time since last response +4. Value of potential contribution +` + shouldRespondFooter + ); +} + +export class InteractionClient { + private client: EchoChamberClient; + private runtime: IAgentRuntime; + private lastCheckedTimestamps: Map<string, string> = new Map(); + private lastResponseTimes: Map<string, number> = new Map(); + private messageThreads: Map<string, ChatMessage[]> = new Map(); + private messageHistory: Map< + string, + { message: ChatMessage; response: ChatMessage | null }[] + > = new Map(); + private pollInterval: NodeJS.Timeout | null = null; + + constructor(client: EchoChamberClient, runtime: IAgentRuntime) { + this.client = client; + this.runtime = runtime; + } + + async start() { + const pollInterval = Number( + this.runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 60 + ); + + const handleInteractionsLoop = () => { + this.handleInteractions(); + this.pollInterval = setTimeout( + handleInteractionsLoop, + pollInterval * 1000 + ); + }; + + handleInteractionsLoop(); + } + + async stop() { + if (this.pollInterval) { + clearTimeout(this.pollInterval); + this.pollInterval = null; + } + } + + private async buildMessageThread( + message: ChatMessage, + messages: ChatMessage[] + ): Promise<ChatMessage[]> { + const thread: ChatMessage[] = []; + const maxThreadLength = Number( + this.runtime.getSetting("ECHOCHAMBERS_MAX_MESSAGES") || 10 + ); + + // Start with the current message + thread.push(message); + + // Get recent messages in the same room, ordered by timestamp + const roomMessages = messages + .filter((msg) => msg.roomId === message.roomId) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + // Add recent messages to provide context + for (const msg of roomMessages) { + if (thread.length >= maxThreadLength) break; + if (msg.id !== message.id) { + thread.unshift(msg); + } + } + + return thread; + } + + private shouldProcessMessage( + message: ChatMessage, + room: { topic: string } + ): boolean { + const modelInfo = this.client.getModelInfo(); + + // Don't process own messages + if (message.sender.username === modelInfo.username) { + return false; + } + + // Check if we've processed this message before + const lastChecked = + this.lastCheckedTimestamps.get(message.roomId) || "0"; + if (message.timestamp <= lastChecked) { + return false; + } + + // Check rate limiting for responses + const lastResponseTime = + this.lastResponseTimes.get(message.roomId) || 0; + const minTimeBetweenResponses = 30000; // 30 seconds + if (Date.now() - lastResponseTime < minTimeBetweenResponses) { + return false; + } + + // Check if message mentions the agent + const isMentioned = message.content + .toLowerCase() + .includes(`${modelInfo.username.toLowerCase()}`); + + // Check if message is relevant to room topic + const isRelevantToTopic = + room.topic && + message.content.toLowerCase().includes(room.topic.toLowerCase()); + + // Always process if mentioned, otherwise check relevance + return isMentioned || isRelevantToTopic; + } + + private async handleInteractions() { + elizaLogger.log("Checking EchoChambers interactions"); + + try { + const defaultRoom = this.runtime.getSetting( + "ECHOCHAMBERS_DEFAULT_ROOM" + ); + const rooms = await this.client.listRooms(); + + for (const room of rooms) { + // Only process messages from the default room if specified + if (defaultRoom && room.id !== defaultRoom) { + continue; + } + + const messages = await this.client.getRoomHistory(room.id); + this.messageThreads.set(room.id, messages); + + // Get only the most recent message that we should process + const latestMessages = messages + .filter((msg) => !this.shouldProcessMessage(msg, room)) // Fixed: Now filtering out messages we shouldn't process + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + if (latestMessages.length > 0) { + const latestMessage = latestMessages[0]; + await this.handleMessage(latestMessage, room.topic); + + // Update history + const roomHistory = this.messageHistory.get(room.id) || []; + roomHistory.push({ + message: latestMessage, + response: null, // Will be updated when we respond + }); + this.messageHistory.set(room.id, roomHistory); + + // Update last checked timestamp + if ( + latestMessage.timestamp > + (this.lastCheckedTimestamps.get(room.id) || "0") + ) { + this.lastCheckedTimestamps.set( + room.id, + latestMessage.timestamp + ); + } + } + } + + elizaLogger.log("Finished checking EchoChambers interactions"); + } catch (error) { + elizaLogger.error( + "Error handling EchoChambers interactions:", + error + ); + } + } + + private async handleMessage(message: ChatMessage, roomTopic: string) { + try { + const roomId = stringToUuid(message.roomId); + const userId = stringToUuid(message.sender.username); + + // Ensure connection exists + await this.runtime.ensureConnection( + userId, + roomId, + message.sender.username, + message.sender.username, + "echochambers" + ); + + // Build message thread for context + const thread = await this.buildMessageThread( + message, + this.messageThreads.get(message.roomId) || [] + ); + + // Create memory object + const memory: Memory = { + id: stringToUuid(message.id), + userId, + agentId: this.runtime.agentId, + roomId, + content: { + text: message.content, + source: "echochambers", + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(message.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + // Check if we've already processed this message + const existing = await this.runtime.messageManager.getMemoryById( + memory.id + ); + if (existing) { + elizaLogger.log( + `Already processed message ${message.id}, skipping` + ); + return; + } + + // Save the message to memory + await this.runtime.messageManager.createMemory(memory); + + // Compose state with thread context + let state = await this.runtime.composeState(memory); + state = await this.runtime.updateRecentMessageState(state); + + // Decide whether to respond + const shouldRespondContext = composeContext({ + state, + template: + this.runtime.character.templates?.shouldRespondTemplate || + createShouldRespondTemplate(message.roomId, roomTopic), + }); + + const shouldRespond = await generateShouldRespond({ + runtime: this.runtime, + context: shouldRespondContext, + modelClass: ModelClass.SMALL, + }); + + if (shouldRespond !== "RESPOND") { + elizaLogger.log( + `Not responding to message ${message.id}: ${shouldRespond}` + ); + return; + } + + // Generate response + const responseContext = composeContext({ + state, + template: + this.runtime.character.templates?.messageHandlerTemplate || + createMessageTemplate(message.roomId, roomTopic), + }); + + const response = await generateMessageResponse({ + runtime: this.runtime, + context: responseContext, + modelClass: ModelClass.SMALL, + }); + + if (!response || !response.text) { + elizaLogger.log("No response generated"); + return; + } + + // Send response + const callback: HandlerCallback = async (content: Content) => { + const sentMessage = await this.client.sendMessage( + message.roomId, + content.text + ); + + // Update last response time + this.lastResponseTimes.set(message.roomId, Date.now()); + + // Update history with our response + const roomHistory = + this.messageHistory.get(message.roomId) || []; + const lastEntry = roomHistory[roomHistory.length - 1]; + if (lastEntry && lastEntry.message.id === message.id) { + lastEntry.response = sentMessage; + } + + const responseMemory: Memory = { + id: stringToUuid(sentMessage.id), + userId: this.runtime.agentId, + agentId: this.runtime.agentId, + roomId, + content: { + text: sentMessage.content, + source: "echochambers", + action: content.action, + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(sentMessage.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + await this.runtime.messageManager.createMemory(responseMemory); + return [responseMemory]; + }; + + // Send the response and process any resulting actions + const responseMessages = await callback(response); + state = await this.runtime.updateRecentMessageState(state); + await this.runtime.processActions( + memory, + responseMessages, + state, + callback + ); + await this.runtime.evaluate(memory, state, true); + } catch (error) { + elizaLogger.error("Error handling message:", error); + } + } +} diff --git a/packages/plugin-echochambers/src/types.ts b/packages/plugin-echochambers/src/types.ts new file mode 100644 index 00000000000..887758813eb --- /dev/null +++ b/packages/plugin-echochambers/src/types.ts @@ -0,0 +1,68 @@ +export interface ModelInfo { + username: string; // Unique username for the model/agent + model: string; // Type/name of the model being used +} + +export interface ChatMessage { + id: string; // Unique message identifier + content: string; // Message content/text + sender: ModelInfo; // Information about who sent the message + timestamp: string; // ISO timestamp of when message was sent + roomId: string; // ID of the room this message belongs to +} + +export interface ChatRoom { + id: string; // Unique room identifier + name: string; // Display name of the room + topic: string; // Room's current topic/description + tags: string[]; // Tags associated with the room for categorization + participants: ModelInfo[]; // List of current room participants + createdAt: string; // ISO timestamp of room creation + messageCount: number; // Total number of messages in the room +} + +export interface EchoChamberConfig { + apiUrl: string; // Base URL for the EchoChambers API + apiKey: string; // Required API key for authenticated endpoints + defaultRoom?: string; // Optional default room to join on startup + username?: string; // Optional custom username (defaults to agent-{agentId}) + model?: string; // Optional model name (defaults to runtime.modelProvider) +} + +export interface ListRoomsResponse { + rooms: ChatRoom[]; +} + +export interface RoomHistoryResponse { + messages: ChatMessage[]; +} + +export interface MessageResponse { + message: ChatMessage; +} + +export interface CreateRoomResponse { + room: ChatRoom; +} + +export interface ClearMessagesResponse { + success: boolean; + message: string; +} + +export enum RoomEvent { + MESSAGE_CREATED = "message_created", + ROOM_CREATED = "room_created", + ROOM_UPDATED = "room_updated", + ROOM_JOINED = "room_joined", + ROOM_LEFT = "room_left", +} + +export interface MessageTransformer { + transformIncoming(content: string): Promise<string>; + transformOutgoing?(content: string): Promise<string>; +} + +export interface ContentModerator { + validateContent(content: string): Promise<boolean>; +} diff --git a/packages/plugin-echochambers/tsconfig.json b/packages/plugin-echochambers/tsconfig.json new file mode 100644 index 00000000000..b98954f213e --- /dev/null +++ b/packages/plugin-echochambers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/plugin-echochambers/tsup.config.ts b/packages/plugin-echochambers/tsup.config.ts new file mode 100644 index 00000000000..6d705138fb1 --- /dev/null +++ b/packages/plugin-echochambers/tsup.config.ts @@ -0,0 +1,19 @@ +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", + ], +}); diff --git a/packages/plugin-evm/package.json b/packages/plugin-evm/package.json index c041a142159..b24d1045c71 100644 --- a/packages/plugin-evm/package.json +++ b/packages/plugin-evm/package.json @@ -15,7 +15,8 @@ }, "scripts": { "build": "tsup --format esm --dts", - "dev": "tsup --format esm --dts --watch" + "dev": "tsup --format esm --dts --watch", + "test": "vitest run" }, "peerDependencies": { "whatwg-url": "7.1.0" diff --git a/packages/plugin-evm/src/actions/bridge.ts b/packages/plugin-evm/src/actions/bridge.ts index 4d92018e96a..eeb888486c7 100644 --- a/packages/plugin-evm/src/actions/bridge.ts +++ b/packages/plugin-evm/src/actions/bridge.ts @@ -1,13 +1,11 @@ import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; import { - ChainId, createConfig, executeRoute, ExtendedChain, getRoutes, } from "@lifi/sdk"; import { WalletProvider } from "../providers/wallet"; -import { getChainConfigs } from "../providers/chainConfigs"; import { bridgeTemplate } from "../templates"; import type { BridgeParams, Transaction } from "../types"; @@ -19,25 +17,23 @@ export class BridgeAction { constructor(private walletProvider: WalletProvider) { this.config = createConfig({ integrator: "eliza", - chains: Object.values( - getChainConfigs(this.walletProvider.runtime) - ).map((config) => ({ - id: config.chainId, + chains: Object.values(this.walletProvider.chains).map((config) => ({ + id: config.id, name: config.name, key: config.name.toLowerCase(), chainType: "EVM", nativeToken: { ...config.nativeCurrency, - chainId: config.chainId, + chainId: config.id, address: "0x0000000000000000000000000000000000000000", coinKey: config.nativeCurrency.symbol, }, metamask: { - chainId: `0x${config.chainId.toString(16)}`, + chainId: `0x${config.id.toString(16)}`, chainName: config.name, nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrl], - blockExplorerUrls: [config.blockExplorerUrl], + rpcUrls: [config.rpcUrls.default.http[0]], + blockExplorerUrls: [config.blockExplorers.default.url], }, diamondAddress: "0x0000000000000000000000000000000000000000", coin: config.nativeCurrency.symbol, @@ -47,16 +43,15 @@ export class BridgeAction { } async bridge(params: BridgeParams): Promise<Transaction> { - const walletClient = this.walletProvider.getWalletClient(); + const walletClient = this.walletProvider.getWalletClient( + params.fromChain + ); const [fromAddress] = await walletClient.getAddresses(); const routes = await getRoutes({ - fromChainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId as ChainId, - toChainId: getChainConfigs(this.walletProvider.runtime)[ - params.toChain - ].chainId as ChainId, + fromChainId: this.walletProvider.getChainConfigs(params.fromChain) + .id, + toChainId: this.walletProvider.getChainConfigs(params.toChain).id, fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, @@ -79,9 +74,7 @@ export class BridgeAction { to: routes.routes[0].steps[0].estimate .approvalAddress as `0x${string}`, value: BigInt(params.amount), - chainId: getChainConfigs(this.walletProvider.runtime)[ - params.fromChain - ].chainId, + chainId: this.walletProvider.getChainConfigs(params.fromChain).id, }; } } @@ -95,7 +88,10 @@ export const bridgeAction = { state: State, options: any ) => { - const walletProvider = new WalletProvider(runtime); + const privateKey = runtime.getSetting( + "EVM_PRIVATE_KEY" + ) as `0x${string}`; + const walletProvider = new WalletProvider(privateKey); const action = new BridgeAction(walletProvider); return action.bridge(options); }, diff --git a/packages/plugin-evm/src/actions/swap.ts b/packages/plugin-evm/src/actions/swap.ts index 3b22916cb35..1c66f43f14b 100644 --- a/packages/plugin-evm/src/actions/swap.ts +++ b/packages/plugin-evm/src/actions/swap.ts @@ -7,7 +7,6 @@ import { getRoutes, } from "@lifi/sdk"; import { WalletProvider } from "../providers/wallet"; -import { getChainConfigs } from "../providers/chainConfigs"; import { swapTemplate } from "../templates"; import type { SwapParams, Transaction } from "../types"; @@ -19,16 +18,14 @@ export class SwapAction { constructor(private walletProvider: WalletProvider) { this.config = createConfig({ integrator: "eliza", - chains: Object.values( - getChainConfigs(this.walletProvider.runtime) - ).map((config) => ({ - id: config.chainId, + chains: Object.values(this.walletProvider.chains).map((config) => ({ + id: config.id, name: config.name, key: config.name.toLowerCase(), chainType: "EVM" as const, nativeToken: { ...config.nativeCurrency, - chainId: config.chainId, + chainId: config.id, address: "0x0000000000000000000000000000000000000000", coinKey: config.nativeCurrency.symbol, priceUSD: "0", @@ -38,15 +35,15 @@ export class SwapAction { name: config.nativeCurrency.name, }, rpcUrls: { - public: { http: [config.rpcUrl] }, + public: { http: [config.rpcUrls.default.http[0]] }, }, - blockExplorerUrls: [config.blockExplorerUrl], + blockExplorerUrls: [config.blockExplorers.default.url], metamask: { - chainId: `0x${config.chainId.toString(16)}`, + chainId: `0x${config.id.toString(16)}`, chainName: config.name, nativeCurrency: config.nativeCurrency, - rpcUrls: [config.rpcUrl], - blockExplorerUrls: [config.blockExplorerUrl], + rpcUrls: [config.rpcUrls.default.http[0]], + blockExplorerUrls: [config.blockExplorers.default.url], }, coin: config.nativeCurrency.symbol, mainnet: true, @@ -56,16 +53,12 @@ export class SwapAction { } async swap(params: SwapParams): Promise<Transaction> { - const walletClient = this.walletProvider.getWalletClient(); + const walletClient = this.walletProvider.getWalletClient(params.chain); const [fromAddress] = await walletClient.getAddresses(); const routes = await getRoutes({ - fromChainId: getChainConfigs(this.walletProvider.runtime)[ - params.chain - ].chainId as ChainId, - toChainId: getChainConfigs(this.walletProvider.runtime)[ - params.chain - ].chainId as ChainId, + fromChainId: this.walletProvider.getChainConfigs(params.chain).id, + toChainId: this.walletProvider.getChainConfigs(params.chain).id, fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, @@ -92,8 +85,7 @@ export class SwapAction { .approvalAddress as `0x${string}`, value: BigInt(params.amount), data: process.data as `0x${string}`, - chainId: getChainConfigs(this.walletProvider.runtime)[params.chain] - .chainId, + chainId: this.walletProvider.getChainConfigs(params.chain).id, }; } } @@ -109,7 +101,10 @@ export const swapAction = { callback?: any ) => { try { - const walletProvider = new WalletProvider(runtime); + const privateKey = runtime.getSetting( + "EVM_PRIVATE_KEY" + ) as `0x${string}`; + const walletProvider = new WalletProvider(privateKey); const action = new SwapAction(walletProvider); return await action.swap(options); } catch (error) { diff --git a/packages/plugin-evm/src/actions/transfer.ts b/packages/plugin-evm/src/actions/transfer.ts index 18321097fe9..5c3cb71957b 100644 --- a/packages/plugin-evm/src/actions/transfer.ts +++ b/packages/plugin-evm/src/actions/transfer.ts @@ -1,25 +1,34 @@ -import { ByteArray, parseEther, type Hex } from "viem"; -import { WalletProvider } from "../providers/wallet"; +import { ByteArray, formatEther, parseEther, type Hex } from "viem"; +import { + composeContext, + generateObjectDEPRECATED, + HandlerCallback, + ModelClass, + type IAgentRuntime, + type Memory, + type State, +} from "@ai16z/eliza"; + +import { initWalletProvider, WalletProvider } from "../providers/wallet"; import type { Transaction, TransferParams } from "../types"; import { transferTemplate } from "../templates"; -import type { IAgentRuntime, Memory, State } from "@ai16z/eliza"; export { transferTemplate }; export class TransferAction { constructor(private walletProvider: WalletProvider) {} - async transfer( - runtime: IAgentRuntime, - params: TransferParams - ): Promise<Transaction> { - const walletClient = this.walletProvider.getWalletClient(); - const [fromAddress] = await walletClient.getAddresses(); + async transfer(params: TransferParams): Promise<Transaction> { + console.log( + `Transferring: ${params.amount} tokens to (${params.toAddress} on ${params.fromChain})` + ); - await this.walletProvider.switchChain(runtime, params.fromChain); + const walletClient = this.walletProvider.getWalletClient( + params.fromChain + ); try { const hash = await walletClient.sendTransaction({ - account: fromAddress, + account: walletClient.account, to: params.toAddress, value: parseEther(params.amount), data: params.data as Hex, @@ -39,7 +48,7 @@ export class TransferAction { return { hash, - from: fromAddress, + from: walletClient.account.address, to: params.toAddress, value: parseEther(params.amount), data: params.data as Hex, @@ -50,6 +59,43 @@ export class TransferAction { } } +const buildTransferDetails = async ( + state: State, + runtime: IAgentRuntime, + wp: WalletProvider +): Promise<TransferParams> => { + const context = composeContext({ + state, + template: transferTemplate, + }); + + const chains = Object.keys(wp.chains); + + const contextWithChains = context.replace( + "SUPPORTED_CHAINS", + chains.toString() + ); + + const transferDetails = (await generateObjectDEPRECATED({ + runtime, + context: contextWithChains, + modelClass: ModelClass.SMALL, + })) as TransferParams; + + const existingChain = wp.chains[transferDetails.fromChain]; + + if (!existingChain) { + throw new Error( + "The chain " + + transferDetails.fromChain + + " not configured yet. Add the chain or choose one from configured: " + + chains.toString() + ); + } + + return transferDetails; +}; + export const transferAction = { name: "transfer", description: "Transfer tokens between addresses on the same chain", @@ -57,11 +103,43 @@ export const transferAction = { runtime: IAgentRuntime, message: Memory, state: State, - options: any + options: any, + callback?: HandlerCallback ) => { - const walletProvider = new WalletProvider(runtime); - const action = new TransferAction(walletProvider); - return action.transfer(runtime, options); + try { + const walletProvider = initWalletProvider(runtime); + const action = new TransferAction(walletProvider); + const transferDetails = await buildTransferDetails( + state, + runtime, + walletProvider + ); + const tx = await action.transfer(transferDetails); + + if (callback) { + callback({ + text: `Successfully transferred ${formatEther(tx.value)} tokens to ${tx.to}\nTransaction hash: ${tx.hash}\nChain: ${transferDetails.fromChain}`, + content: { + success: true, + hash: tx.hash, + amount: formatEther(tx.value), + recipient: tx.to, + chain: transferDetails.fromChain, + }, + }); + } + + return true; + } catch (error) { + console.error("Error during token transfer:", error); + if (callback) { + callback({ + text: `Error transferring tokens: ${error.message}`, + content: { error: error.message }, + }); + } + return false; + } }, template: transferTemplate, validate: async (runtime: IAgentRuntime) => { diff --git a/packages/plugin-evm/src/providers/chainConfigs.ts b/packages/plugin-evm/src/providers/chainConfigs.ts deleted file mode 100644 index a410c56b962..00000000000 --- a/packages/plugin-evm/src/providers/chainConfigs.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { - mainnet, - base, - sepolia, - bsc, - arbitrum, - avalanche, - polygon, - optimism, - cronos, - gnosis, - fantom, - klaytn, - celo, - moonbeam, - aurora, - harmonyOne, - moonriver, - arbitrumNova, - mantle, - linea, - scroll, - filecoin, - taiko, - zksync, - canto, -} from "viem/chains"; -import type { ChainMetadata, SupportedChain } from "../types"; -import type { IAgentRuntime } from "@ai16z/eliza"; - -export const DEFAULT_CHAIN_CONFIGS: Record<SupportedChain, ChainMetadata> = { - ethereum: { - chainId: 1, - name: "Ethereum", - chain: mainnet, - rpcUrl: "https://eth.llamarpc.com", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://etherscan.io", - }, - base: { - chainId: 8453, - name: "Base", - chain: base, - rpcUrl: "https://base.llamarpc.com", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://basescan.org", - }, - sepolia: { - chainId: 11155111, - name: "Sepolia", - chain: sepolia, - rpcUrl: "https://rpc.sepolia.org", - nativeCurrency: { - name: "Sepolia Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://sepolia.etherscan.io", - }, - bsc: { - chainId: 56, - name: "BNB Smart Chain", - chain: bsc, - rpcUrl: "https://bsc-dataseed1.binance.org/", - nativeCurrency: { - name: "Binance Coin", - symbol: "BNB", - decimals: 18, - }, - blockExplorerUrl: "https://bscscan.com", - }, - arbitrum: { - chainId: 42161, - name: "Arbitrum One", - chain: arbitrum, - rpcUrl: "https://arb1.arbitrum.io/rpc", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://arbiscan.io", - }, - avalanche: { - chainId: 43114, - name: "Avalanche C-Chain", - chain: avalanche, - rpcUrl: "https://api.avax.network/ext/bc/C/rpc", - nativeCurrency: { - name: "Avalanche", - symbol: "AVAX", - decimals: 18, - }, - blockExplorerUrl: "https://snowtrace.io", - }, - polygon: { - chainId: 137, - name: "Polygon", - chain: polygon, - rpcUrl: "https://polygon-rpc.com", - nativeCurrency: { - name: "MATIC", - symbol: "MATIC", - decimals: 18, - }, - blockExplorerUrl: "https://polygonscan.com", - }, - optimism: { - chainId: 10, - name: "Optimism", - chain: optimism, - rpcUrl: "https://mainnet.optimism.io", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://optimistic.etherscan.io", - }, - cronos: { - chainId: 25, - name: "Cronos", - chain: cronos, - rpcUrl: "https://evm.cronos.org", - nativeCurrency: { - name: "Cronos", - symbol: "CRO", - decimals: 18, - }, - blockExplorerUrl: "https://cronoscan.com", - }, - gnosis: { - chainId: 100, - name: "Gnosis", - chain: gnosis, - rpcUrl: "https://rpc.gnosischain.com", - nativeCurrency: { - name: "xDAI", - symbol: "XDAI", - decimals: 18, - }, - blockExplorerUrl: "https://gnosisscan.io", - }, - fantom: { - chainId: 250, - name: "Fantom", - chain: fantom, - rpcUrl: "https://rpc.ftm.tools", - nativeCurrency: { - name: "Fantom", - symbol: "FTM", - decimals: 18, - }, - blockExplorerUrl: "https://ftmscan.com", - }, - klaytn: { - chainId: 8217, - name: "Klaytn", - chain: klaytn, - rpcUrl: "https://public-node-api.klaytnapi.com/v1/cypress", - nativeCurrency: { - name: "KLAY", - symbol: "KLAY", - decimals: 18, - }, - blockExplorerUrl: "https://scope.klaytn.com", - }, - celo: { - chainId: 42220, - name: "Celo", - chain: celo, - rpcUrl: "https://forno.celo.org", - nativeCurrency: { - name: "Celo", - symbol: "CELO", - decimals: 18, - }, - blockExplorerUrl: "https://celoscan.io", - }, - moonbeam: { - chainId: 1284, - name: "Moonbeam", - chain: moonbeam, - rpcUrl: "https://rpc.api.moonbeam.network", - nativeCurrency: { - name: "Glimmer", - symbol: "GLMR", - decimals: 18, - }, - blockExplorerUrl: "https://moonscan.io", - }, - aurora: { - chainId: 1313161554, - name: "Aurora", - chain: aurora, - rpcUrl: "https://mainnet.aurora.dev", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://aurorascan.dev", - }, - harmonyOne: { - chainId: 1666600000, - name: "harmonyOne", - chain: harmonyOne, - rpcUrl: "https://api.harmonyOne.one", - nativeCurrency: { - name: "ONE", - symbol: "ONE", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.harmonyOne.one", - }, - moonriver: { - chainId: 1285, - name: "Moonriver", - chain: moonriver, - rpcUrl: "https://rpc.api.moonriver.moonbeam.network", - nativeCurrency: { - name: "Moonriver", - symbol: "MOVR", - decimals: 18, - }, - blockExplorerUrl: "https://moonriver.moonscan.io", - }, - arbitrumNova: { - chainId: 42170, - name: "Arbitrum Nova", - chain: arbitrumNova, - rpcUrl: "https://nova.arbitrum.io/rpc", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://nova-explorer.arbitrum.io", - }, - mantle: { - chainId: 5000, - name: "Mantle", - chain: mantle, - rpcUrl: "https://rpc.mantle.xyz", - nativeCurrency: { - name: "Mantle", - symbol: "MNT", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.mantle.xyz", - }, - linea: { - chainId: 59144, - name: "Linea", - chain: linea, - rpcUrl: "https://linea-mainnet.rpc.build", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://lineascan.build", - }, - scroll: { - chainId: 534353, - name: "Scroll Alpha Testnet", - chain: scroll, - rpcUrl: "https://alpha-rpc.scroll.io/l2", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://blockscout.scroll.io", - }, - filecoin: { - chainId: 314, - name: "Filecoin", - chain: filecoin, - rpcUrl: "https://api.node.glif.io/rpc/v1", - nativeCurrency: { - name: "Filecoin", - symbol: "FIL", - decimals: 18, - }, - blockExplorerUrl: "https://filfox.info/en", - }, - taiko: { - chainId: 167005, - name: "Taiko (Alpha-3) Testnet", - chain: taiko, - rpcUrl: "https://rpc.a3.taiko.xyz", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.a3.taiko.xyz", - }, - zksync: { - chainId: 324, - name: "zksync Era", - chain: zksync, - rpcUrl: "https://mainnet.era.zksync.io", - nativeCurrency: { - name: "Ether", - symbol: "ETH", - decimals: 18, - }, - blockExplorerUrl: "https://explorer.zksync.io", - }, - canto: { - chainId: 7700, - name: "Canto", - chain: canto, - rpcUrl: "https://canto.slingshot.finance", - nativeCurrency: { - name: "CANTO", - symbol: "CANTO", - decimals: 18, - }, - blockExplorerUrl: "https://tuber.build", - }, -} as const; - -export const getChainConfigs = (runtime: IAgentRuntime) => { - return ( - (runtime.character.settings.chains?.evm as ChainMetadata[]) || - DEFAULT_CHAIN_CONFIGS - ); -}; diff --git a/packages/plugin-evm/src/providers/chainUtils.ts b/packages/plugin-evm/src/providers/chainUtils.ts deleted file mode 100644 index 377aa3f3631..00000000000 --- a/packages/plugin-evm/src/providers/chainUtils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createPublicClient, createWalletClient, http } from "viem"; -import type { IAgentRuntime } from "@ai16z/eliza"; -import type { - Account, - Chain, - HttpTransport, - PublicClient, - WalletClient, -} from "viem"; -import type { SupportedChain, ChainConfig } from "../types"; -import { DEFAULT_CHAIN_CONFIGS } from "./chainConfigs"; - -export const createChainClients = ( - chain: SupportedChain, - runtime: IAgentRuntime, - account: Account -): ChainConfig => { - const chainConfig = DEFAULT_CHAIN_CONFIGS[chain]; - const transport = http(chainConfig.rpcUrl); - - return { - chain: chainConfig.chain, - publicClient: createPublicClient<HttpTransport>({ - chain: chainConfig.chain, - transport, - }) as PublicClient<HttpTransport, Chain, Account | undefined>, - walletClient: createWalletClient<HttpTransport>({ - chain: chainConfig.chain, - transport, - account, - }), - }; -}; - -export const initializeChainConfigs = ( - runtime: IAgentRuntime, - account: Account -): Record<SupportedChain, ChainConfig> => { - return Object.keys(DEFAULT_CHAIN_CONFIGS).reduce( - (configs, chain) => { - const supportedChain = chain as SupportedChain; - configs[supportedChain] = createChainClients( - supportedChain, - runtime, - account - ); - return configs; - }, - {} as Record<SupportedChain, ChainConfig> - ); -}; diff --git a/packages/plugin-evm/src/providers/wallet.ts b/packages/plugin-evm/src/providers/wallet.ts index a317a861879..2b816c461bb 100644 --- a/packages/plugin-evm/src/providers/wallet.ts +++ b/packages/plugin-evm/src/providers/wallet.ts @@ -1,4 +1,9 @@ -import { formatUnits } from "viem"; +import { + createPublicClient, + createWalletClient, + formatUnits, + http, +} from "viem"; import { privateKeyToAccount } from "viem/accounts"; import type { IAgentRuntime, Provider, Memory, State } from "@ai16z/eliza"; import type { @@ -8,39 +13,73 @@ import type { Chain, HttpTransport, Account, + PrivateKeyAccount, } from "viem"; -import type { SupportedChain, ChainConfig } from "../types"; -import { getChainConfigs } from "./chainConfigs"; -import { initializeChainConfigs } from "./chainUtils"; +import * as viemChains from "viem/chains"; -export class WalletProvider { - private chainConfigs: Record<SupportedChain, ChainConfig>; - private currentChain: SupportedChain = "ethereum"; - private address: Address; - runtime: IAgentRuntime; +import type { SupportedChain } from "../types"; - constructor(runtime: IAgentRuntime) { - const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); - if (!privateKey) throw new Error("EVM_PRIVATE_KEY not configured"); +export class WalletProvider { + private currentChain: SupportedChain = "mainnet"; + chains: Record<string, Chain> = { mainnet: viemChains.mainnet }; + account: PrivateKeyAccount; - this.runtime = runtime; - const account = privateKeyToAccount(privateKey as `0x${string}`); - this.address = account.address; + constructor(privateKey: `0x${string}`, chains?: Record<string, Chain>) { + this.setAccount(privateKey); + this.setChains(chains); - // Initialize all chain configs at once - this.chainConfigs = initializeChainConfigs(runtime, account); + if (chains && Object.keys(chains).length > 0) { + this.setCurrentChain(Object.keys(chains)[0] as SupportedChain); + } } getAddress(): Address { - return this.address; + return this.account.address; + } + + getCurrentChain(): Chain { + return this.chains[this.currentChain]; + } + + getPublicClient( + chainName: SupportedChain + ): PublicClient<HttpTransport, Chain, Account | undefined> { + const transport = this.createHttpTransport(chainName); + + const publicClient = createPublicClient({ + chain: this.chains[chainName], + transport, + }); + return publicClient; + } + + getWalletClient(chainName: SupportedChain): WalletClient { + const transport = this.createHttpTransport(chainName); + + const walletClient = createWalletClient({ + chain: this.chains[chainName], + transport, + account: this.account, + }); + + return walletClient; + } + + getChainConfigs(chainName: SupportedChain): Chain { + const chain = viemChains[chainName]; + + if (!chain?.id) { + throw new Error("Invalid chain name"); + } + + return chain; } async getWalletBalance(): Promise<string | null> { try { const client = this.getPublicClient(this.currentChain); - const walletClient = this.getWalletClient(); const balance = await client.getBalance({ - address: walletClient.account.address, + address: this.account.address, }); return formatUnits(balance, 18); } catch (error) { @@ -49,70 +88,125 @@ export class WalletProvider { } } - async connect(): Promise<`0x${string}`> { - return this.runtime.getSetting("EVM_PRIVATE_KEY") as `0x${string}`; - } - - async switchChain( - runtime: IAgentRuntime, - chain: SupportedChain - ): Promise<void> { - const walletClient = this.chainConfigs[this.currentChain].walletClient; - if (!walletClient) throw new Error("Wallet not connected"); - + async getWalletBalanceForChain( + chainName: SupportedChain + ): Promise<string | null> { try { - await walletClient.switchChain({ - id: getChainConfigs(runtime)[chain].chainId, + const client = this.getPublicClient(chainName); + const balance = await client.getBalance({ + address: this.account.address, }); - } catch (error: any) { - if (error.code === 4902) { - console.log( - "[WalletProvider] Chain not added to wallet (error 4902) - attempting to add chain first" - ); - await walletClient.addChain({ - chain: { - ...getChainConfigs(runtime)[chain].chain, - rpcUrls: { - default: { - http: [getChainConfigs(runtime)[chain].rpcUrl], - }, - public: { - http: [getChainConfigs(runtime)[chain].rpcUrl], - }, - }, - }, - }); - await walletClient.switchChain({ - id: getChainConfigs(runtime)[chain].chainId, - }); - } else { - throw error; - } + return formatUnits(balance, 18); + } catch (error) { + console.error("Error getting wallet balance:", error); + return null; } + } - this.currentChain = chain; + addChain(chain: Record<string, Chain>) { + this.setChains(chain); } - getPublicClient( - chain: SupportedChain - ): PublicClient<HttpTransport, Chain, Account | undefined> { - return this.chainConfigs[chain].publicClient; + switchChain(chainName: SupportedChain, customRpcUrl?: string) { + if (!this.chains[chainName]) { + const chain = WalletProvider.genChainFromName( + chainName, + customRpcUrl + ); + this.addChain({ [chainName]: chain }); + } + this.setCurrentChain(chainName); } - getWalletClient(): WalletClient { - const walletClient = this.chainConfigs[this.currentChain].walletClient; - if (!walletClient) throw new Error("Wallet not connected"); - return walletClient; + private setAccount = (pk: `0x${string}`) => { + this.account = privateKeyToAccount(pk); + }; + + private setChains = (chains?: Record<string, Chain>) => { + if (!chains) { + return; + } + Object.keys(chains).forEach((chain: string) => { + this.chains[chain] = chains[chain]; + }); + }; + + private setCurrentChain = (chain: SupportedChain) => { + this.currentChain = chain; + }; + + private createHttpTransport = (chainName: SupportedChain) => { + const chain = this.chains[chainName]; + + if (chain.rpcUrls.custom) { + return http(chain.rpcUrls.custom.http[0]); + } + return http(chain.rpcUrls.default.http[0]); + }; + + static genChainFromName( + chainName: string, + customRpcUrl?: string | null + ): Chain { + const baseChain = viemChains[chainName]; + + if (!baseChain?.id) { + throw new Error("Invalid chain name"); + } + + const viemChain: Chain = customRpcUrl + ? { + ...baseChain, + rpcUrls: { + ...baseChain.rpcUrls, + custom: { + http: [customRpcUrl], + }, + }, + } + : baseChain; + + return viemChain; } +} - getCurrentChain(): SupportedChain { - return this.currentChain; +const genChainsFromRuntime = ( + runtime: IAgentRuntime +): Record<string, Chain> => { + const chainNames = + (runtime.character.settings.chains?.evm as SupportedChain[]) || []; + const chains = {}; + + chainNames.forEach((chainName) => { + const rpcUrl = runtime.getSetting( + "ETHEREUM_PROVIDER_" + chainName.toUpperCase() + ); + const chain = WalletProvider.genChainFromName(chainName, rpcUrl); + chains[chainName] = chain; + }); + + const mainnet_rpcurl = runtime.getSetting("EVM_PROVIDER_URL"); + if (mainnet_rpcurl) { + const chain = WalletProvider.genChainFromName( + "mainnet", + mainnet_rpcurl + ); + chains["mainnet"] = chain; } - getChainConfig(chain: SupportedChain) { - return getChainConfigs(this.runtime)[chain]; + return chains; +}; + +export const initWalletProvider = (runtime: IAgentRuntime) => { + const privateKey = runtime.getSetting("EVM_PRIVATE_KEY"); + if (!privateKey) { + throw new Error("EVM_PRIVATE_KEY is missing"); } -} + + const chains = genChainsFromRuntime(runtime); + + return new WalletProvider(privateKey as `0x${string}`, chains); +}; export const evmWalletProvider: Provider = { async get( @@ -120,15 +214,12 @@ export const evmWalletProvider: Provider = { message: Memory, state?: State ): Promise<string | null> { - if (!runtime.getSetting("EVM_PRIVATE_KEY")) { - return null; - } - try { - const walletProvider = new WalletProvider(runtime); + const walletProvider = initWalletProvider(runtime); const address = walletProvider.getAddress(); const balance = await walletProvider.getWalletBalance(); - return `EVM Wallet Address: ${address}\nBalance: ${balance} ETH`; + const chain = walletProvider.getCurrentChain(); + return `EVM Wallet Address: ${address}\nBalance: ${balance} ${chain.nativeCurrency.symbol}\nChain ID: ${chain.id}, Name: ${chain.name}`; } catch (error) { console.error("Error in EVM wallet provider:", error); return null; diff --git a/packages/plugin-evm/src/templates/index.ts b/packages/plugin-evm/src/templates/index.ts index a8c7f1fcc3e..20d6ef19af8 100644 --- a/packages/plugin-evm/src/templates/index.ts +++ b/packages/plugin-evm/src/templates/index.ts @@ -5,19 +5,17 @@ export const transferTemplate = `Given the recent messages and wallet informatio {{walletInfo}} Extract the following information about the requested transfer: -- Chain to execute on -- Amount to transfer +- Chain to execute on (like in viem/chains) +- Amount to transfer (only number without coin symbol) - Recipient address -- Token symbol or address (if not native token) Respond with a JSON markdown block containing only the extracted values: \`\`\`json { - "chain": "ethereum" | "base" | "sepolia" | "bsc" | "arbitrum" | "avalanche" | "polygon" | "optimism" | "cronos" | "gnosis" | "fantom" | "klaytn" | "celo" | "moonbeam" | "aurora" | "harmonyOne" | "moonriver" | "arbitrumNova" | "mantle" | "linea" | "scroll" | "filecoin" | "taiko" | "zksync" | "canto" | null, - "amount": string | null, - "toAddress": string | null, - "token": string | null + "fromChain": SUPPORTED_CHAINS, + "amount": string, + "toAddress": string } \`\`\` `; diff --git a/packages/plugin-evm/src/tests/transfer.test.ts b/packages/plugin-evm/src/tests/transfer.test.ts new file mode 100644 index 00000000000..d637a6a3d9d --- /dev/null +++ b/packages/plugin-evm/src/tests/transfer.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Account, Chain } from "viem"; + +import { TransferAction } from "../actions/transfer"; +import { WalletProvider } from "../providers/wallet"; + +describe("Transfer Action", () => { + let wp: WalletProvider; + + beforeEach(async () => { + const pk = generatePrivateKey(); + const customChains = prepareChains(); + wp = new WalletProvider(pk, customChains); + }); + describe("Constructor", () => { + it("should initialize with wallet provider", () => { + const ta = new TransferAction(wp); + + expect(ta).to.toBeDefined(); + }); + }); + describe("Transfer", () => { + let ta: TransferAction; + let receiver: Account; + + beforeEach(() => { + ta = new TransferAction(wp); + receiver = privateKeyToAccount(generatePrivateKey()); + }); + + it("throws if not enough gas", async () => { + await expect( + ta.transfer({ + fromChain: "iotexTestnet", + toAddress: receiver.address, + amount: "1", + }) + ).rejects.toThrow( + "Transfer failed: The total cost (gas * gas fee + value) of executing this transaction exceeds the balance of the account." + ); + }); + }); +}); + +const prepareChains = () => { + let customChains: Record<string, Chain> = {}; + const chainNames = ["iotexTestnet"]; + chainNames.forEach( + (chain) => + (customChains[chain] = WalletProvider.genChainFromName(chain)) + ); + + return customChains; +}; diff --git a/packages/plugin-evm/src/tests/wallet.test.ts b/packages/plugin-evm/src/tests/wallet.test.ts new file mode 100644 index 00000000000..a8e5a3ee872 --- /dev/null +++ b/packages/plugin-evm/src/tests/wallet.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { mainnet, iotex, arbitrum, Chain } from "viem/chains"; + +import { WalletProvider } from "../providers/wallet"; + +const customRpcUrls = { + mainnet: "custom-rpc.mainnet.io", + arbitrum: "custom-rpc.base.io", + iotex: "custom-rpc.iotex.io", +}; + +describe("Wallet provider", () => { + let walletProvider: WalletProvider; + let pk: `0x${string}`; + let customChains: Record<string, Chain> = {}; + + beforeAll(() => { + pk = generatePrivateKey(); + + const chainNames = ["iotex", "arbitrum"]; + chainNames.forEach( + (chain) => + (customChains[chain] = WalletProvider.genChainFromName(chain)) + ); + }); + + describe("Constructor", () => { + it("sets address", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + + walletProvider = new WalletProvider(pk); + + expect(walletProvider.getAddress()).to.be.eq(expectedAddress); + }); + it("sets default chain to ethereum mainnet", () => { + walletProvider = new WalletProvider(pk); + + expect(walletProvider.chains.mainnet.id).to.be.eq(mainnet.id); + expect(walletProvider.getCurrentChain().id).to.be.eq(mainnet.id); + }); + it("sets custom chains", () => { + walletProvider = new WalletProvider(pk, customChains); + + expect(walletProvider.chains.iotex.id).to.be.eq(iotex.id); + expect(walletProvider.chains.arbitrum.id).to.be.eq(arbitrum.id); + }); + it("sets the first provided custom chain as current chain", () => { + walletProvider = new WalletProvider(pk, customChains); + + expect(walletProvider.getCurrentChain().id).to.be.eq(iotex.id); + }); + }); + describe("Clients", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk); + }); + it("generates public client", () => { + const client = walletProvider.getPublicClient("mainnet"); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.transport.url).toEqual( + mainnet.rpcUrls.default.http[0] + ); + }); + it("generates public client with custom rpcurl", () => { + const chain = WalletProvider.genChainFromName( + "mainnet", + customRpcUrls.mainnet + ); + const wp = new WalletProvider(pk, { ["mainnet"]: chain }); + + const client = wp.getPublicClient("mainnet"); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.chain.rpcUrls.default.http[0]).to.eq( + mainnet.rpcUrls.default.http[0] + ); + expect(client.chain.rpcUrls.custom.http[0]).to.eq( + customRpcUrls.mainnet + ); + expect(client.transport.url).toEqual(customRpcUrls.mainnet); + }); + it("generates wallet client", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + + const client = walletProvider.getWalletClient("mainnet"); + + expect(client.account.address).to.be.equal(expectedAddress); + expect(client.transport.url).toEqual( + mainnet.rpcUrls.default.http[0] + ); + }); + it("generates wallet client with custom rpcurl", () => { + const account = privateKeyToAccount(pk); + const expectedAddress = account.address; + const chain = WalletProvider.genChainFromName( + "mainnet", + customRpcUrls.mainnet + ); + const wp = new WalletProvider(pk, { ["mainnet"]: chain }); + + const client = wp.getWalletClient("mainnet"); + + expect(client.account.address).to.be.equal(expectedAddress); + expect(client.chain.id).to.be.equal(mainnet.id); + expect(client.chain.rpcUrls.default.http[0]).to.eq( + mainnet.rpcUrls.default.http[0] + ); + expect(client.chain.rpcUrls.custom.http[0]).to.eq( + customRpcUrls.mainnet + ); + expect(client.transport.url).toEqual(customRpcUrls.mainnet); + }); + }); + describe("Balance", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk, customChains); + }); + it("should fetch balance", async () => { + const bal = await walletProvider.getWalletBalance(); + + expect(bal).to.be.eq("0"); + }); + it("should fetch balance for a specific added chain", async () => { + const bal = await walletProvider.getWalletBalanceForChain("iotex"); + + expect(bal).to.be.eq("0"); + }); + it("should return null if chain is not added", async () => { + const bal = await walletProvider.getWalletBalanceForChain("base"); + expect(bal).to.be.null; + }); + }); + describe("Chain", () => { + beforeEach(() => { + walletProvider = new WalletProvider(pk, customChains); + }); + it("generates chains from chain name", () => { + const chainName = "iotex"; + const chain: Chain = WalletProvider.genChainFromName(chainName); + + expect(chain.rpcUrls.default.http[0]).to.eq( + iotex.rpcUrls.default.http[0] + ); + }); + it("generates chains from chain name with custom rpc url", () => { + const chainName = "iotex"; + const customRpcUrl = "custom.url.io"; + const chain: Chain = WalletProvider.genChainFromName( + chainName, + customRpcUrl + ); + + expect(chain.rpcUrls.default.http[0]).to.eq( + iotex.rpcUrls.default.http[0] + ); + expect(chain.rpcUrls.custom.http[0]).to.eq(customRpcUrl); + }); + it("switches chain", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + walletProvider.switchChain("mainnet"); + + const newChain = walletProvider.getCurrentChain().id; + expect(newChain).to.be.eq(mainnet.id); + }); + it("switches chain (by adding new chain)", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + walletProvider.switchChain("arbitrum"); + + const newChain = walletProvider.getCurrentChain().id; + expect(newChain).to.be.eq(arbitrum.id); + }); + it("adds chain", () => { + const initialChains = walletProvider.chains; + expect(initialChains.base).to.be.undefined; + + const base = WalletProvider.genChainFromName("base"); + walletProvider.addChain({ base }); + const newChains = walletProvider.chains; + expect(newChains.arbitrum.id).to.be.eq(arbitrum.id); + }); + it("gets chain configs", () => { + const chain = walletProvider.getChainConfigs("iotex"); + + expect(chain.id).to.eq(iotex.id); + }); + it("throws if tries to switch to an invalid chain", () => { + const initialChain = walletProvider.getCurrentChain().id; + expect(initialChain).to.be.eq(iotex.id); + + // @ts-ignore + expect(() => walletProvider.switchChain("eth")).to.throw(); + }); + it("throws if unsupported chain name", () => { + // @ts-ignore + expect(() => + WalletProvider.genChainFromName("ethereum") + ).to.throw(); + }); + it("throws if invalid chain name", () => { + // @ts-ignore + expect(() => WalletProvider.genChainFromName("eth")).to.throw(); + }); + }); +}); diff --git a/packages/plugin-evm/src/types/index.ts b/packages/plugin-evm/src/types/index.ts index 885f3994fce..8fa8247dcdb 100644 --- a/packages/plugin-evm/src/types/index.ts +++ b/packages/plugin-evm/src/types/index.ts @@ -8,33 +8,10 @@ import type { PublicClient, WalletClient, } from "viem"; +import * as viemChains from "viem/chains"; -export type SupportedChain = - | "ethereum" - | "base" - | "sepolia" - | "bsc" - | "arbitrum" - | "avalanche" - | "polygon" - | "optimism" - | "cronos" - | "gnosis" - | "fantom" - | "klaytn" - | "celo" - | "moonbeam" - | "aurora" - | "harmonyOne" - | "moonriver" - | "arbitrumNova" - | "mantle" - | "linea" - | "scroll" - | "filecoin" - | "taiko" - | "zksync" - | "canto"; +const SupportedChainList = Object.keys(viemChains) as Array<keyof typeof viemChains>; +export type SupportedChain = (typeof SupportedChainList)[number]; // Transaction types export interface Transaction { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd6e2535fef..92f3c9d09fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: vitest: specifier: 2.1.5 version: 2.1.5(@types/node@22.8.4)(jsdom@25.0.1(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@5.0.10))(terser@5.37.0) + zx: + specifier: ^8.2.4 + version: 8.2.4 agent: dependencies: @@ -585,7 +588,7 @@ importers: version: 8.57.1 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@20.17.9) + version: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) typescript: specifier: ^5.0.0 version: 5.6.3 @@ -913,6 +916,15 @@ importers: specifier: 0.7.1 version: 0.7.1(bufferutil@4.0.8)(typescript@5.6.3)(utf-8-validate@5.0.10) + packages/plugin-echochambers: + dependencies: + '@ai16z/eliza': + specifier: workspace:* + version: link:../core + '@ai16z/plugin-node': + specifier: workspace:* + version: link:../plugin-node + packages/plugin-evm: dependencies: '@ai16z/eliza': @@ -1042,7 +1054,7 @@ importers: version: 29.5.14 jest: specifier: 29.7.0 - version: 29.7.0(@types/node@22.8.4) + version: 29.7.0(@types/node@22.8.4)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) tsup: specifier: 8.3.5 version: 8.3.5(@swc/core@1.10.1(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(typescript@5.6.3)(yaml@2.6.1) @@ -1447,10 +1459,10 @@ importers: version: 8.16.0(eslint@9.16.0(jiti@2.4.0))(typescript@5.6.3) jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.17.9) + version: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) ts-jest: specifier: 29.2.5 - version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.9))(typescript@5.6.3) + version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)))(typescript@5.6.3) typescript: specifier: 5.6.3 version: 5.6.3 @@ -4164,8 +4176,8 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} '@jridgewell/resolve-uri@3.1.2': @@ -6487,6 +6499,9 @@ packages: '@types/fluent-ffmpeg@2.1.27': resolution: {integrity: sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==} + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/geojson@7946.0.15': resolution: {integrity: sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==} @@ -6538,6 +6553,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/jsonwebtoken@9.0.7': resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} @@ -14785,9 +14803,9 @@ packages: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} - schema-utils@4.2.0: - resolution: {integrity: sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==} - engines: {node: '>= 12.13.0'} + schema-utils@4.3.0: + resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==} + engines: {node: '>= 10.13.0'} scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -16857,6 +16875,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zx@8.2.4: + resolution: {integrity: sha512-g9wVU+5+M+zVen/3IyAZfsZFmeqb6vDfjqFggakviz5uLK7OAejOirX+jeTOkyvAh/OYRlCgw+SdqzN7F61QVQ==} + engines: {node: '>= 12.17.0'} + hasBin: true + snapshots: '@0glabs/0g-ts-sdk@0.2.1(bufferutil@4.0.8)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)': @@ -17187,7 +17210,7 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 '@antfu/install-pkg@0.4.1': @@ -17963,7 +17986,7 @@ snapshots: dependencies: '@babel/parser': 7.26.3 '@babel/types': 7.26.3 - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 @@ -20864,6 +20887,41 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.9 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3))': dependencies: '@jest/console': 29.7.0 @@ -21017,7 +21075,7 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.5': + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 @@ -21029,7 +21087,7 @@ snapshots: '@jridgewell/source-map@0.3.6': dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/sourcemap-codec@1.5.0': {} @@ -24110,6 +24168,12 @@ snapshots: dependencies: '@types/node': 20.17.9 + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 20.17.9 + optional: true + '@types/geojson@7946.0.15': {} '@types/glob@8.1.0': @@ -24162,6 +24226,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 20.17.9 + optional: true + '@types/jsonwebtoken@9.0.7': dependencies: '@types/node': 20.17.9 @@ -25581,7 +25650,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 find-cache-dir: 4.0.0 - schema-utils: 4.2.0 + schema-utils: 4.3.0 webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15)) babel-plugin-dynamic-import-node@2.3.3: @@ -26636,7 +26705,7 @@ snapshots: glob-parent: 6.0.2 globby: 13.2.2 normalize-path: 3.0.0 - schema-utils: 4.2.0 + schema-utils: 4.3.0 serialize-javascript: 6.0.2 webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15)) @@ -26704,13 +26773,13 @@ snapshots: ripemd160: 2.0.2 sha.js: 2.4.11 - create-jest@29.7.0(@types/node@20.17.9): + create-jest@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -26811,7 +26880,7 @@ snapshots: cssnano: 6.1.2(postcss@8.4.49) jest-worker: 29.7.0 postcss: 8.4.49 - schema-utils: 4.2.0 + schema-utils: 4.3.0 serialize-javascript: 6.0.2 webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15)) optionalDependencies: @@ -29830,16 +29899,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.17.9): + jest-cli@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.9) + create-jest: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -29849,7 +29918,7 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.8.4): + jest-cli@29.7.0(@types/node@22.8.4)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) '@jest/test-result': 29.7.0 @@ -29868,24 +29937,36 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.8.4)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)): + jest-config@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) - '@jest/test-result': 29.7.0 + '@babel/core': 7.26.0 + '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.0) chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.8.4)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.8.4)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 jest-util: 29.7.0 jest-validate: 29.7.0 - yargs: 17.7.2 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.17.9 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3) transitivePeerDependencies: - - '@types/node' - babel-plugin-macros - supports-color - - ts-node jest-config@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)): dependencies: @@ -30170,24 +30251,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.17.9): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.9) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@29.7.0(@types/node@22.8.4): + jest@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.8.4) + jest-cli: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -31590,7 +31659,7 @@ snapshots: mini-css-extract-plugin@2.9.2(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))): dependencies: - schema-utils: 4.2.0 + schema-utils: 4.3.0 tapable: 2.2.1 webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15)) @@ -34624,7 +34693,7 @@ snapshots: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) - schema-utils@4.2.0: + schema-utils@4.3.0: dependencies: '@types/json-schema': 7.0.15 ajv: 8.17.1 @@ -35287,7 +35356,7 @@ snapshots: sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 @@ -35670,12 +35739,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.9))(typescript@5.6.3): + ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.17.9) + jest: 29.7.0(@types/node@20.17.9)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -35710,6 +35779,27 @@ snapshots: ts-mixer@6.0.4: {} + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@20.17.9)(typescript@5.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.9 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.6.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.10.1(@swc/helpers@0.5.15) + optional: true + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.15))(@types/node@22.8.4)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -36545,7 +36635,7 @@ snapshots: memfs: 3.5.3 mime-types: 2.1.35 range-parser: 1.2.1 - schema-utils: 4.2.0 + schema-utils: 4.3.0 webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15)) webpack-dev-server@4.15.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))): @@ -36573,7 +36663,7 @@ snapshots: open: 8.4.2 p-retry: 4.6.2 rimraf: 3.0.2 - schema-utils: 4.2.0 + schema-utils: 4.3.0 selfsigned: 2.4.1 serve-index: 1.9.1 sockjs: 0.3.24 @@ -36936,3 +37026,8 @@ snapshots: zwitch@1.0.5: {} zwitch@2.0.4: {} + + zx@8.2.4: + optionalDependencies: + '@types/fs-extra': 11.0.4 + '@types/node': 20.17.9 diff --git a/scripts/integrationTests.sh b/scripts/integrationTests.sh new file mode 100755 index 00000000000..6dff86b571f --- /dev/null +++ b/scripts/integrationTests.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Check Node.js version +REQUIRED_NODE_VERSION=23 +CURRENT_NODE_VERSION=$(node -v | cut -d'.' -f1 | sed 's/v//') + +if (( CURRENT_NODE_VERSION < REQUIRED_NODE_VERSION )); then + echo "Error: Node.js version must be $REQUIRED_NODE_VERSION or higher. Current version is $CURRENT_NODE_VERSION." + exit 1 +fi + +# Navigate to the script's directory +cd "$(dirname "$0")"/.. + +cd tests +node test1.mjs diff --git a/scripts/smokeTests.sh b/scripts/smokeTests.sh new file mode 100755 index 00000000000..fcce311fe1d --- /dev/null +++ b/scripts/smokeTests.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Print some information about the environment to aid in case of troubleshooting + +echo "node version:" +node --version + +echo "python version:" +python3 --version + +echo "make version:" +make --version + +echo "gcc version:" +gcc --version + +echo "g++ version:" +g++ --version + +# Check Node.js version +REQUIRED_NODE_VERSION=23 +CURRENT_NODE_VERSION=$(node -v | cut -d'.' -f1 | sed 's/v//') + +if (( CURRENT_NODE_VERSION < REQUIRED_NODE_VERSION )); then + echo "Error: Node.js version must be $REQUIRED_NODE_VERSION or higher. Current version is $CURRENT_NODE_VERSION." + exit 1 +fi + +# Autodetect project directory relative to this script's path +PROJECT_DIR="$0" +while [ -h "$PROJECT_DIR" ]; do + ls=$(ls -ld "$PROJECT_DIR") + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' > /dev/null; then + PROJECT_DIR="$link" + else + PROJECT_DIR="$(dirname "$PROJECT_DIR")/$link" + fi +done +PROJECT_DIR="$(dirname "$PROJECT_DIR")/.." +PROJECT_DIR="$(cd "$PROJECT_DIR"; pwd)" + +cd $PROJECT_DIR + +cp .env.example .env + +pnpm install -r + +pnpm build + +OUTFILE="$(mktemp)" +echo $OUTFILE +( + # Wait for the ready message + while true; do + if grep -q "Chat started" "$OUTFILE"; then + echo "exit"; sleep 2 + break + fi + sleep 0.5 + done +) | pnpm start --character=characters/trump.character.json > "$OUTFILE" & + +# Wait for process to finish +wait $! +RESULT=$? +echo "----- OUTPUT START -----" +cat "$OUTFILE" +echo "----- OUTPUT END -----" + +# Check the exit code of the last command +if [[ $RESULT -ne 0 ]]; then + echo "Error: 'start' command exited with an error." + exit 1 +fi + +# Check if output.txt contains "Terminating and cleaning up resources..." +if grep -q "Terminating and cleaning up resources..." "$OUTFILE"; then + echo "Script completed successfully." +else + echo "Error: The output does not contain the expected string." + exit 1 +fi + +# Clean up +rm "$OUTFILE" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000000..761b59b8b69 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,25 @@ +# Integration tests + +This directory contains smoke and integration tests for Eliza project. + +## Smoke tests +- Should always be run on a freshly cloned project (i.e. no local changes) +- Building and installing is part of the test +- No configuration required +- To run: `pnpm run smokeTests` + +## Integration tests +- You need to configure your .env file before running (currently at least `OPENAI_API_KEY` is required) +- How to use: + 1. Install project dependencies and build the project as described in top-level `README` + 2. To run all the tests: `pnpm run integrationTests` + +## Integration test library +- For simplicity, integration tests are written in plain JavaScript (ESM) +- Currently this is just a "proof of concept" (single test), please reach out if you would like to contribute. + +## Using in GitHub CI/CD +- Settings -> Secrets and variables -> Actions: +- Create an enviroment +- Add repository secret `OPENAI_API_KEY` +- Refer to https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions for more information diff --git a/tests/test1.mjs b/tests/test1.mjs new file mode 100644 index 00000000000..11ebbe37aac --- /dev/null +++ b/tests/test1.mjs @@ -0,0 +1,33 @@ +import { $, chalk } from 'zx'; +import assert from 'assert'; +import { + startAgent, + stopAgent, + send +} from "./testLibrary.mjs"; +import { stringToUuid } from '../packages/core/dist/index.js' + +export const DEFAULT_CHARACTER = "trump" +export const DEFAULT_AGENT_ID = stringToUuid(DEFAULT_CHARACTER ?? uuidv4()); + +async function test1() { + const proc = await startAgent(); + try { + + const reply = await send("Hi"); + assert(reply.length > 10); + console.log(chalk.green('✓ Test 1 passed')); + } catch (error) { + console.error(chalk.red(`✗ Test 1 failed: ${error.message}`)); + process.exit(1); + } finally { + await stopAgent(proc); + } +} + +try { + await test1(); +} catch (error) { + console.error(chalk.red(`Error: ${error.message}`)); + process.exit(1); +} \ No newline at end of file diff --git a/tests/testLibrary.mjs b/tests/testLibrary.mjs new file mode 100644 index 00000000000..7646bf0b041 --- /dev/null +++ b/tests/testLibrary.mjs @@ -0,0 +1,93 @@ +import { $, fs, path, chalk } from 'zx'; +import { DEFAULT_AGENT_ID, DEFAULT_CHARACTER } from './test1.mjs'; +import { spawn } from 'node:child_process'; +$.verbose = false; // Suppress command output unless there's an error + +function projectRoot() { + return path.join(import.meta.dirname, ".."); +} + +async function runProcess(command, args = [], directory = projectRoot()) { + try { + const result = await $`cd ${directory} && ${command} ${args}`; + return result.stdout.trim(); + } catch (error) { + throw new Error(`Command failed: ${error.message}`); + } +} + +async function installProjectDependencies() { + console.log(chalk.blue('Installing dependencies...')); + return await runProcess('pnpm', ['install', '-r']); +} + +async function buildProject() { + console.log(chalk.blue('Building project...')); + return await runProcess('pnpm', ['build']); +} + +async function writeEnvFile(entries) { + const envContent = Object.entries(entries) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + await fs.writeFile('.env', envContent); +} + +async function startAgent(character = DEFAULT_CHARACTER) { + console.log(chalk.blue(`Starting agent for character: ${character}`)); + const proc = spawn('pnpm', ['start', `--character=characters/${character}.character.json`, '--non-interactive'], { shell: true, "stdio": "inherit" }); + log(`proc=${JSON.stringify(proc)}`); + + // Wait for server to be ready + await new Promise(resolve => setTimeout(resolve, 20000)); + return proc; +} + +async function stopAgent(proc) { + console.log(chalk.blue('Stopping agent...')); + proc.kill('SIGTERM') +} + +async function send(message) { + const endpoint = `http://127.0.0.1:3000/${DEFAULT_AGENT_ID}/message`; + const payload = { + text: message, + userId: "user", + userName: "User" + }; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data[0].text; + } catch (error) { + throw new Error(`Failed to send message: ${error.message}`); + } +} + +function log(message) { + console.log(message); +} + +export { + projectRoot, + runProcess, + installProjectDependencies, + buildProject, + writeEnvFile, + startAgent, + stopAgent, + send, + log +} \ No newline at end of file