diff --git a/.cursor/rules/discord-bot/RULE.md b/.cursor/rules/discord-bot/RULE.md new file mode 100644 index 0000000..d104f81 --- /dev/null +++ b/.cursor/rules/discord-bot/RULE.md @@ -0,0 +1,142 @@ +--- +description: "Discord.js patterns and bot class architecture for ArtBot" +alwaysApply: false +--- + +# Discord Bot Patterns + +## Bot Class Architecture + +Bot classes follow a consistent pattern: + +```typescript +export class SomeBot { + // Private fields first + private bot: Client + private someState: Map = new Map() + + constructor(bot: Client, otherDeps?: OtherType) { + this.bot = bot + // Synchronous initialization only in constructor + } + + // Async initialization in separate init() method if needed + async init() { + await this.buildSomething() + + // Set up intervals for periodic tasks + setInterval(() => { + this.periodicTask() + }, 60000) + } + + // Public handler methods + async handleSomeEvent(event: EventType) { + try { + // Handle the event + } catch (err) { + console.error('Error handling event:', err) + } + } + + // Private helper methods + private helperMethod(): void { + // Implementation + } + + // Cleanup method for resource management + cleanup() { + // Clear intervals, close connections, reset state + this.someState.clear() + } +} +``` + +## Message Handling + +Check if channel is sendable before sending messages: + +```typescript +async handleNumberMessage(msg: Message) { + if (!msg.channel.isSendable()) { + return + } + + // Process message + msg.channel.send('Response') +} +``` + +## Embed Messages + +Use `EmbedBuilder` for rich message formatting: + +```typescript +import { EmbedBuilder } from 'discord.js' + +const embed = new EmbedBuilder() + .setTitle('Token Name - Artist') + .setURL('https://artblocks.io/...') + .setColor(0x00ff00) + .setImage(assetUrl) + .setDescription('Description text') + .addFields( + { name: 'Owner', value: ownerText, inline: true }, + { name: 'Price', value: priceText, inline: true } + ) + .setFooter({ text: 'Footer text' }) + +msg.channel.send({ embeds: [embed] }) +``` + +## Event Listeners + +Handle Discord events via the client exported from index.ts: + +```typescript +import { discordClient } from '..' + +discordClient.on(Events.MessageCreate, async (msg) => { + const content = msg.content + const channelID = msg.channel.id + + if (content.startsWith('#')) { + // Handle # commands + } +}) + +discordClient.on('ready', () => { + console.log(`Logged in as ${discordClient.user?.tag}!`) +}) +``` + +## Process Signal Handling + +Register cleanup handlers for graceful shutdown: + +```typescript +// In index.ts +const botsToCleanup: { cleanup: () => void }[] = [] + +// Add bots that need cleanup +botsToCleanup.push(openSeaListBot) +botsToCleanup.push(openSeaSaleBot) + +process.on('SIGINT', () => { + console.log('Received SIGINT. Cleaning up...') + botsToCleanup.forEach((bot) => bot.cleanup()) + discordClient.destroy() + process.exit(0) +}) +``` + +## Message Routing + +The `#` prefix triggers project/token queries: + +- `#42 fidenza` - Get Fidenza #42 +- `#? squiggle` - Random Chromie Squiggle +- `#entry curated` - Lowest priced Curated token +- `#set AB500` - Set collection data + +Messages are routed through `ArtIndexerBot.handleNumberMessage()` which normalizes project names via `toProjectKey()` and dispatches to the appropriate `ProjectBot`. diff --git a/.cursor/rules/graphql-hasura/RULE.md b/.cursor/rules/graphql-hasura/RULE.md new file mode 100644 index 0000000..a1741f9 --- /dev/null +++ b/.cursor/rules/graphql-hasura/RULE.md @@ -0,0 +1,158 @@ +--- +description: "GraphQL and Hasura patterns for ArtBot data queries" +alwaysApply: false +--- + +# GraphQL & Hasura Patterns + +## Query File Location + +Define all GraphQL queries in `.graphql` files under `src/Data/graphql/`: + +```graphql +# src/Data/graphql/artbot-hasura-queries.graphql + +fragment ProjectDetail on projects_metadata { + id + project_id + name + artist_name + contract_address + invocations + max_invocations +} + +query getProject($id: String!) { + projects_metadata(where: { id: { _eq: $id } }) { + ...ProjectDetail + } +} +``` + +## Code Generation + +After modifying `.graphql` files, regenerate TypeScript types: + +```bash +yarn codegen +``` + +This generates types in `generated/graphql.ts`. Import from there: + +```typescript +import { + ProjectDetailFragment, + GetProjectDocument, + TokenDetailFragment, +} from '../../generated/graphql' +``` + +## Query Function Pattern + +Create wrapper functions in `src/Data/queryGraphQL.ts`: + +```typescript +import { createClient } from 'urql/core' +import { GetProjectDocument, ProjectDetailFragment } from '../../generated/graphql' + +const client = createClient({ + url: PUBLIC_HASURA_ENDPOINT, + fetch: fetch, + requestPolicy: 'network-only', +}) + +export async function getProject(projectId: string): Promise { + const { data, error } = await client + .query(GetProjectDocument, { id: projectId }) + .toPromise() + + if (error) { + throw Error(error.message) + } + + if (!data || !data.projects_metadata?.length) { + throw Error('No data returned from getProject query') + } + + return data.projects_metadata[0] +} +``` + +## Pagination Pattern + +For queries that may return many results, use pagination: + +```typescript +const maxProjectsPerQuery = 1000 + +export async function getAllProjects(): Promise { + const allProjects: ProjectDetailFragment[] = [] + let loop = true + + while (loop) { + const { data } = await client + .query(GetAllProjectsDocument, { + first: maxProjectsPerQuery, + skip: allProjects.length, + }) + .toPromise() + + if (!data) { + throw Error('No data returned from query') + } + + allProjects.push(...data.projects_metadata) + + if (data.projects_metadata.length !== maxProjectsPerQuery) { + loop = false + } + } + + return allProjects +} +``` + +## Multi-Chain Support + +ArtBot supports both Ethereum mainnet and Arbitrum. Use the appropriate client: + +```typescript +const client = createClient({ url: PUBLIC_HASURA_ENDPOINT }) +const arbitrumClient = createClient({ url: PUBLIC_ARB_HASURA_ENDPOINT }) + +const getClientForContract = (contract: string) => { + if (isArbitrumContract(contract)) { + return arbitrumClient + } + return client +} +``` + +## Fragment Reuse + +Use fragments for consistent field selection across queries: + +```graphql +fragment TokenDetail on tokens_metadata { + invocation + preview_asset_url + live_view_url + owner { public_address } + list_price + list_currency_symbol + project { name, artist_name } + contract { token_base_url, name } +} + +query getToken($token_id: String!) { + tokens_metadata(where: { id: { _eq: $token_id } }) { + ...TokenDetail + } +} + +query getWalletTokens($wallet: String!, $contracts: [String!]!) { + tokens_metadata(where: { owner_address: { _eq: $wallet } }) { + ...TokenDetail + } +} +``` diff --git a/.cursor/rules/project-config/RULE.md b/.cursor/rules/project-config/RULE.md new file mode 100644 index 0000000..14b058d --- /dev/null +++ b/.cursor/rules/project-config/RULE.md @@ -0,0 +1,147 @@ +--- +description: "Project and channel configuration patterns for ArtBot" +alwaysApply: false +--- + +# Project Configuration + +## Configuration Files + +Configuration lives in `src/ProjectConfig/`: + +| File | Purpose | +|------|---------| +| `channels.json` | Discord channel IDs and their project bot handlers | +| `channels_dev.json` | Development channel configuration | +| `projectBots.json` | Named mappings and custom config per project | +| `coreContracts.json` | Art Blocks core contract addresses | +| `partnerContracts.json` | Partner/Engine contract addresses | +| `collaborationContracts.json` | Collaboration contract addresses | +| `explorationsContracts.json` | Explorations contract addresses | + +## Channel Configuration + +Each channel can have a `projectBotHandlers` object: + +```json +{ + "123456789012345678": { + "name": "chromie-squiggle", + "projectBotHandlers": { + "default": "0", + "stringTriggers": { + "1": ["other-project"] + }, + "tokenIdTriggers": [ + { "2": [100, 200] } + ] + } + } +} +``` + +- `default`: Project ID to handle messages by default +- `stringTriggers`: Map project IDs to trigger words +- `tokenIdTriggers`: Map project IDs to token ID ranges + +## Contract Addresses + +**Always use lowercase for contract addresses:** + +```json +{ + "DOODLE": "0x28f2d3805652fb5d359486dffb7d08320d403240", + "PLOTTABLES": "0xa319c382a702682129fcbf55d514e61a16f97f9c" +} +``` + +## Named Mappings + +For projects with community-named tokens, create JSON files in `NamedMappings/`: + +```json +// ringerSingles.json - single token aliases +{ + "goose": "879", + "theone": "109" +} + +// ringerSets.json - sets of tokens +{ + "perfects": [109, 879, 1024], + "rainbows": [42, 156, 789] +} +``` + +Reference in `projectBots.json`: + +```json +{ + "13": { + "namedMappings": { + "singles": "ringerSingles.json", + "sets": "ringerSets.json" + } + } +} +``` + +## Adding a New Project Channel + +1. Get the Discord channel ID +2. Add entry to `channels.json`: + +```json +{ + "CHANNEL_ID": { + "name": "artist-channel-name", + "projectBotHandlers": { + "default": "PROJECT_ID" + } + } +} +``` + +3. Optionally add named mappings in `projectBots.json` + +## Adding a New Contract + +1. Add to appropriate contracts file (lowercase address): + +```json +{ + "CONTRACT_NAME": "0xcontractaddress..." +} +``` + +2. If it's a new Engine partner, also add to `partnerContracts.json` + +## Environment-Based Config + +The bot loads different configs based on environment: + +```typescript +const ARTBOT_IS_PROD = process.env.ARTBOT_IS_PROD?.toLowerCase() === 'true' + +const CHANNELS = ARTBOT_IS_PROD + ? require('./channels.json') + : require('./channels_dev.json') + +const PROJECT_BOTS = ARTBOT_IS_PROD + ? require('./projectBots.json') + : require('./projectBots_dev.json') +``` + +## Project Aliases + +Common project name aliases are in `project_aliases.json`: + +```json +{ + "squig": "Chromie Squiggle", + "fiddy": "Fidenza", + "ringer": "Ringers" +} +``` + +These allow users to use shorthand in commands like `#? squig`. diff --git a/.cursor/rules/project-overview/RULE.md b/.cursor/rules/project-overview/RULE.md new file mode 100644 index 0000000..70e9a8d --- /dev/null +++ b/.cursor/rules/project-overview/RULE.md @@ -0,0 +1,72 @@ +--- +description: "ArtBot project overview - Discord bot for Art Blocks NFT platform" +alwaysApply: true +--- + +# ArtBot Project Overview + +ArtBot is a Discord bot for the Art Blocks NFT platform. It provides information about Art Blocks projects, handles sales/listing feeds from OpenSea, and supports Twitter integration for notable sales. + +## Tech Stack + +- **Runtime**: Node.js 20.x +- **Language**: TypeScript 5.x with strict mode enabled +- **Package Manager**: Yarn 4.3.1 +- **Discord**: discord.js v14 +- **GraphQL**: Hasura with urql client +- **NFT APIs**: OpenSea Stream API, OpenSea REST API +- **Blockchain**: viem for Ethereum interactions +- **Twitter**: twitter-api-v2 + +## Project Structure + +``` +src/ +├── index.ts # Main entry point, Discord client, bot initialization +├── Classes/ # Bot class implementations +│ ├── APIBots/ # External API integration bots (OpenSea, etc.) +│ ├── ArtIndexerBot.ts # Project indexing and message routing +│ ├── ProjectBot.ts # Individual project handlers +│ ├── MintBot.ts # Mint event handling +│ ├── TwitterBot.ts # Twitter posting functionality +│ └── TriviaBot.ts # Trivia game functionality +├── Data/ # Data access layer +│ ├── graphql/ # GraphQL queries (.graphql files) +│ ├── queryGraphQL.ts # GraphQL client and query functions +│ └── supabase.ts # Supabase client +├── ProjectConfig/ # Configuration files +│ ├── channels.json # Discord channel configurations +│ ├── projectBots.json # Project-specific bot configurations +│ ├── *Contracts.json # Contract address configurations +│ └── projectConfig.ts # Configuration loading/parsing +├── NamedMappings/ # Project-specific token name mappings +└── Utils/ # Utility functions + ├── smartBotResponse.ts # FAQ/help response handling + ├── activityTriager.ts # Sales/listing channel routing + └── common.ts # Shared utilities +``` + +## Key Concepts + +- **ProjectBot**: Handles queries for a specific Art Blocks project (e.g., Fidenza, Chromie Squiggles) +- **ArtIndexerBot**: Indexes all projects and routes `#` commands to the right ProjectBot +- **Named Mappings**: Aliases for specific tokens or sets (e.g., "the goose" for a specific Squiggle) +- **Verticals**: Art Blocks collections (Curated, Presents, Explorations, Heritage, Collaborations) + +## Environment Configuration + +- `PRODUCTION_MODE` - Set to "true" for production behavior +- `ARTBOT_IS_PROD` - Set to "true" to use production config files +- `DISCORD_TOKEN` - Discord bot token +- `OPENSEA_API_KEY` - OpenSea API key +- `TWITTER_ENABLED` - Set to "true" to enable Twitter posting + +## Development Commands + +```bash +yarn start # Run the bot +yarn dev # Same as start +yarn format # Format code with Prettier +yarn lint # Lint with ESLint +yarn codegen # Regenerate GraphQL types +``` diff --git a/.cursor/rules/typescript-patterns/RULE.md b/.cursor/rules/typescript-patterns/RULE.md new file mode 100644 index 0000000..ea80b27 --- /dev/null +++ b/.cursor/rules/typescript-patterns/RULE.md @@ -0,0 +1,101 @@ +--- +description: "TypeScript coding conventions and patterns for ArtBot" +alwaysApply: false +--- + +# TypeScript Patterns + +## Strict Mode + +TypeScript strict mode is enabled. Always handle null/undefined properly: + +```typescript +// Good - check before access +const name = project?.name ?? 'unknown' + +// Good - guard clause +if (!data || !data.projects_metadata) { + throw Error('No data returned from query') +} + +// Bad - will fail strict null checks +const name = project.name // Error if project could be null +``` + +## Type Definitions + +- Use explicit types for function parameters and return values +- Prefer interfaces for object shapes, types for unions/aliases +- Import generated types from `generated/graphql.ts` for GraphQL data + +```typescript +// Interface for object shapes +interface SaleEvent { + contractAddress: string + tokenId: string + price: number + currency: string +} + +// Type for unions +type MessageType = 'random' | 'project' | 'artist' | 'wallet' + +// Explicit return types +async function getProject(id: string): Promise { + // ... +} +``` + +## Async Patterns + +Use `async/await` over raw promises: + +```typescript +// Good +async function fetchData() { + const result = await client.query(SomeDocument, {}).toPromise() + return result.data +} + +// Avoid chained .then() when possible +``` + +## Import Organization + +Order imports: external packages first, then internal modules: + +```typescript +// External packages +import { Client, EmbedBuilder } from 'discord.js' +import axios from 'axios' + +// Internal modules +import { ProjectBot } from './ProjectBot' +import { getProject } from '../Data/queryGraphQL' +import { CHANNEL_BLOCK_TALK } from '..' +``` + +## Error Handling + +Use try/catch blocks around external API calls. Log errors with context: + +```typescript +try { + const data = await someApiCall() + // process data +} catch (err) { + console.error('Error in someOperation:', err) + // Handle gracefully - don't rethrow unless necessary +} +``` + +## Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Classes | PascalCase | `ProjectBot`, `ArtIndexerBot` | +| Functions/Methods | camelCase | `handleNumberMessage` | +| Constants | UPPER_SNAKE_CASE | `CHANNEL_BLOCK_TALK` | +| Class files | PascalCase | `ProjectBot.ts` | +| Utility files | camelCase | `smartBotResponse.ts` | +| Config JSON | camelCase | `channels.json` | diff --git a/src/Classes/InsightsBot.ts b/src/Classes/InsightsBot.ts deleted file mode 100644 index 40c2129..0000000 --- a/src/Classes/InsightsBot.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as dotenv from 'dotenv' -dotenv.config() -import { EmbedBuilder, Message } from 'discord.js' -import axios from 'axios' -import { randomColor } from '../Utils/smartBotResponse' - -export class InsightsBot { - async getInsightsApiResponse(msg: Message): Promise { - try { - // strip out !artBot from the message - const messageContent = msg.content.replace('!artBot', '').trim() - - const insightsResponse = await axios.post( - 'https://qhjte7logh.execute-api.us-east-1.amazonaws.com/production-stage/insights', - { - query: messageContent, - }, - { - headers: { - 'x-api-key': process.env.INSIGHTS_API_KEY ?? '', - 'Content-Type': 'application/json', - }, - } - ) - - const answer = insightsResponse?.data?.[0]?.content ?? '' - - if (!answer.length) { - throw new Error('No answer from Insights API') - } - - const answerWithFeedback = `${answer}\n\nThis response is AI-generated. Let us know what you think in <#769251416778604562>!` - - const embed = new EmbedBuilder() - .setTitle('Artbot AI (Beta)') - .setColor(randomColor()) - .setDescription(answerWithFeedback) - - return embed - } catch (error) { - console.error('Error getting insights API response:', error) - return new EmbedBuilder() - .setTitle('Artbot AI (Beta)') - .setColor('#FF0000') - .setDescription( - "Sorry, I'm not sure how to answer that. Please check out the [Art Blocks website](https://www.artblocks.io)." - ) - .setFooter({ - text: 'If this persists, please contact the bot administrator.', - }) - } - } -} diff --git a/src/Utils/smartBotResponse.ts b/src/Utils/smartBotResponse.ts index 244ce70..2a47910 100644 --- a/src/Utils/smartBotResponse.ts +++ b/src/Utils/smartBotResponse.ts @@ -1,6 +1,6 @@ import { EmbedBuilder, ColorResolvable, Message } from 'discord.js' import * as dotenv from 'dotenv' -import { artIndexerBot, projectConfig, triviaBot, insightsBot } from '..' +import { artIndexerBot, projectConfig, triviaBot } from '..' dotenv.config() const fetch = require('node-fetch') @@ -162,7 +162,6 @@ const HELP_MESSAGE = new EmbedBuilder() **staysafe?**: Tips on avoiding scams **aliases?**: A handy list of aliases that can be used in \`#\` commands. **hashtag?**: A handy list of all \`#\` functionalities - **artBot**: Ask about Art Blocks artists and projects by tagging @artbot at the start of your question. This AI-powered feature is in beta. ` ) // Custom message shown when someone asks about ArtBlocks @@ -302,7 +301,6 @@ export async function smartBotResponse( projectConfig.chIdByName['for-sale-listings'] const CHANNEL_TRADE_SWAPS: string = projectConfig.chIdByName['trade-swaps'] const CHANNEL_BLOCK_TALK: string = projectConfig.chIdByName['block-talk'] - const CHANNEL_GENERAL: string = projectConfig.chIdByName['general'] if (msgContentLowercase === 'gm') { const reactionEmoji = msg.guild?.emojis.cache.find( @@ -404,15 +402,6 @@ export async function smartBotResponse( ) } - if ( - (channelID == CHANNEL_BLOCK_TALK || channelID == CHANNEL_GENERAL) && - containsQuestion && - mentionedArtBot - ) { - // Handle messages to the Insights API for AI responses - return insightsBot.getInsightsApiResponse(msg) - } - if ( (channelID == CHANNEL_FOR_SALE_LISTINGS || channelID == CHANNEL_TRADE_SWAPS) && diff --git a/src/index.ts b/src/index.ts index 3c03eb4..497f0eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ import { getStudioContracts, getArtistsTwitterHandles, } from './Data/queryGraphQL' -import { InsightsBot } from './Classes/InsightsBot' import { TriviaBot } from './Classes/TriviaBot' import { ScheduleBot } from './Classes/SchedulerBot' import { verifyTwitter } from './Utils/twitterUtils' @@ -245,7 +244,6 @@ discordClient.on('disconnect', () => { }) export const triviaBot = new TriviaBot(discordClient) -export const insightsBot = new InsightsBot() new ScheduleBot(discordClient.channels.cache, projectConfig)