Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 26 additions & 75 deletions .cursor/rules/discord-bot/RULE.md
Original file line number Diff line number Diff line change
@@ -1,142 +1,93 @@
---
description: "Discord.js patterns and bot class architecture for ArtBot"
globs: "**/Classes/**,**/Utils/smartBotResponse.ts,**/Utils/activityTriager.ts,**/index.ts"
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<string, Value> = 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)
setInterval(() => this.periodicTask(), 60000)
}

// Public handler methods
async handleSomeEvent(event: EventType) {
try {
// Handle the event
// ...
} catch (err) {
console.error('Error handling event:', err)
logger.error({ err }, 'Error handling event')
}
}

// Private helper methods
private helperMethod(): void {
// Implementation
}

// Cleanup method for resource management
cleanup() {
// Clear intervals, close connections, reset state
this.someState.clear()
}
}
```

## Message Handling
Register bots that need shutdown cleanup on `botsToCleanup` in `index.ts` (SIGINT/SIGTERM). MintBot/TriviaBot/TwitterBot are not all on that list today — be careful adding long-lived intervals.

Check if channel is sendable before sending messages:
## Message Handling

```typescript
async handleNumberMessage(msg: Message) {
if (!msg.channel.isSendable()) {
return
}

// Process message
msg.channel.send('Response')
if (!msg.channel.isSendable()) return
await msg.channel.send('Response')
}
```

## Embed Messages
Use `logger` (pino) from `src/logger` / project logger helpers — not `console.error`.

Use `EmbedBuilder` for rich message formatting:
## Embeds

```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] })
await msg.channel.send({ embeds: [embed] })
```

## Event Listeners
## Message Routing

Handle Discord events via the client exported from index.ts:
`#` prefix → project/token queries via `ArtIndexerBot` / `projectConfig`:

```typescript
import { discordClient } from '..'

discordClient.on(Events.MessageCreate, async (msg) => {
const content = msg.content
const channelID = msg.channel.id

if (content.startsWith('#')) {
// Handle # commands
}
})
- `#42 fidenza` — specific token
- `#? squiggle` — random
- `#entry curated` — lowest listed in vertical
- `#set AB500` — set collection helpers

discordClient.on('ready', () => {
console.log(`Logged in as ${discordClient.user?.tag}!`)
})
```
Non-`#` → `smartBotResponse.ts`. Channel must be present in active `channels*.json`.

## Process Signal Handling
## OpenSea Dual Path

Register cleanup handlers for graceful shutdown:
1. **Stream** (`@opensea/stream-js`): primary listings + sales; gated by `PRODUCTION_MODE` in handlers
2. **REST poll** (`OpenSeaEventsPollBot`): sales backfill; registers stream sale IDs to dedupe

```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)
})
```
Activity posts go through `activityTriager.ts` (hardcoded artist/platform → channel mapping + ban list). New sale destinations usually need code there, not only JSON.

## Message Routing
## Mint Webhook

The `#` prefix triggers project/token queries:
`POST /new-mint` — Hasura payload; auth header `webhook_secret` must equal `MINT_WEBHOOK_SECRET`. MintBot polls media-proxy until an image is ready, then posts using `mintBotConfig.json` channel names.

- `#42 fidenza` - Get Fidenza #42
- `#? squiggle` - Random Chromie Squiggle
- `#entry curated` - Lowest priced Curated token
- `#set AB500` - Set collection data
## Process Signals

Messages are routed through `ArtIndexerBot.handleNumberMessage()` which normalizes project names via `toProjectKey()` and dispatches to the appropriate `ProjectBot`.
`SIGINT`/`SIGTERM` in `index.ts`: OpenSea cleanup → `botsToCleanup` → `discordClient.destroy()`.
145 changes: 27 additions & 118 deletions .cursor/rules/graphql-hasura/RULE.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
---
description: "GraphQL and Hasura patterns for ArtBot data queries"
globs: "**/Data/**,**/generated/**,**/codegen.ts"
alwaysApply: false
---

# GraphQL & Hasura Patterns

## Query File Location
## Endpoint

Define all GraphQL queries in `.graphql` files under `src/Data/graphql/`:
- Public Hasura: `https://data.artblocks.io/v1/graphql` (hardcoded in `queryGraphQL.ts` and `codegen.ts`)
- Optional auth: `HASURA_GRAPHQL_ADMIN_SECRET` → `x-hasura-admin-secret`
- **One client only** — there is no separate Arbitrum Hasura endpoint/client in this repo

```graphql
# src/Data/graphql/artbot-hasura-queries.graphql
## Query Files

Define documents under `src/Data/graphql/`:

```graphql
fragment ProjectDetail on projects_metadata {
id
project_id
Expand All @@ -21,138 +26,42 @@ fragment ProjectDetail on projects_metadata {
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:
Writes `generated/graphql.ts` (gitignored). `postinstall` and pre-commit also run codegen. Import generated types/documents from there.

```typescript
import {
ProjectDetailFragment,
GetProjectDocument,
TokenDetailFragment,
} from '../../generated/graphql'
```
## Query Wrapper Pattern

## Query Function Pattern

Create wrapper functions in `src/Data/queryGraphQL.ts`:
Wrappers live in `src/Data/queryGraphQL.ts` (urql, `requestPolicy: 'network-only'`).

```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<ProjectDetailFragment> {
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]
export async function getProject(
projectId: number,
contractAddress?: string
): Promise<ProjectDetailFragment> {
// builds Hasura id, queries, throws if missing
}
```

## Pagination Pattern
Token IDs in Hasura are typically `{contractAddress}-{tokenId}` where tokenId encodes invocation + project number × 1e6.

For queries that may return many results, use pagination:
## Pagination

```typescript
const maxProjectsPerQuery = 1000

export async function getAllProjects(): Promise<ProjectDetailFragment[]> {
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
}
```
Large lists use 1000-item loops (`first` / `skip`) until a short page is returned — see `getAllProjects` and similar helpers.

## Fragment Reuse
## Multi-Chain Note

Use fragments for consistent field selection across queries:
Hasura rows may include `chain_id` for Ethereum / Arbitrum / Base. That does **not** imply OpenSea Discord feeds cover those chains — stream filtering in `index.ts` is Ethereum-oriented. Do not document a phantom `arbitrumClient`.

```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 }
}
## After Schema/Query Changes

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
}
}
```
1. Edit `.graphql`
2. `yarn codegen`
3. Update `queryGraphQL.ts` callers/types
4. Smoke with `yarn start` (CI uses `PRODUCTION_MODE=false`)
Loading
Loading