├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── curated-project-bot-support.md │ ├── new-command.md │ ├── new-project-alias.md │ ├── new-project-set.md │ ├── new-project-singles.md │ └── playground-project-bot-support.md └── workflows │ └── build-check.yml ├── .gitignore ├── .husky ├── post-checkout ├── post-merge └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .yarnrc.yml ├── CODEOWNERS ├── LICENSE ├── Procfile ├── README.md ├── codegen.ts ├── package.json ├── src ├── Classes │ ├── APIBots │ │ ├── ApiPollBot.ts │ │ ├── ReservoirListBot.ts │ │ ├── ReservoirSaleBot.ts │ │ └── utils.ts │ ├── ArtIndexerBot.ts │ ├── InsightsBot.ts │ ├── MintBot.ts │ ├── ProjectBot.ts │ ├── ProjectHandlerHelper.ts │ ├── SchedulerBot.ts │ ├── TriviaBot.ts │ └── TwitterBot.ts ├── Data │ ├── graphql │ │ └── artbot-hasura-queries.graphql │ ├── queryGraphQL.ts │ └── supabase.ts ├── NamedMappings │ ├── apparitionSets.json │ ├── apparitionSingles.json │ ├── archetypeSets.json │ ├── autologySets.json │ ├── autologySingles.json │ ├── bentSets.json │ ├── buuSets.json │ ├── dreamSets.json │ ├── dreamSingles.json │ ├── edificeSets.json │ ├── elementalsSets.json │ ├── elementalsSingles.json │ ├── fidenzaSets.json │ ├── geometryRunnersSets.json │ ├── geometryRunnersSingles.json │ ├── paperArmadaSets.json │ ├── paperArmadaSingles.json │ ├── pigmentsSets.json │ ├── qilinSingles.json │ ├── ringerSets.json │ ├── ringerSingles.json │ ├── screensSets.json │ ├── screensSingles.json │ ├── scribbledSets.json │ ├── scribbledSingles.json │ ├── squiggleSets.json │ ├── subscapeSets.json │ ├── subscapeSingles.json │ ├── watercolorDreamSets.json │ └── watercolorDreamSingles.json ├── ProjectConfig │ ├── blockedEngineContracts.json │ ├── channels.json │ ├── channels_dev.json │ ├── collaborationContracts.json │ ├── contract_aliases.json │ ├── coreContracts.json │ ├── explorationsContracts.json │ ├── mintBotConfig.json │ ├── partnerContracts.json │ ├── projectBots.json │ ├── projectBots_dev.json │ ├── projectConfig.ts │ ├── project_aliases.json │ └── stagingContracts.json ├── Utils │ ├── activityTriager.ts │ ├── common.ts │ ├── smartBotResponse.ts │ └── twitterUtils.ts └── index.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_AUTH_DETAILS={} 2 | METADATA_REFRESH_INTERVAL_MINUTES=60 3 | OPENSEA_API_KEY= 4 | RANDOM_ART_INTERVAL_MINUTES=20 5 | RESERVOIR_API_KEY= 6 | DISCORD_TOKEN= 7 | ETHERSCAN_API_KEY= 8 | MINT_REFRESH_TIME_SECONDS=30 9 | PRODUCTION_MODE=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | generated/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint', 'prettier'], 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier', 8 | ], 9 | parserOptions: { 10 | project: './tsconfig.json', 11 | ecmaVersion: 2021, 12 | }, 13 | env: { 14 | jest: true, 15 | node: true, 16 | }, 17 | rules: { 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-member-accessibility': 'off', 20 | '@typescript-eslint/indent': 'off', 21 | '@typescript-eslint/member-delimiter-style': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-var-requires': 'off', 24 | '@typescript-eslint/no-use-before-define': 'off', 25 | '@typescript-eslint/no-unused-vars': [ 26 | 'error', 27 | { 28 | argsIgnorePattern: '^_', 29 | ignoreRestSiblings: true, 30 | }, 31 | ], 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/curated-project-bot-support.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Curated Project Bot Support 3 | about: 'Ticket for requesting bot support for a new Curated project. ' 4 | title: '[CURATED PROJECT] Project #N' 5 | labels: curated, project-query-support 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like for Project 143, Phase by Loren Bednar: 14 | 15 | - Project Title: "Phase" 16 | - Project ID: 143 17 | - Artist Channel ID: 877060147619446814 18 | - Artist Channel Name: "Loren Bednar" 19 | 20 | Note: you can find the channel ID for a given channel by right-clicking it in Discord and selecting "Copy ID". 21 | 22 | ###################################################################### 23 | 24 | - Project Title: 25 | - Project ID: 26 | - Artist Channel ID: 27 | - Artist Channel Name: 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-command.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Command 3 | about: 'Ticket for requesting a new command (e.g. 'staysafe?') or trigger (e.g. 'otc')' 4 | title: 'New Command: ' 5 | labels: command 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like for the "staysafe?" command: 14 | 15 | - Command word(s): "safety", "staysafe" 16 | - Question or Trigger: Question 17 | - Message: "Always double check who you are trading with by verifying their Discord # and ID" 18 | - Valid channels: trade-swaps, help, listing-feed, general 19 | - "help?" menu text (if any): "Tips on avoiding scams" 20 | 21 | ###################################################################### 22 | 23 | - Command word(s): 24 | - Question or Trigger: 25 | - Message: 26 | - Valid channels: 27 | - "help?" menu text (if any): 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-project-alias.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Project Alias 3 | about: 'Ticket for requesting bot support for new project aliases.' 4 | title: '[ALIAS] Project #N' 5 | labels: project-query-support 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like for Project 23, Archetype 14 | 15 | - Project Name: "Archetype" 16 | - New Alias(es): "archies", "arch", "arches" 17 | 18 | ###################################################################### 19 | 20 | - Project Title: 21 | - New Alias(es): 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-project-set.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Project Named Set 3 | about: 'Ticket for requesting bot support for new named sets. ' 4 | title: '[SETS] Project #N' 5 | labels: project-query-support 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like for Project 23, Archetype 14 | 15 | - Project Title: "Archetype" 16 | - Project ID: 23 17 | - New Set(s): 18 | 19 | "cube": [122, 213, 216, 250, 327, 397, 457, 471, 582, 596] 20 | 21 | ###################################################################### 22 | 23 | - Project Title: 24 | - Project ID: 25 | - New Set(s): 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-project-singles.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Project Named Singles 3 | about: 'Ticket for requesting bot support for new named singles. ' 4 | title: '[SINGLES] Project #N' 5 | labels: project-query-support 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like for Project 282, Memories of Qilin: 14 | 15 | - Project Title: "Memories of Qilin" 16 | - Project ID: 282 17 | - New Singles: 18 | 19 | "leviathan": 102, 20 | "uncoiling-dragon": 134, 21 | "anteater": 189, 22 | "black-heart": 191, 23 | "diving-swan": 388 24 | 25 | ###################################################################### 26 | 27 | - Project Title: 28 | - Project ID: 29 | - New Singles: 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/playground-project-bot-support.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Playground Project Bot Support 3 | about: Ticket for requesting bot support for a new Playground project 4 | title: '[PLAYGROUND PROJECT] Project #N' 5 | labels: playground, project-query-support 6 | assignees: grantoesterling 7 | --- 8 | 9 | ###################################################################### 10 | 11 | Please fill out the template below and then delete everything within the #'s here, it is purely informational for help filing this ticket. 12 | 13 | For example, this is what it would look like Project 145, Beatboxes by Zeblocks: 14 | 15 | - Project Title: "Beatboxes" 16 | - Project ID: 145 17 | - Artist Channel ID: 800761846235136020 18 | - Project Keyword: "beatbox" 19 | 20 | Note: you can find the channel ID for a given channel by right-clicking it in Discord and selecting "Copy ID". 21 | 22 | Also note that Playground projects need a project keyword so that ArtBot knows that the user is intending to trigger the bot for a given Playground project vs. the artist's first Curated project, which is the default. 23 | 24 | ###################################################################### 25 | 26 | - Project Title: 27 | - Project ID: 28 | - Artist Channel ID: 29 | - Project Keyword: 30 | -------------------------------------------------------------------------------- /.github/workflows/build-check.yml: -------------------------------------------------------------------------------- 1 | name: 'Build Check' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | 10 | jobs: 11 | build-check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | - uses: actions/checkout@v4 18 | - name: Enable corepack 19 | run: corepack enable 20 | - name: Set env vars 21 | run: | 22 | echo "PRODUCTION_MODE=false" >> $GITHUB_ENV 23 | echo "HASURA_GRAPHQL_ENDPOINT=${{ secrets.HASURA_GRAPHQL_ENDPOINT }}" >> $GITHUB_ENV 24 | echo "HASURA_GRAPHQL_ADMIN_SECRET=${{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}" >> $GITHUB_ENV 25 | echo "DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }}" >> $GITHUB_ENV 26 | - name: Install modules 27 | run: yarn install 28 | - name: Run codegen 29 | run: yarn codegen 30 | - name: Run program 31 | run: timeout 1m yarn start || ( [[ $? -eq 124 ]] && echo "WARNING Timeout reached, but that's OK" ) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # Giveaway json file 76 | giveaways.json 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | .parcel-cache 81 | 82 | # Next.js build output 83 | .next 84 | out 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and not Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | 111 | # Stores VSCode versions used for testing VSCode extensions 112 | .vscode-test 113 | 114 | # yarn v2 115 | .yarn/cache 116 | .yarn/unplugged 117 | .yarn/build-state.yml 118 | .yarn/install-state.gz 119 | .pnp.* 120 | 121 | # Intellij IDEA 122 | .idea 123 | 124 | # Yarn package lock 125 | package-lock.json 126 | 127 | generated/ 128 | 129 | .DS_Store -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn codegen 5 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn codegen 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn codegen && yarn lint-and-format 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.5.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | package-lock.json 4 | generated/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "esbenp.prettier-vscode", 8 | "dbaeumer.vscode-eslint", 9 | "graphql.vscode-graphql" 10 | ], 11 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 12 | "unwantedRecommendations": [] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | // Set the default 4 | // "editor.formatOnSave": false, 5 | "editor.tabSize": 2, 6 | "scss.validate": false, 7 | "editor.quickSuggestions": { 8 | "comments": "on", 9 | "strings": "on", 10 | "other": "on" 11 | }, 12 | "editor.autoClosingQuotes": "always", 13 | "[javascript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode", 15 | "editor.formatOnSave": true 16 | }, 17 | "[typescript]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode", 19 | "editor.formatOnSave": true 20 | }, 21 | "[graphql]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode", 23 | "editor.formatOnSave": true 24 | }, 25 | "typescript.tsdk": "node_modules/typescript/lib" 26 | } 27 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Request review from the best fit approvers group for this repo. 2 | * @ArtBlocks/Eng-Approvers-Product 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 curatingbits and others. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArtBlocks/artbot/001778746a24a713acc9a1007b1e055ee46151a1/Procfile -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ArtBot: The Art Blocks Discord Bot 2 | 3 | ![Build status](https://github.com/ArtBlocks/artbot/actions/workflows/build-check.yml/badge.svg) 4 | [![GitPOAPs](https://public-api.gitpoap.io/v1/repo/ArtBlocks/artbot/badge)](https://www.gitpoap.io/gh/ArtBlocks/artbot) 5 | 6 | The Discord bot for [ArtBlocks](http://artblocks.io/). 7 | 8 | ArtBot is a Node.js application. It uses the [Yarn Package Manager](https://yarnpkg.com/) to manage dependencies and run the application. It can be interacted with via Discord messages. 9 | 10 | ## Running artbot 11 | 12 | - Verify you have Node.js and npm installed. If not, you can refer to the [Node.js official page](https://nodejs.org/) to get started. 13 | 14 | ```bash 15 | node -v 16 | npm -v 17 | ``` 18 | 19 | - Install Yarn Package Manager. For detailed instructions, refer to the [Yarn official page](https://yarnpkg.com/getting-started/install). 20 | 21 | ```bash 22 | npm install -g yarn 23 | ``` 24 | 25 | - Install the package dependencies 26 | 27 | ```bash 28 | yarn install 29 | ``` 30 | 31 | - Join the a-t Discord Server 32 | 33 | Artbot is based on the [discord.js](https://discord.js.org/) package, and is exclusively concerned with processing and sending Discord messages. If you want to be able to interact with it, joining the Artbot test Discord server is the way to go. 34 | 35 | - Set up `.env` file based on `.env.example`. 36 | 37 | - Run the application 38 | 39 | ```bash 40 | yarn start 41 | ``` 42 | 43 | - You should now be able to interact with your local Artbot instance in the a-t Discord server! 44 | 45 | ## Basic structure of artbot 46 | 47 | The core engine of Artbot is built around the discord.js package. It serves several functions, all of which are based on listening to messages in the ArtBlocks Discord, and responding with other messages. This core functionality is driven from index.js, and there are several helper Classes and Utility packages that assist with this logic. 48 | 49 | - Project Queries 50 | 51 | One of the most widely used features is Artbot's ability to respond to a #[n] [project_name] query with a link to the appropriate token w/ embedded image. Currently this is implemented via the ProjectBot class. 52 | 53 | - All projects and their metadata are retrieved from the subgraph on startup in the `ArtIndexerBot.ts` class, which in turn creates a `ProjectBot` for every project. `#[n] [project_name]`, `#?`, etc queries are triaged by the `ArtIndexerBot` class, and the corresponding `ProjectBot` is triggered to respond. 54 | - Curated artist channels are handled a bit differently. ProjectBots for the artist's projects are defined in `ProjectConfig/channels.json` and are triggered by the artist's name in the query. e.g. `#1 ringer` in `#dmitri-cherniak` will trigger the Ringer project bot. 55 | - Additional configuration for these projects can be defined in `ProjectConfig/projectBots.json`. See [Adding query support for a project](#adding-query-support-for-a-project) for more details. 56 | 57 | - Sales/Listing Feeds 58 | 59 | Artbot also provides a feeds for sales and listings of Art Blocks projects. It polls the (incredible) [Reservoir API](https://docs.reservoir.tools/reference/overview) to get the latest activity across all marketplaces (using the `ReservoirListBot.ts` and `ReservoirSaleBot.ts` classes, respectively), and then posts them to the appropriate Discord channels (`Utils/activityTriager.js`). 60 | 61 | - SmartBot Responses 62 | 63 | Artbot has been taught to respond to some specific queries about gas price, curated/playground/factory, etc. when directly queried. This logic lives in `Utils/smartBotResponse.js`. 64 | 65 | ## Adding query support for a project 66 | 67 | ### Definitions 68 | 69 | #### Bot ID 70 | 71 | A bot ID consists of a project ID and contract name concatenated via a `-`. This is used in the config files to identify which bot should be used where or which bot you're configuring. For Art Blocks projects the contract name is optional and as such the `-` is not required. 72 | 73 | An example of a simple bot ID would be `0` for Chromie Squiggles or `0-DOODLE` for The Family Mooks. Contract names are defined in `partnerContracts.json`. 74 | 75 | ##### Contract Names 76 | 77 | Here are the currently valid contract names. 78 | 79 | | Partner | Contract Name | Contract Address | 80 | | ----------- | ------------- | ------------------------------------------ | 81 | | Doodle Labs | DOODLE | 0x28f2d3805652fb5d359486dffb7d08320d403240 | 82 | | Plottables | PLOTTABLES | 0xa319C382a702682129fcbF55d514E61a16f97f9c | 83 | 84 | ### Required Configuration 85 | 86 | - `ProjectConfig/channels.json`: 87 | - key: Discord channel ID 88 | - value: object: 89 | - key: `"name"` 90 | - value: name of Discord channel 91 | - key: `"projectBotHandlers"` 92 | - value: object: 93 | - key: `"default"` 94 | - value: [Bot ID](#bot-id) 95 | - (optional) key: `"stringTriggers"` 96 | - value: object: 97 | - key: [Bot ID](#bot-id) 98 | - value: array of strings that trigger artbot to use the project bot 99 | - (optional) key: `"tokenIdTriggers"`: 100 | - value: object: 101 | - key: [Bot ID](#bot-id) 102 | - value: length-2 array defining range of token IDs that trigger artbot to use the project bot. e.g. [555, null] means all tokens >= 555 should use the project bot defined in key. [100, 200] means all tokens from 100 to 200 should use the project bot bot defined in key. 103 | 104 | ### Optional Configuration 105 | 106 | - `ProjectConfig/projectBots.json` 107 | - key: [Bot ID](#bot-id) 108 | - value: object: 109 | - (optional) key: `"namedMappings"` 110 | - value: object: 111 | - (optional) key: `"sets"` 112 | - value: json filename defining single token labels; located in 'NamedMappings' directory. e.g. `ringerSingles.json` 113 | - (optional) key: `"singles"` 114 | value: json filename defining sets of token labels; located in 'NamedMappings' directory. e.g. `ringerSets.json` 115 | - `ProjectConfig/partnerContracts.json` 116 | - key: contract name 117 | - value: contract address (lowercase) 118 | - `NamedMappings/Singles.json` 119 | - json file defining trigger names for single tokens. See `ringerSingles.json` for example. 120 | - `NamedMappings/Seets.json` 121 | - json file defining trigger names for single tokens. See `ringerSets.json` for example. 122 | 123 | ## Engine instructions 124 | 125 | These instructions explain how to configure Art Bot to serve project data in relevant channels. 126 | 127 | 1. Invite ArtBot to your server by clicking [here](https://discord.com/oauth2/authorize?client_id=794646394420854824&scope=bot&permissions=19520). Note that you must have the "Manage Server" permission on the desired server to invite Art Bot. 128 | 2. As a Engine partner you most likely have a contract of your own. To configure this you will have to follow the [optional configuration](#optional-configuration) scheme in `ProjectConfig/partnerContracts.json` by adding a new entry. 129 | 130 | > :warning: Address must be all lowercase characters 131 | 132 | **Example Config** 133 | 134 | ```json 135 | { 136 | "DOODLE": "0x28f2d3805652fb5d359486dffb7d08320d403240", 137 | "PLOTTABLES": "0xa319c382a702682129fcbf55d514e61a16f97f9c", 138 | "": "" 139 | } 140 | ``` 141 | 142 | 3. Create a pull request following the configuration schema in [required configuration](#required-configuration) to set up Art Bot to listen to a relevant channel or channels. Note that Art Bot must have the proper permissions to view whatever channel it is listening to. 143 | 144 | **Example Config** 145 | 146 | ```json 147 | "880280317477404713": { 148 | "name": "doodle-labs-the-lab", 149 | "projectBotHandlers": { 150 | "default": "0-DOODLE", 151 | "stringTriggers": { 152 | "1-DOODLE": [ 153 | "slider" 154 | ], 155 | "2-DOODLE": [ 156 | "neo", 157 | "neogen" 158 | ] 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | 4. Please update the [contract names](#contract-names) table in the README if you added a new contract to `partnerContract.json`. 165 | 5. Once the pull request goes in you should then be able to query Art Bot for configured projects in the relevant channel(s). 166 | 167 | ## Contributing to artbot 168 | 169 | For now, Artbot development is coordinated informally over Discord. Please reach out to grant#6616, purplehat.eth#7327, or ryley-o.eth#5272 if you think you might be interested in helping out. 170 | 171 | Anyone who contributes to Artbot will be eligible to claim a [GitPOAP](https://www.gitpoap.io/gh/ArtBlocks/artbot) 172 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | const config: CodegenConfig = { 4 | overwrite: true, 5 | schema: 'https://data.artblocks.io/v1/graphql', 6 | documents: ['./src/Data/**/*.graphql'], 7 | generates: { 8 | 'generated/graphql.ts': { 9 | plugins: [ 10 | 'typescript', 11 | 'typescript-operations', 12 | 'typescript-resolvers', 13 | 'typed-document-node', 14 | ], 15 | }, 16 | }, 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "artbot", 3 | "version": "1.0.0", 4 | "description": "A bot to provide info for artblocks", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node src/index.ts", 8 | "dev": "yarn start", 9 | "test": "jest", 10 | "format": "prettier --write .", 11 | "lint": "eslint . --ext ts --ext tsx --ext js", 12 | "codegen": "graphql-codegen --config ./codegen.ts", 13 | "prepare": "husky install", 14 | "postinstall": "yarn codegen", 15 | "lint-and-format": "yarn lint && yarn format" 16 | }, 17 | "keywords": [ 18 | "artblocks", 19 | "discord", 20 | "crypto", 21 | "eth" 22 | ], 23 | "packageManager": "yarn@4.3.1", 24 | "engines": { 25 | "node": "20.x" 26 | }, 27 | "author": "grant oesterling", 28 | "license": "MIT", 29 | "dependencies": { 30 | "@ardatan/aggregate-error": "^0.0.6", 31 | "@graphql-codegen/cli": "^2.13.11", 32 | "@graphql-codegen/typed-document-node": "^2.3.6", 33 | "@graphql-codegen/typescript": "^2.8.1", 34 | "@graphql-codegen/typescript-operations": "^2.5.5", 35 | "@graphql-codegen/typescript-resolvers": "2.7.6", 36 | "@graphql-codegen/typescript-urql": "^3.7.3", 37 | "@graphql-codegen/urql-introspection": "^2.2.1", 38 | "@graphql-tools/utils": "^8.3.0", 39 | "@reservoir0x/reservoir-sdk": "^2.4.32", 40 | "@supabase/supabase-js": "^2.29.0", 41 | "@types/node": "20.1.2", 42 | "@types/node-cron": "^3.0.8", 43 | "@typescript-eslint/eslint-plugin": "^5.41.0", 44 | "@typescript-eslint/parser": "^5.41.0", 45 | "@urql/exchange-retry": "^1.0.0", 46 | "axios": "^1.6.0", 47 | "body-parser": "^1.15.2", 48 | "chatgpt": "^5.2.5", 49 | "croner": "^6.0.7", 50 | "discord.js": "^14.16.3", 51 | "dotenv": "^16.0.3", 52 | "eslint": "^8.26.0", 53 | "eslint-config-airbnb-base": "^15.0.0", 54 | "eslint-config-prettier": "^8.5.0", 55 | "eslint-plugin-import": "^2.26.0", 56 | "eslint-plugin-prettier": "^4.0.0", 57 | "ethers": "^5.7.0", 58 | "googleapis": "^92.0.0", 59 | "graphql": "^16.8.1", 60 | "graphql-tag": "^2.12.6", 61 | "husky": "^8.0.3", 62 | "jest": "^28.0.3", 63 | "lodash.deburr": "^4.1.0", 64 | "ms": "^2.0.0", 65 | "node-fetch": "^2.6.1", 66 | "node-html-parser": "^3.2.0", 67 | "openai": "^4.73.0", 68 | "prettier": "^2.6.2", 69 | "react": "18.2.0", 70 | "reconnecting-websocket": "^4.4.0", 71 | "sharp": "^0.32.6", 72 | "timeago.js": "^4.0.2", 73 | "ts-node": "^10.9.2", 74 | "twitter-api-v2": "^1.14.1", 75 | "typescript": "^5.1.6", 76 | "urql": "^3.0.3", 77 | "viem": "2.17.4", 78 | "web3": "^1.7.0", 79 | "ws": "^8.17.1" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Classes/APIBots/ApiPollBot.ts: -------------------------------------------------------------------------------- 1 | import { Client, ColorResolvable } from 'discord.js' 2 | import { 3 | buildOpenseaURL, 4 | buildLooksRareURL, 5 | buildX2Y2URL, 6 | getOSName, 7 | } from './utils' 8 | 9 | const axios = require('axios') 10 | /** Abstract parent class for all API Poll Bots */ 11 | export class APIPollBot { 12 | apiEndpoint: string 13 | refreshRateMs: number 14 | bot: Client 15 | headers: any 16 | listColor: ColorResolvable 17 | saleColor: ColorResolvable 18 | sweepColor: ColorResolvable 19 | artblocksSaleColor: ColorResolvable 20 | artblocksListColor: ColorResolvable 21 | lastUpdatedTime: number 22 | intervalId?: NodeJS.Timeout 23 | 24 | /** 25 | * Constructor 26 | * @param {string} apiEndpoint - Endpoint to be hitting 27 | * @param {number} refreshRateMs - How often to poll the endpoint (in ms) 28 | * @param {*} bot - Discord bot that will be sending messages 29 | * @param {*} headers - Optional: any headers to supply (namely, API tokens) 30 | */ 31 | constructor( 32 | apiEndpoint: string, 33 | refreshRateMs: number, 34 | bot: Client, 35 | headers = {} 36 | ) { 37 | this.apiEndpoint = apiEndpoint 38 | this.refreshRateMs = refreshRateMs 39 | this.bot = bot 40 | this.headers = headers 41 | this.listColor = '#407FDB' 42 | this.saleColor = '#62DE7C' 43 | this.sweepColor = '#A956FA' 44 | this.artblocksSaleColor = '#ffc204' 45 | this.artblocksListColor = '#e300cd' 46 | 47 | // Only send events that occur after this bot gets initialized 48 | this.lastUpdatedTime = Date.now() 49 | 50 | // Poll the specified API every refreshRateMS millis 51 | this.startPolling() 52 | } 53 | 54 | /** 55 | * Start polling the API 56 | */ 57 | startPolling() { 58 | this.intervalId = setInterval(this.pollApi.bind(this), this.refreshRateMs) 59 | } 60 | 61 | /** 62 | * Stop polling the API 63 | */ 64 | stopPolling() { 65 | if (this.intervalId) { 66 | clearInterval(this.intervalId) 67 | this.intervalId = undefined 68 | } 69 | } 70 | 71 | /** 72 | * Cleanup method to be called when the bot is being destroyed 73 | */ 74 | cleanup() { 75 | this.stopPolling() 76 | } 77 | 78 | /** 79 | * Polls provided apiEndpoint with provided headers 80 | */ 81 | async pollApi() { 82 | try { 83 | const response = await axios.get(this.apiEndpoint, { 84 | headers: this.headers, 85 | }) 86 | await this.handleAPIResponse(response.data) 87 | } catch (err) { 88 | console.log(err) 89 | console.warn( 90 | `Error encountered when polling endpoint: ${this.apiEndpoint}` 91 | ) 92 | } 93 | } 94 | 95 | /** 96 | * "Abstact" function each ApiBot must implement 97 | * Parses endpoint response 98 | * @param {*} responseData - Dict parsed from API request json 99 | */ 100 | async handleAPIResponse(responseData: any) { 101 | console.warn('handleAPIResponse function not implemented!', responseData) 102 | } 103 | 104 | /** 105 | * "Abstact" function each ApiBot must implement 106 | * Builds and sends any Discord messages 107 | * @param {*} msg - Event info dict 108 | */ 109 | async buildDiscordMessage(msg: any) { 110 | console.warn('buildDiscordMessage function not implemented!', msg) 111 | } 112 | 113 | async osName(address: string): Promise { 114 | return await getOSName(address) 115 | } 116 | getPlatformUrl( 117 | platform: string, 118 | contractAddress: string, 119 | tokenId: string, 120 | externalUrl: string 121 | ): string { 122 | let platformUrl = externalUrl 123 | if (platform.includes('opensea')) { 124 | platformUrl = buildOpenseaURL(contractAddress, tokenId) 125 | } else if (platform.includes('looksrare')) { 126 | platformUrl = buildLooksRareURL(contractAddress, tokenId) 127 | } else if (platform.includes('x2y2')) { 128 | platformUrl = buildX2Y2URL(contractAddress, tokenId) 129 | } 130 | return platformUrl 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Classes/APIBots/ReservoirListBot.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { Client, EmbedBuilder } from 'discord.js' 3 | import { 4 | BAN_ADDRESSES, 5 | sendEmbedToListChannels, 6 | } from '../../Utils/activityTriager' 7 | import { APIPollBot } from './ApiPollBot' 8 | import { paths } from '@reservoir0x/reservoir-sdk' 9 | import { 10 | LISTING_UTM, 11 | ensOrAddress, 12 | getCollectionType, 13 | getTokenApiUrl, 14 | getTokenUrl, 15 | isEngineContract, 16 | isExplorationsContract, 17 | replaceVideoWithGIF, 18 | } from './utils' 19 | 20 | type ReservoirListing = { 21 | maker: string 22 | id: string 23 | tokenSetId: string 24 | contract?: string 25 | price?: { 26 | currency?: { 27 | symbol?: string 28 | } 29 | amount?: { 30 | decimal?: number 31 | } 32 | } 33 | source?: { 34 | name?: string 35 | domain?: string 36 | url?: string 37 | } 38 | createdAt: string 39 | } 40 | 41 | type ReservoirListResponse = 42 | paths['/orders/asks/v5']['get']['responses']['200']['schema'] 43 | 44 | const IDENTICAL_TOLERANCE = 0.0001 45 | const LISTING_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours 46 | /** API Poller for Reservoir Sale events */ 47 | export class ReservoirListBot extends APIPollBot { 48 | recentListings: { [key: string]: { price: number; timestamp: number } } = {} 49 | /** Constructor just calls super 50 | * @param {string} apiEndpoint - Endpoint to be hitting 51 | * @param {number} refreshRateMs - How often to poll the endpoint (in ms) 52 | * @param {*} bot - Discord bot that will be sending messages 53 | */ 54 | constructor( 55 | apiEndpoint: string, 56 | refreshRateMs: number, 57 | bot: Client, 58 | headers: any 59 | ) { 60 | super(apiEndpoint, refreshRateMs, bot, headers) 61 | this.lastUpdatedTime = Math.floor(this.lastUpdatedTime) 62 | } 63 | 64 | /** 65 | * Parses and handles Reservoir API endpoint data 66 | * Only sends events that are new 67 | * Response spec: https://docs.reservoir.tools/reference/getordersasksv2 68 | * @param {*} responseData - Dict parsed from API request json 69 | */ 70 | async handleAPIResponse(responseData: ReservoirListResponse) { 71 | let maxTime = 0 72 | responseData.orders?.forEach((order) => { 73 | const eventTime = Date.parse(order.createdAt) 74 | // Only deal with event if it is new 75 | if (this.lastUpdatedTime < eventTime) { 76 | this.buildDiscordMessage(order).catch((err) => { 77 | console.error('Error sending listing message', err) 78 | }) 79 | } 80 | 81 | // Save the time of the latest event from this batch 82 | if (maxTime < eventTime) { 83 | maxTime = eventTime 84 | } 85 | }) 86 | 87 | // Update latest time vars if batch has new latest time 88 | if (maxTime > this.lastUpdatedTime) { 89 | this.lastUpdatedTime = maxTime 90 | } 91 | 92 | // Cleanup old listings 93 | this.cleanupOldListings() 94 | } 95 | 96 | /** 97 | * Cleanup listings older than TTL 98 | */ 99 | private cleanupOldListings() { 100 | const now = Date.now() 101 | Object.entries(this.recentListings).forEach(([key, value]) => { 102 | if (now - value.timestamp > LISTING_TTL_MS) { 103 | delete this.recentListings[key] 104 | } 105 | }) 106 | } 107 | 108 | /** 109 | * Handles constructing and sending Discord embed message 110 | * Reservoir API Spec: https://docs.reservoir.tools/reference/getordersasksv2 111 | * @param {*} listing - Dict of event data from API response 112 | */ 113 | async buildDiscordMessage(listing: ReservoirListing) { 114 | // Create embed we will be sending 115 | const embed = new EmbedBuilder() 116 | 117 | // Parsing message to get info 118 | const tokenID = listing.tokenSetId.split(':')[2] 119 | const priceText = 'List Price' 120 | const price = listing.price?.amount?.decimal ?? 0 121 | const currency = listing.price?.currency?.symbol 122 | const owner = listing.maker 123 | let platform = listing.source?.name 124 | 125 | if ( 126 | this.recentListings[tokenID] && 127 | Math.abs(this.recentListings[tokenID].price - price) <= 128 | IDENTICAL_TOLERANCE 129 | ) { 130 | console.log(`Skipping identical relisting for ${tokenID}`) 131 | console.log( 132 | 'recentListings size:', 133 | Object.keys(this.recentListings).length 134 | ) 135 | return 136 | } 137 | this.recentListings[tokenID] = { price, timestamp: Date.now() } 138 | 139 | if (listing.source?.domain?.includes('artblocks')) { 140 | embed.setColor(this.artblocksListColor) 141 | platform = 'Art Blocks <:lilsquig:1028047420636020786>' 142 | } else { 143 | embed.setColor(this.listColor) 144 | } 145 | 146 | if (BAN_ADDRESSES.has(owner)) { 147 | console.log(`Skipping message propagation for ${owner}`) 148 | return 149 | } 150 | 151 | if (listing.source?.name?.toLowerCase().includes('looksrare')) { 152 | console.log(`Skipping message propagation for LooksRare`) 153 | return 154 | } 155 | 156 | const sellerText = await ensOrAddress(listing.maker) 157 | const baseABProfile = 'https://www.artblocks.io/user/' 158 | const sellerProfile = baseABProfile + owner + LISTING_UTM 159 | 160 | embed.addFields( 161 | { 162 | name: `Seller (${platform})`, 163 | value: `[${sellerText}](${sellerProfile})`, 164 | }, 165 | { 166 | name: priceText, 167 | value: `${price} ${currency}`, 168 | inline: true, 169 | } 170 | ) 171 | 172 | // Get Art Blocks metadata response for the item. 173 | const tokenApiUrl = getTokenApiUrl(listing.contract ?? '', tokenID) 174 | const artBlocksResponse = await axios.get(tokenApiUrl) 175 | const artBlocksData = artBlocksResponse?.data 176 | const tokenUrl = getTokenUrl( 177 | artBlocksData.external_url, 178 | listing.contract ?? '', 179 | tokenID 180 | ) 181 | 182 | let curationStatus = artBlocksData?.curation_status 183 | ? artBlocksData.curation_status[0].toUpperCase() + 184 | artBlocksData.curation_status.slice(1).toLowerCase() 185 | : '' 186 | 187 | let title = `${artBlocksData.name} - ${artBlocksData.artist}` 188 | 189 | if (artBlocksData?.platform.includes('Art Blocks x Pace')) { 190 | curationStatus = 'AB x Pace' 191 | } else if (artBlocksData?.platform === 'Art Blocks × Bright Moments') { 192 | curationStatus = 'AB x Bright Moments' 193 | } else if (isExplorationsContract(listing.contract ?? '')) { 194 | curationStatus = 'Explorations' 195 | } else if (isEngineContract(listing.contract ?? '')) { 196 | curationStatus = 'Engine' 197 | if (artBlocksData?.platform) { 198 | title = `${artBlocksData.platform} - ${title}` 199 | } 200 | } 201 | // Update thumbnail image to use larger variant from Art Blocks API. 202 | let assetUrl = artBlocksData?.preview_asset_url 203 | if (assetUrl && !assetUrl.includes('undefined')) { 204 | assetUrl = await replaceVideoWithGIF(assetUrl) 205 | embed.setThumbnail(assetUrl) 206 | } 207 | 208 | embed.addFields( 209 | { 210 | name: `Collection`, 211 | value: `${curationStatus}`, 212 | inline: true, 213 | }, 214 | { 215 | name: 'Live Script', 216 | value: `[view on artblocks.io](${tokenUrl + LISTING_UTM})`, 217 | inline: true, 218 | } 219 | ) 220 | 221 | const platformUrl = listing.source?.url 222 | 223 | embed.setTitle(title) 224 | if (platformUrl) { 225 | embed.setURL(platformUrl + LISTING_UTM) 226 | } 227 | if (artBlocksData.collection_name) { 228 | console.log(artBlocksData.name + ' LIST') 229 | sendEmbedToListChannels( 230 | this.bot, 231 | embed, 232 | artBlocksData, 233 | await getCollectionType(listing.contract ?? '') 234 | ) 235 | } 236 | } 237 | 238 | /** 239 | * Cleanup method to be called when the bot is being destroyed 240 | */ 241 | cleanup() { 242 | super.cleanup() 243 | this.recentListings = {} 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/Classes/APIBots/ReservoirSaleBot.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { Client, EmbedBuilder } from 'discord.js' 3 | import { 4 | BAN_ADDRESSES, 5 | sendEmbedToSaleChannels, 6 | } from '../../Utils/activityTriager' 7 | import { CollectionType } from '../MintBot' 8 | import { APIPollBot } from './ApiPollBot' 9 | import { 10 | getTokenApiUrl, 11 | isExplorationsContract, 12 | isEngineContract, 13 | getCollectionType, 14 | SALE_UTM, 15 | ensOrAddress, 16 | replaceVideoWithGIF, 17 | getTokenUrl, 18 | isStudioContract, 19 | } from './utils' 20 | 21 | type ReservoirSale = { 22 | from: string 23 | to: string 24 | saleId: string 25 | fillSource: string 26 | orderSource: string 27 | orderKind: string 28 | token: { 29 | contract: string 30 | tokenId: string 31 | } 32 | price: { 33 | currency: { 34 | symbol: string 35 | } 36 | amount: { 37 | decimal: number 38 | native: number 39 | } 40 | } 41 | timestamp: number 42 | } 43 | 44 | type ReservoirSaleResponse = { 45 | sales: ReservoirSale[] 46 | continuation: string 47 | } 48 | 49 | /** API Poller for Reservoir Sale events */ 50 | export class ReservoirSaleBot extends APIPollBot { 51 | contract: string 52 | saleIds: Set 53 | /** Constructor just calls super 54 | * @param {string} apiEndpoint - Endpoint to be hitting 55 | * @param {number} refreshRateMs - How often to poll the endpoint (in ms) 56 | * @param {*} bot - Discord bot that will be sending messages 57 | */ 58 | constructor( 59 | apiEndpoint: string, 60 | refreshRateMs: number, 61 | bot: Client, 62 | headers: any, 63 | contract = '' 64 | ) { 65 | apiEndpoint = 66 | apiEndpoint + '&startTimestamp=' + (Date.now() / 1000).toFixed() 67 | super(apiEndpoint, refreshRateMs, bot, headers) 68 | this.contract = contract 69 | this.lastUpdatedTime = Math.floor(this.lastUpdatedTime / 1000) 70 | this.saleIds = new Set() 71 | } 72 | 73 | /** 74 | * Parses and handles Opensea API endpoint data 75 | * Only sends events that are new 76 | * Response spec: https://docs.reservoir.tools/reference/getsalesbulkv1 77 | * @param {*} responseData - Dict parsed from API request json 78 | */ 79 | async handleAPIResponse(responseData: ReservoirSaleResponse) { 80 | let maxTime = 0 81 | const sales: { [id: string]: ReservoirSale[] } = {} 82 | for (const data of responseData.sales) { 83 | const eventTime = data.timestamp 84 | // Only deal with event if it is new and unique saleId 85 | if (this.lastUpdatedTime < eventTime && !this.saleIds.has(data.saleId)) { 86 | this.saleIds.add(data.saleId) 87 | // Only worrying about batch sales messages for Friendship Bracelets 88 | if ( 89 | !isExplorationsContract(data.token.contract) || 90 | parseInt(data.token.tokenId) / 1e6 > 1 // To make sure non-FB explorations aren't batched 91 | ) { 92 | this.buildDiscordMessage(data).catch((err) => { 93 | console.error('Error sending sale message', err) 94 | }) 95 | } else { 96 | // Instantiate array for address if it doesn't exist yet 97 | if (!sales[data.to]) { 98 | sales[data.to] = [] 99 | } 100 | sales[data.to].push(data) 101 | } 102 | } 103 | 104 | // Save the time of the latest event from this batch 105 | if (maxTime < eventTime) { 106 | maxTime = eventTime 107 | } 108 | } 109 | // Handle Explorations sales (may need to batch big sweeps) 110 | Object.keys(sales).forEach((user) => { 111 | if (sales[user].length > 1) { 112 | this.buildSweepDiscordMessage(sales[user]).catch((err) => { 113 | console.error('Error sending batch sale message', err) 114 | }) 115 | } else { 116 | this.buildDiscordMessage(sales[user][0]).catch((err) => { 117 | console.error('Error sending sale message', err) 118 | }) 119 | } 120 | }) 121 | 122 | // Update latest time vars if batch has new latest time 123 | if (maxTime > this.lastUpdatedTime) { 124 | this.lastUpdatedTime = maxTime 125 | 126 | this.apiEndpoint.split('&startTimestamp=')[0] + 127 | '&startTimestamp=' + 128 | this.lastUpdatedTime 129 | } 130 | } 131 | 132 | /** 133 | * Handles constructing and sending Discord embed message 134 | * OS API Spec: https://docs.opensea.io/reference/retrieving-asset-events 135 | * @param {*} sale - Dict of event data from API response 136 | */ 137 | async buildDiscordMessage(sale: ReservoirSale) { 138 | // Create embed we will be sending 139 | const embed = new EmbedBuilder() 140 | // Parsing Reservoir sale message to get info 141 | const tokenID = sale.token.tokenId 142 | 143 | if (sale.orderKind === 'mint') { 144 | return // Don't send mint events 145 | } 146 | const priceText = 'Sale Price' 147 | const price = sale.price.amount.decimal 148 | const currency = sale.price.currency.symbol 149 | const owner = sale.from 150 | let platform = sale.fillSource.toLowerCase() 151 | 152 | if (platform.includes('artblocks')) { 153 | embed.setColor(this.artblocksSaleColor) 154 | platform = 'Art Blocks <:lilsquig:1028047420636020786>' 155 | } else { 156 | embed.setColor(this.saleColor) 157 | } 158 | 159 | if (BAN_ADDRESSES.has(owner)) { 160 | console.log(`Skipping message propagation for ${owner}`) 161 | return 162 | } 163 | if (platform.toLowerCase().includes('looksrare')) { 164 | console.log(`Skipping message propagation for LooksRare`) 165 | return 166 | } 167 | 168 | // Get Art Blocks metadata response for the item. 169 | const tokenApiUrl = getTokenApiUrl(sale.token.contract, tokenID) 170 | const artBlocksResponse = await axios.get(tokenApiUrl) 171 | const artBlocksData = artBlocksResponse?.data 172 | 173 | const tokenUrl = getTokenUrl( 174 | artBlocksData.external_url, 175 | sale.token.contract, 176 | tokenID 177 | ) 178 | 179 | let sellerText = await ensOrAddress(sale.from) 180 | let buyerText = await ensOrAddress(sale.to) 181 | const platformUrl = this.getPlatformUrl( 182 | platform, 183 | sale.token.contract, 184 | sale.token.tokenId, 185 | tokenUrl 186 | ) 187 | 188 | if (platform.includes('opensea')) { 189 | if (!sellerText.includes('.eth')) { 190 | const sellerOS = await this.osName(sale.from) 191 | sellerText = 192 | sellerOS === '' ? sellerText : `${sellerText} (OS: ${sellerOS})` 193 | } 194 | if (!buyerText.includes('.eth')) { 195 | const buyerOS = await this.osName(sale.to) 196 | buyerText = buyerOS === '' ? buyerText : `${buyerText} (OS: ${buyerOS})` 197 | } 198 | } 199 | const baseABProfile = 'https://www.artblocks.io/user/' 200 | const sellerProfile = baseABProfile + owner + SALE_UTM 201 | const buyerProfile = baseABProfile + sale.to + SALE_UTM 202 | embed.addFields( 203 | { 204 | name: `Seller (${platform})`, 205 | value: `[${sellerText}](${sellerProfile})`, 206 | }, 207 | { 208 | name: `Buyer`, 209 | value: `[${buyerText}](${buyerProfile})`, 210 | }, 211 | { 212 | name: priceText, 213 | value: `${price} ${currency}`, 214 | inline: true, 215 | } 216 | ) 217 | 218 | let title = `${artBlocksData.name} - ${artBlocksData.artist}` 219 | 220 | let curationStatus = artBlocksData?.curation_status 221 | ? artBlocksData.curation_status[0].toUpperCase() + 222 | artBlocksData.curation_status.slice(1).toLowerCase() 223 | : '' 224 | 225 | if (artBlocksData?.platform.includes('Art Blocks x Pace')) { 226 | curationStatus = 'AB x Pace' 227 | } else if (artBlocksData?.platform === 'Art Blocks × Bright Moments') { 228 | curationStatus = 'AB x Bright Moments' 229 | } else if (isExplorationsContract(sale.token.contract)) { 230 | curationStatus = 'Explorations' 231 | } else if (isStudioContract(sale.token.contract)) { 232 | curationStatus = 'Studio' 233 | } else if (isEngineContract(sale.token.contract)) { 234 | curationStatus = 'Engine' 235 | if (artBlocksData?.platform) { 236 | title = `${artBlocksData.platform} - ${title}` 237 | } 238 | } 239 | // Update thumbnail image to use larger variant from Art Blocks API. 240 | let assetUrl = artBlocksData?.preview_asset_url 241 | if (assetUrl && !assetUrl.includes('undefined')) { 242 | assetUrl = await replaceVideoWithGIF(assetUrl) 243 | embed.setThumbnail(assetUrl) 244 | } 245 | embed.addFields( 246 | { 247 | name: `Collection`, 248 | value: `${curationStatus}`, 249 | inline: true, 250 | }, 251 | { 252 | name: 'Live Script', 253 | value: `[view on artblocks.io](${tokenUrl + SALE_UTM})`, 254 | inline: true, 255 | } 256 | ) 257 | // Update to remove author name and to reflect this info in piece name 258 | // rather than token number as the title and URL field.. 259 | embed.setTitle(title) 260 | if (platformUrl) { 261 | embed.setURL(platformUrl + SALE_UTM) 262 | } 263 | if (artBlocksData.collection_name) { 264 | console.log(artBlocksData.name + ' SALE') 265 | sendEmbedToSaleChannels( 266 | this.bot, 267 | embed, 268 | artBlocksData, 269 | await getCollectionType(sale.token.contract) 270 | ) 271 | } 272 | } 273 | 274 | async buildSweepDiscordMessage(sales: ReservoirSale[]) { 275 | const sale0 = sales[0] 276 | 277 | if (BAN_ADDRESSES.has(sale0.to)) { 278 | console.log(`Skipping message propagation for ${sale0.to}`) 279 | return 280 | } 281 | // Create embed we will be sending 282 | const embed = new EmbedBuilder() 283 | 284 | const buyerText = await ensOrAddress(sale0.to) 285 | 286 | // Get sale 0 token info for thumbnail, etc 287 | const tokenUrl = getTokenApiUrl(sale0.token.contract, sale0.token.tokenId) 288 | const artBlocksResponse = await axios.get(tokenUrl) 289 | const artBlocksData = artBlocksResponse?.data 290 | let assetUrl = artBlocksData?.preview_asset_url 291 | if (assetUrl && !assetUrl.includes('undefined')) { 292 | assetUrl = await replaceVideoWithGIF(artBlocksData.preview_asset_url) 293 | embed.setThumbnail(assetUrl) 294 | } 295 | 296 | let totalCost = 0 297 | 298 | for (let i = 0; i < sales.length; i++) { 299 | const sale = sales[i] 300 | if (sale.orderKind === 'mint') { 301 | return // Don't send mint events 302 | } 303 | const tokenName = 304 | 'Friendship Bracelets #' + (parseInt(sale.token.tokenId) % 1e6) 305 | const price = sale.price.amount.decimal 306 | const currency = sale.price.currency.symbol 307 | totalCost += sale.price.amount.native 308 | const ab_url = artBlocksData.external_url.replace( 309 | sale0.token.tokenId, 310 | sale.token.tokenId 311 | ) 312 | const sellerText = await ensOrAddress(sale.from) 313 | 314 | const platform = sale.fillSource.toLowerCase() 315 | const platformUrl = this.getPlatformUrl( 316 | platform, 317 | sale.token.contract, 318 | sale.token.tokenId, 319 | ab_url 320 | ) 321 | embed.addFields([ 322 | { 323 | name: `${tokenName}`, 324 | value: `Price: ${price}\t${currency} 325 | Seller: ${sellerText} ([${platform}](${platformUrl})) `, 326 | }, 327 | ]) 328 | } 329 | 330 | embed.setTitle( 331 | `${buyerText} bought ${ 332 | sales.length 333 | } Friendship Bracelets for ${totalCost.toFixed(3)} ETH` 334 | ) 335 | 336 | embed.setColor(this.sweepColor) 337 | 338 | console.log(buyerText + ' FB SWEEP') 339 | sendEmbedToSaleChannels( 340 | this.bot, 341 | embed, 342 | artBlocksData, 343 | CollectionType.EXPLORATIONS 344 | ) 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/Classes/APIBots/utils.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | 3 | import { 4 | ARBITRUM_CONTRACTS, 5 | COLLAB_CONTRACTS, 6 | ENGINE_CONTRACTS, 7 | STUDIO_CONTRACTS, 8 | } from '../../index' 9 | import { CollectionType } from '../MintBot' 10 | import { AxiosError } from 'axios' 11 | dotenv.config() 12 | 13 | const axios = require('axios') 14 | const ethers = require('ethers') 15 | 16 | const provider = new ethers.providers.EtherscanProvider( 17 | 'homestead', 18 | process.env.ETHERSCAN_API_KEY 19 | ) 20 | 21 | const STAGING_CONTRACTS = require('../../ProjectConfig/stagingContracts.json') 22 | const EXPLORATIONS_CONTRACTS = require('../../ProjectConfig/explorationsContracts.json') 23 | 24 | const CORE_CONTRACTS = require('../../ProjectConfig/coreContracts.json') 25 | // Runtime ENS cache just to limit queries 26 | const ensAddressMap: { [id: string]: string } = {} 27 | const ensResolvedMap: { [id: string]: string } = {} 28 | const osAddressMap: { [id: string]: string } = {} 29 | const MAX_ENS_RETRIES = 3 30 | 31 | // UTM for links so we can track traffic that comes through Artbot 32 | const ARTBOT_UTM = 'utm_medium=artbot' 33 | const DISCORD_UTM = `?utm_source=discord&${ARTBOT_UTM}` 34 | const TWITTER_UTM = `?utm_source=twitter&${ARTBOT_UTM}` 35 | export const LISTING_UTM = `${DISCORD_UTM}&utm_campaign=listing` 36 | export const SALE_UTM = `${DISCORD_UTM}&utm_campaign=sale` 37 | export const MINT_UTM = `${DISCORD_UTM}&utm_campaign=mint` 38 | export const PROJECTBOT_UTM = `${DISCORD_UTM}&utm_campaign=projectbot` 39 | export const PROJECTBOT_BUY_UTM = `${DISCORD_UTM}&utm_campaign=projectbot_buy` 40 | export const PROJECTBOT_EXPLORE_UTM = `${DISCORD_UTM}&utm_campaign=projectbot_explore` 41 | export const TWITTER_PROJECTBOT_UTM = `${TWITTER_UTM}&utm_campaign=projectbot` 42 | 43 | async function getENSName(address: string): Promise { 44 | let name = '' 45 | if (ensAddressMap[address]) { 46 | name = ensAddressMap[address] 47 | } else { 48 | let ens = '' 49 | let retries = 0 50 | while (ens === '' && retries < MAX_ENS_RETRIES) { 51 | try { 52 | ens = await provider.lookupAddress(address) 53 | } catch (err) { 54 | retries++ 55 | console.warn(`ENS lookup error on ${address}`, err) 56 | } 57 | } 58 | 59 | name = ens ?? '' 60 | ensAddressMap[address] = name 61 | ensResolvedMap[name] = address 62 | } 63 | return name 64 | } 65 | 66 | export async function resolveEnsName(ensName: string): Promise { 67 | let wallet = '' 68 | if (ensResolvedMap[ensName]) { 69 | wallet = ensResolvedMap[ensName] 70 | } else { 71 | let retries = 0 72 | 73 | while (wallet === '' && retries < MAX_ENS_RETRIES) { 74 | try { 75 | wallet = await provider.resolveName(ensName) 76 | } catch (err) { 77 | retries++ 78 | console.warn(`ENS resolve error on ${ensName}`, err) 79 | } 80 | } 81 | 82 | if (wallet !== '') { 83 | ensResolvedMap[ensName] = wallet 84 | } 85 | } 86 | return wallet 87 | } 88 | 89 | export async function ensOrAddress(address: string): Promise { 90 | const ens = await getENSName(address) 91 | return ens !== '' ? ens : address 92 | } 93 | 94 | export async function getOSName(address: string): Promise { 95 | let name = '' 96 | if (osAddressMap[address]) { 97 | console.log('Cached!') 98 | name = osAddressMap[address] 99 | } else { 100 | try { 101 | const response = await axios.get( 102 | `https://api.opensea.io/api/v2/accounts/${address}`, 103 | { 104 | headers: { 105 | Accept: 'application/json', 106 | 'X-API-KEY': process.env.OPENSEA_API_KEY, 107 | }, 108 | } 109 | ) 110 | const responseBody = response?.data 111 | if (responseBody?.detail) { 112 | throw new Error(responseBody.detail) 113 | } 114 | name = responseBody?.username ?? '' 115 | osAddressMap[address] = name 116 | } catch (err) { 117 | // Probably rate limited - return empty sting but don't cache 118 | name = '' 119 | console.log(err) 120 | console.log("Error getting user's OpenSea name") 121 | } 122 | } 123 | 124 | return name 125 | } 126 | 127 | export function isWallet(msg: string): boolean { 128 | return !!msg.match(/(0x[a-fA-F0-9]{40})|([a-zA-Z0-9.-]+\.eth)/g) 129 | } 130 | 131 | const acceptedVerticals = [ 132 | 'curated', 133 | 'collabs', 134 | 'collaborations', 135 | 'explorations', 136 | 'engine', 137 | 'presents', 138 | ] 139 | export function isVerticalName(msg: string): boolean { 140 | return acceptedVerticals.includes(msg) 141 | } 142 | export function getVerticalName(msg: string): string { 143 | switch (msg) { 144 | case 'collabs': 145 | return 'collaborations' 146 | default: 147 | return msg 148 | } 149 | } 150 | 151 | export function getTokenApiUrl( 152 | contractAddress: string, 153 | tokenId: string 154 | ): string { 155 | contractAddress = contractAddress.toLowerCase() 156 | if ( 157 | Object.values(CORE_CONTRACTS).includes(contractAddress) || 158 | contractAddress === '' 159 | ) { 160 | return `https://token.artblocks.io/${tokenId}` 161 | } else if (Object.values(STAGING_CONTRACTS).includes(contractAddress)) { 162 | return `https://token.staging.artblocks.io/${contractAddress}/${tokenId}` 163 | } else if (isArbitrumContract(contractAddress)) { 164 | return `https://token.arbitrum.artblocks.io/${contractAddress}/${tokenId}` 165 | } else { 166 | return `https://token.artblocks.io/${contractAddress}/${tokenId}` 167 | } 168 | } 169 | 170 | export function isExplorationsContract(contractAddress: string): boolean { 171 | return Object.values(EXPLORATIONS_CONTRACTS).includes( 172 | contractAddress.toLowerCase() 173 | ) 174 | } 175 | 176 | export function isStudioContract(contractAddress: string): boolean { 177 | return STUDIO_CONTRACTS.includes(contractAddress.toLowerCase()) 178 | } 179 | 180 | export function isEngineContract(contractAddress: string): boolean { 181 | return ENGINE_CONTRACTS.includes(contractAddress.toLowerCase()) 182 | } 183 | 184 | export function isArbitrumContract(contractAddress: string): boolean { 185 | return ARBITRUM_CONTRACTS.includes(contractAddress.toLowerCase()) 186 | } 187 | 188 | export function isCoreContract(contractAddress: string): boolean { 189 | return Object.values(CORE_CONTRACTS).includes(contractAddress.toLowerCase()) 190 | } 191 | 192 | export async function getCollectionType( 193 | contractAddress: string 194 | ): Promise { 195 | if (isExplorationsContract(contractAddress)) { 196 | return CollectionType.EXPLORATIONS 197 | } else if ( 198 | Object.values(CORE_CONTRACTS).includes(contractAddress.toLowerCase()) 199 | ) { 200 | return CollectionType.CORE 201 | } else if ( 202 | Object.values(COLLAB_CONTRACTS).includes(contractAddress.toLowerCase()) 203 | ) { 204 | return CollectionType.COLLAB 205 | } else if (isEngineContract(contractAddress)) { 206 | return CollectionType.ENGINE 207 | } else if (isStudioContract(contractAddress)) { 208 | return CollectionType.STUDIO 209 | } 210 | 211 | throw new Error('Unknown collection type') 212 | } 213 | 214 | export function getTokenUrl( 215 | external_url: string, 216 | contractAddr: string, 217 | tokenId: string 218 | ): string { 219 | if (external_url && !external_url.includes('generator.artblocks.io')) { 220 | return external_url 221 | } 222 | return buildArtBlocksTokenURL(contractAddr, tokenId) 223 | } 224 | 225 | export function getProjectUrl(contractAddr: string, projectId: string): string { 226 | return `https://www.artblocks.io/collections/${contractAddr}-${projectId}` 227 | } 228 | 229 | function buildArtBlocksTokenURL(contractAddr: string, tokenId: string): string { 230 | return `https://www.artblocks.io/token/${contractAddr}/${tokenId}` 231 | } 232 | 233 | export function buildOpenseaURL(contractAddr: string, tokenId: string): string { 234 | return `https://opensea.io/assets/ethereum/${contractAddr}/${tokenId}` 235 | } 236 | export function buildLooksRareURL( 237 | contractAddr: string, 238 | tokenId: string 239 | ): string { 240 | return `https://looksrare.org/collections/${contractAddr}/${tokenId}` 241 | } 242 | export function buildX2Y2URL(contractAddr: string, tokenId: string): string { 243 | return `https://x2y2.io/eth/${contractAddr}/${tokenId}` 244 | } 245 | 246 | export function timeout( 247 | timeoutMs: number, 248 | failureMessage: string 249 | ): Promise { 250 | return new Promise((resolve, reject) => { 251 | setTimeout(() => reject(failureMessage), timeoutMs) 252 | }) 253 | } 254 | 255 | export function replaceToPNG(url: string): string { 256 | return url.replace(/\.(gif|mp4)$/i, '.png') 257 | } 258 | 259 | // defaulting our discord embeds to always send GIFs 260 | export async function replaceVideoWithGIF(url: string) { 261 | if (url.includes('mp4')) { 262 | const gifURL = url.replace('mp4', 'gif') 263 | 264 | // some GIFs are not available, so we fallback to PNG 265 | try { 266 | const resp = await axios.get(gifURL) 267 | 268 | if (resp.headers['content-length'] === '0') { 269 | throw new Error('GIF size 0 for ' + gifURL) 270 | } 271 | } catch (e) { 272 | const axiosError = e as AxiosError 273 | if (axiosError && e.response?.status === 404) { 274 | console.log('GIF not found, returning PNG') 275 | } 276 | console.log(`Error on fetching token API for ${gifURL}`, e) 277 | return url.replace('mp4', 'png') 278 | } 279 | 280 | return gifURL 281 | } 282 | 283 | return url 284 | } 285 | 286 | export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) 287 | export const waitForStudioContracts = async (): Promise => { 288 | while (STUDIO_CONTRACTS.length === 0) { 289 | console.log('Waiting for studio contracts to load...') 290 | await delay(4000) 291 | } 292 | console.log('studio contracts loaded') 293 | return STUDIO_CONTRACTS 294 | } 295 | export const waitForEngineContracts = async (): Promise => { 296 | while (ENGINE_CONTRACTS.length === 0) { 297 | console.log('Waiting for engine contracts to load...') 298 | await delay(5000) 299 | } 300 | console.log('Engine contracts loaded') 301 | return ENGINE_CONTRACTS 302 | } 303 | 304 | export const ethFromWeiString = (wei: string): string => { 305 | return `${parseInt(wei) / 1e18}` 306 | } 307 | -------------------------------------------------------------------------------- /src/Classes/InsightsBot.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | import { EmbedBuilder, Message } from 'discord.js' 4 | import axios from 'axios' 5 | import { randomColor } from '../Utils/smartBotResponse' 6 | 7 | export class InsightsBot { 8 | async getInsightsApiResponse(msg: Message): Promise { 9 | try { 10 | // strip out !artBot from the message 11 | const messageContent = msg.content.replace('!artBot', '').trim() 12 | 13 | const insightsResponse = await axios.post( 14 | 'https://qhjte7logh.execute-api.us-east-1.amazonaws.com/production-stage/insights', 15 | { 16 | query: messageContent, 17 | }, 18 | { 19 | headers: { 20 | 'x-api-key': process.env.INSIGHTS_API_KEY ?? '', 21 | 'Content-Type': 'application/json', 22 | }, 23 | } 24 | ) 25 | 26 | const answer = insightsResponse?.data?.[0]?.content ?? '' 27 | 28 | if (!answer.length) { 29 | throw new Error('No answer from Insights API') 30 | } 31 | 32 | const answerWithFeedback = `${answer}\n\nThis response is AI-generated. Let us know what you think in <#769251416778604562>!` 33 | 34 | const embed = new EmbedBuilder() 35 | .setTitle('Artbot AI (Beta)') 36 | .setColor(randomColor()) 37 | .setDescription(answerWithFeedback) 38 | 39 | return embed 40 | } catch (error) { 41 | console.error('Error getting insights API response:', error) 42 | return new EmbedBuilder() 43 | .setTitle('Artbot AI (Beta)') 44 | .setColor('#FF0000') 45 | .setDescription( 46 | "Sorry, I'm not sure how to answer that. Please check out the [Art Blocks website](https://www.artblocks.io)." 47 | ) 48 | .setFooter({ 49 | text: 'If this persists, please contact the bot administrator.', 50 | }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Classes/MintBot.ts: -------------------------------------------------------------------------------- 1 | import { Client, EmbedBuilder, TextChannel } from 'discord.js' 2 | import { artIndexerBot, mintBot, projectConfig } from '../index' 3 | import axios from 'axios' 4 | import { 5 | MINT_UTM, 6 | getCollectionType, 7 | getTokenApiUrl, 8 | getTokenUrl, 9 | replaceToPNG, 10 | waitForEngineContracts, 11 | waitForStudioContracts, 12 | } from './APIBots/utils' 13 | import { ensOrAddress } from './APIBots/utils' 14 | import { TwitterBot } from './TwitterBot' 15 | 16 | const MINT_CONFIG: { 17 | [id: string]: string[] 18 | } = require('../ProjectConfig/mintBotConfig.json') 19 | const CORE_CONTRACTS = require('../ProjectConfig/coreContracts.json') 20 | const COLLAB_CONTRACTS = require('../ProjectConfig/collaborationContracts.json') 21 | const STAGING_CONTRACTS = require('../ProjectConfig/stagingContracts.json') 22 | const EXPLORATIONS_CONTRACTS = require('../ProjectConfig/explorationsContracts.json') 23 | const PARTNER_CONTRACTS = require('../ProjectConfig/partnerContracts.json') 24 | 25 | const MINT_REFRESH_TIME_SECONDS = process.env.MINT_REFRESH_TIME_SECONDS ?? '60' 26 | 27 | export enum CollectionType { 28 | CORE = 'CORE', 29 | EXPLORATIONS = 'EXPLORATIONS', 30 | COLLAB = 'COLLAB', 31 | ENGINE = 'ENGINE', 32 | STAGING = 'STAGING', 33 | STUDIO = 'STUDIO', 34 | } 35 | 36 | // Handles all logic and posting of new project mints! 37 | export class MintBot { 38 | bot: Client 39 | abTwitterBot?: TwitterBot 40 | newMints: { [id: string]: Mint } = {} 41 | intervalId?: NodeJS.Timeout 42 | mintsToPost: { [id: string]: Mint } = {} 43 | contractToChannel: { [id: string]: string[] } = {} 44 | contractToTwitterBot: { [id: string]: TwitterBot } = {} 45 | constructor(bot: Client) { 46 | this.bot = bot 47 | this.buildContractToChannel() 48 | this.startRoutine() 49 | 50 | if (process.env.PRODUCTION_MODE) { 51 | if (process.env.AB_TWITTER_API_KEY) { 52 | this.abTwitterBot = new TwitterBot({ 53 | appKey: process.env.AB_TWITTER_API_KEY ?? '', 54 | appSecret: process.env.AB_TWITTER_API_SECRET ?? '', 55 | accessToken: process.env.AB_TWITTER_OAUTH_TOKEN ?? '', 56 | accessSecret: process.env.AB_TWITTER_OAUTH_SECRET ?? '', 57 | listener: true, 58 | }) 59 | } 60 | } 61 | } 62 | 63 | async buildContractToChannel() { 64 | const contractToChannel: { [id: string]: string[] } = {} 65 | const engineContracts = await waitForEngineContracts() 66 | const studioContracts = await waitForStudioContracts() 67 | Object.entries(MINT_CONFIG).forEach(([mintType, channels]) => { 68 | let contracts: string[] = [] 69 | switch (mintType) { 70 | case CollectionType.CORE: 71 | contracts = Object.values(CORE_CONTRACTS) 72 | break 73 | case CollectionType.EXPLORATIONS: 74 | contracts = Object.values(EXPLORATIONS_CONTRACTS) 75 | break 76 | case CollectionType.COLLAB: 77 | contracts = Object.values(COLLAB_CONTRACTS) 78 | break 79 | case CollectionType.ENGINE: 80 | contracts = engineContracts ?? [] 81 | break 82 | case CollectionType.STUDIO: 83 | contracts = studioContracts ?? [] 84 | break 85 | case CollectionType.STAGING: 86 | contracts = Object.values(STAGING_CONTRACTS) 87 | break 88 | default: 89 | // Non-MintTypes are partner contracts that forward to other discord servers/channels 90 | contracts = PARTNER_CONTRACTS[mintType] 91 | if (typeof contracts === 'string') { 92 | contracts = [contracts] 93 | } 94 | break 95 | } 96 | channels.forEach((channel) => { 97 | contracts.forEach((contract) => { 98 | if (!contractToChannel[contract]) { 99 | contractToChannel[contract] = [] 100 | } 101 | contractToChannel[contract].push(channel) 102 | }) 103 | }) 104 | }) 105 | 106 | this.contractToChannel = contractToChannel 107 | } 108 | 109 | // Go through all mints in the queue and make sure the image exists 110 | // If it does, report to discord and remove from the queue 111 | // If it doesn't, add it back in the queue to check again later 112 | async checkAndPostMints() { 113 | if (Object.keys(this.mintsToPost).length === 0) { 114 | return 115 | } 116 | 117 | try { 118 | await Promise.all( 119 | Object.entries(this.mintsToPost).map(async ([id, mint]) => { 120 | try { 121 | const tokenUrl = getTokenApiUrl(mint.contractAddress, mint.tokenId) 122 | const artBlocksResponse = await axios.get(tokenUrl) 123 | 124 | const artBlocksData = artBlocksResponse.data 125 | let assetUrl = artBlocksData?.preview_asset_url 126 | if (!assetUrl) { 127 | console.log(`No preview asset URL for mint ${id}`) 128 | return 129 | } 130 | 131 | assetUrl = replaceToPNG(assetUrl) 132 | 133 | // Validate URL format 134 | try { 135 | new URL(assetUrl) 136 | } catch (e) { 137 | console.log(`Invalid asset URL for mint ${id}: ${assetUrl}`) 138 | return 139 | } 140 | 141 | // Check image with timeout and content-type validation 142 | try { 143 | const imageRes = await axios.get(assetUrl, { 144 | timeout: 10000, // 10 second timeout 145 | validateStatus: (status) => status === 200, 146 | headers: { Accept: 'image/*' }, 147 | }) 148 | 149 | const contentType = imageRes.headers['content-type'] 150 | if (!contentType?.startsWith('image/')) { 151 | console.log( 152 | `Invalid content type for mint ${id}: ${contentType}` 153 | ) 154 | return 155 | } 156 | 157 | // If we get here, the image is valid 158 | delete this.mintsToPost[id] 159 | mint.image = assetUrl 160 | mint.generatorLink = artBlocksData.generator_url 161 | mint.tokenName = artBlocksData.name 162 | mint.artistName = artBlocksData.artist 163 | mint.artblocksUrl = getTokenUrl( 164 | artBlocksData.external_url, 165 | mint.contractAddress, 166 | mint.tokenId 167 | ) 168 | await mint.postToDiscord() 169 | await this.postToTwitter(mint) 170 | } catch (e) { 171 | console.log(`Error fetching image for mint ${id}:`, e) 172 | // Keep in queue to retry later 173 | return 174 | } 175 | } catch (e) { 176 | console.log(`Error processing mint ${id}:`, e) 177 | return 178 | } 179 | }) 180 | ) 181 | } catch (e) { 182 | console.error('Error in checkAndPostMints:', e) 183 | } 184 | } 185 | 186 | // Function to add a new mint to the queue! 187 | addMint( 188 | contractAddress: string, 189 | tokenID: string, 190 | owner: string, 191 | invocation: string, 192 | projectId: string 193 | ) { 194 | console.log('NEW MINT', contractAddress, tokenID, owner) 195 | const id = `${contractAddress}-${tokenID}` 196 | 197 | if (parseInt(tokenID) % 1e6 === 0) { 198 | console.log('Skipping mint #0') 199 | return 200 | } 201 | 202 | if (!this.contractToChannel[contractAddress]) { 203 | console.log('Skipping mint for contract not in config') 204 | return 205 | } 206 | 207 | artIndexerBot.checkMintedOut(projectId, invocation) 208 | 209 | this.mintsToPost[id] = new Mint(this.bot, contractAddress, tokenID, owner) 210 | } 211 | 212 | // Routine that runs every MINT_REFRESH_TIME_SECONDS seconds and 213 | // tries to report any new mints to the discord! 214 | startRoutine() { 215 | this.intervalId = setInterval(async () => { 216 | if (Object.keys(this.mintsToPost).length > 0) { 217 | console.log(`${Object.keys(this.mintsToPost).length} mints to post`) 218 | } 219 | await this.checkAndPostMints() 220 | }, parseInt(MINT_REFRESH_TIME_SECONDS) * 1000) 221 | } 222 | 223 | async postToTwitter(mint: Mint) { 224 | const collectionType = await getCollectionType(mint.contractAddress) 225 | if ( 226 | collectionType !== CollectionType.ENGINE && 227 | collectionType !== CollectionType.STAGING 228 | ) { 229 | // Turned off for now to avoid rate limiting 230 | // this.abTwitterBot?.sendToTwitter(mint) 231 | } 232 | if (this.contractToTwitterBot[mint.contractAddress]) { 233 | this.contractToTwitterBot[mint.contractAddress].sendToTwitter(mint) 234 | } 235 | } 236 | 237 | /** 238 | * Cleanup method to be called when the bot is being destroyed 239 | */ 240 | cleanup() { 241 | if (this.intervalId) { 242 | clearInterval(this.intervalId) 243 | this.intervalId = undefined 244 | } 245 | // Clear any pending mints 246 | this.mintsToPost = {} 247 | } 248 | } 249 | 250 | export class Mint { 251 | bot: Client 252 | contractAddress: string 253 | tokenId: string 254 | owner: string 255 | image: string 256 | generatorLink: string 257 | tokenName: string 258 | artistName: string 259 | artblocksUrl: string 260 | constructor( 261 | bot: Client, 262 | contractAddress: string, 263 | tokenId: string, 264 | owner: string 265 | ) { 266 | this.bot = bot 267 | this.contractAddress = contractAddress 268 | this.tokenId = tokenId 269 | this.owner = owner 270 | this.image = '' 271 | this.generatorLink = '' 272 | this.tokenName = '' 273 | this.artistName = '' 274 | this.artblocksUrl = '' 275 | } 276 | 277 | async postToDiscord() { 278 | // Create embed we will be sending 279 | const embed = new EmbedBuilder() 280 | const ownerText = await ensOrAddress(this.owner) 281 | 282 | const baseABProfile = 'https://www.artblocks.io/user/' 283 | const ownerProfile = baseABProfile + this.owner + MINT_UTM 284 | 285 | embed.setTitle(`Minted: ${this.tokenName} - ${this.artistName}`) 286 | if (this.artblocksUrl) { 287 | embed.setURL(this.artblocksUrl + MINT_UTM) 288 | } 289 | embed.setImage(this.image) 290 | embed.setColor('#c9fdc9') 291 | 292 | embed.addFields( 293 | { 294 | name: 'Minted by', 295 | value: `[${ownerText}](${ownerProfile})`, 296 | inline: true, 297 | }, 298 | { 299 | name: 'Live Script', 300 | value: `[Generator](${this.generatorLink + MINT_UTM})`, 301 | inline: true, 302 | } 303 | ) 304 | 305 | mintBot.contractToChannel[this.contractAddress].forEach( 306 | (channel: string) => { 307 | const discordChannel = this.bot.channels?.cache.get( 308 | projectConfig.chIdByName[channel] 309 | ) as TextChannel 310 | if (discordChannel) { 311 | discordChannel.send({ embeds: [embed] }) 312 | } 313 | } 314 | ) 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/Classes/ProjectHandlerHelper.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js' 2 | 3 | export class ProjectHandlerHelper { 4 | singles?: { [id: string]: string } 5 | sets?: { [id: string]: number[] } 6 | constructor( 7 | singles?: { [id: string]: string }, 8 | sets?: { [id: string]: number[] } 9 | ) { 10 | this.singles = singles 11 | this.sets = sets 12 | } 13 | 14 | listMappings() { 15 | const message = new EmbedBuilder() 16 | // Set the title of the field. 17 | .setTitle('Available Named Pieces / Sets') 18 | .setDescription( 19 | 'These are special tokens or sets of tokens that have been given a name by the community! Try them out here with `#`' 20 | ) 21 | 22 | let singles = '' 23 | let sets = '' 24 | if (this.singles) { 25 | for (const [singleName] of Object.entries(this.singles)) { 26 | singles += `${singleName} 27 | ` 28 | } 29 | message.addFields({ name: 'Tokens', value: singles }) 30 | } 31 | 32 | if (this.sets) { 33 | for (const [setName] of Object.entries(this.sets)) { 34 | sets += `${setName} 35 | ` 36 | } 37 | message.addFields({ name: 'Sets', value: sets }) 38 | } 39 | 40 | if (!singles && !sets) { 41 | message.addFields({ 42 | name: 'No named tokens or sets!', 43 | value: 44 | "I don't have any named tokens or sets for this project yet! [You can propose some here](https://github.com/ArtBlocks/artbot/issues/new/choose)", 45 | }) 46 | } 47 | 48 | return message 49 | } 50 | 51 | transform(messageContent: string) { 52 | return ( 53 | (this.singles && this._singlesTransform(messageContent)) || 54 | (this.sets && this._setsTransform(messageContent)) || 55 | messageContent 56 | ) 57 | } 58 | 59 | _singlesTransform(messageContent: string) { 60 | if (messageContent.length <= 1 || !this.singles) { 61 | return null 62 | } 63 | 64 | const afterTheHash = messageContent.substring(1) 65 | const singleKeyString = afterTheHash.split(' ')[0] 66 | if (singleKeyString === null || !singleKeyString) { 67 | return null 68 | } 69 | 70 | const singleKeyStringLowercase = singleKeyString.toLowerCase() 71 | if (!this.singles[singleKeyStringLowercase]) { 72 | return null 73 | } 74 | return `#${this.singles[singleKeyStringLowercase]}` 75 | } 76 | 77 | _setsTransform(messageContent: string) { 78 | if (messageContent.length <= 1 || !this.sets) { 79 | return null 80 | } 81 | 82 | const afterTheHash = messageContent.substring(1) 83 | 84 | const setKeyString = afterTheHash.split(' ')[0] 85 | if (setKeyString === null || !setKeyString) { 86 | return null 87 | } 88 | 89 | const setKeyStringLowercase = setKeyString.toLowerCase() 90 | if (!this.sets[setKeyStringLowercase]) { 91 | return null 92 | } 93 | 94 | const setItems = this.sets[setKeyStringLowercase] 95 | const randomSetItem = setItems[Math.floor(Math.random() * setItems.length)] 96 | return `#${randomSetItem}` 97 | } 98 | 99 | hasNamed() { 100 | return !!this.singles || !!this.sets 101 | } 102 | } 103 | 104 | module.exports.ProjectHandlerHelper = ProjectHandlerHelper 105 | -------------------------------------------------------------------------------- /src/Classes/SchedulerBot.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | import { Channel, Collection } from 'discord.js' 4 | import { ProjectConfig } from '../ProjectConfig/projectConfig' 5 | import { artIndexerBot } from '..' 6 | import { delay } from './APIBots/utils' 7 | 8 | import { Cron } from 'croner' 9 | 10 | // Time to wait for bot to connect and channels to load 11 | const INIT_DELAY = 8000 12 | export class ScheduleBot { 13 | channels: Collection 14 | projectConfig: ProjectConfig 15 | constructor( 16 | channels: Collection, 17 | projectConfig: ProjectConfig 18 | ) { 19 | this.channels = channels 20 | this.projectConfig = projectConfig 21 | this.initialize() 22 | } 23 | 24 | async initialize() { 25 | await delay(INIT_DELAY) 26 | console.log('Starting Scheduler...') 27 | Cron( 28 | '00 1,9,17 * * *', 29 | { timezone: 'America/Chicago', name: 'Bday' }, 30 | () => { 31 | console.log('Birthday Time!') 32 | const now = new Date() 33 | const hour = now.toLocaleString('en-US', { 34 | timeZone: 'America/Chicago', 35 | hour: 'numeric', 36 | }) 37 | artIndexerBot.checkBirthdays( 38 | this.channels, 39 | this.projectConfig, 40 | hour.includes('9') // Only post in artist channels at 9am runtime 41 | ) 42 | } 43 | ) 44 | 45 | const triviaCadence = parseInt(process.env.TRIVIA_CADENCE ?? '0') 46 | 47 | if (triviaCadence > 0) { 48 | Cron( 49 | `0 */${triviaCadence} * * *`, 50 | { timezone: 'America/Chicago', name: 'Trivia' }, 51 | async () => { 52 | const wait = Math.random() * 1000 * 60 * 60 * triviaCadence 53 | console.log(`Waiting ${wait / 60000} mins for trivia`) 54 | await delay(wait) 55 | 56 | if (isTriviaBlackoutTime()) { 57 | console.log('Skipping Trivia during blackout times :(') 58 | return 59 | } 60 | 61 | console.log('Trivia Time!') 62 | artIndexerBot.askRandomTriviaQuestion() 63 | } 64 | ) 65 | } 66 | } 67 | } 68 | 69 | // Don't want to send trivia messages while flagship releases are happening 70 | // Current blackout times are: Monday 11-2pm CT, Wednesday 11-5pm CT 71 | const isTriviaBlackoutTime = () => { 72 | const now = new Date() 73 | const weekday = now.toLocaleString('en-US', { 74 | timeZone: 'America/Chicago', 75 | weekday: 'long', 76 | }) 77 | const hourText = now.toLocaleString('en-US', { 78 | timeZone: 'America/Chicago', 79 | hour: 'numeric', 80 | hour12: false, 81 | }) 82 | const hour = parseInt(hourText) 83 | 84 | return ( 85 | (weekday.includes('Monday') && hour > 11 && hour < 14) || 86 | (weekday.includes('Wednesday') && hour > 11 && hour < 17) 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/Classes/TwitterBot.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | import { 4 | ApiResponseError, 5 | EUploadMimeType, 6 | TweetV2, 7 | TwitterApi, 8 | } from 'twitter-api-v2' 9 | import { Mint } from './MintBot' 10 | import { 11 | TWITTER_PROJECTBOT_UTM, 12 | delay, 13 | ensOrAddress, 14 | getTokenApiUrl, 15 | getTokenUrl, 16 | } from './APIBots/utils' 17 | import axios from 'axios' 18 | import { artIndexerBot } from '..' 19 | import sharp from 'sharp' 20 | import { 21 | getLastTweetId, 22 | getStatusRefreshToken, 23 | updateLastTweetId, 24 | updateStatusRefreshToken, 25 | } from '../Data/supabase' 26 | 27 | const TWITTER_MEDIA_BYTE_LIMIT = 5242880 28 | // Search rate limit is 60 queries per 15 minutes - shortest interval is 15 secs (though we should keep it a bit longer) 29 | const SEARCH_INTERVAL_MS = 20000 30 | const NUM_RETRIES = 5 31 | const RETRY_DELAY_MS = 5000 32 | const prod = process.env.ARTBOT_IS_PROD 33 | ? process.env.ARTBOT_IS_PROD.toLowerCase() === 'true' 34 | : false 35 | 36 | const ARTBOT_TWITTER_HANDLE = 'artbotartbot' 37 | const STATUS_TWITTER_HANDLE = 'ArtbotStatus' 38 | 39 | export class TwitterBot { 40 | twitterClient: TwitterApi 41 | twitterStatusAccount?: TwitterApi 42 | lastTweetId: string 43 | intervalId?: NodeJS.Timeout 44 | 45 | constructor({ 46 | appKey, 47 | appSecret, 48 | accessToken, 49 | accessSecret, 50 | listener, 51 | }: { 52 | appKey: string 53 | appSecret: string 54 | accessToken: string 55 | accessSecret: string 56 | listener?: boolean 57 | }) { 58 | this.lastTweetId = '' 59 | if (listener && process.env.TWITTER_ENABLED === 'true') { 60 | console.log('Starting Twitter listener') 61 | this.startSearchAndReplyRoutine() 62 | } 63 | this.twitterClient = new TwitterApi({ 64 | appKey, 65 | appSecret, 66 | accessToken, 67 | accessSecret, 68 | }) 69 | } 70 | 71 | async startSearchAndReplyRoutine() { 72 | try { 73 | this.lastTweetId = await getLastTweetId(prod) 74 | } catch (e) { 75 | console.error('Error getting last tweet id:', e) 76 | console.log('Aborting Twitter listener') 77 | return 78 | } 79 | 80 | this.intervalId = setInterval(() => { 81 | this.search() 82 | }, SEARCH_INTERVAL_MS) 83 | } 84 | 85 | /** 86 | * Cleanup method to be called when the bot is being destroyed 87 | */ 88 | cleanup() { 89 | if (this.intervalId) { 90 | clearInterval(this.intervalId) 91 | this.intervalId = undefined 92 | } 93 | } 94 | 95 | async search() { 96 | let artbotTweets: any 97 | try { 98 | // Query breakdown: 99 | // to:${ARTBOT_TWITTER_HANDLE} = Original tweets that start with @artbotartbot or direct replies to @artbotartbot tweets 100 | // @${ARTBOT_TWITTER_HANDLE} = Mentions artbotartbot 101 | 102 | const query = `(to:${ARTBOT_TWITTER_HANDLE} OR @${ARTBOT_TWITTER_HANDLE}) -is:retweet -has:links has:mentions -from:${STATUS_TWITTER_HANDLE} -from:${ARTBOT_TWITTER_HANDLE}` 103 | const devQuery = `to:ArtbotTesting from:ArtbotTesting` 104 | artbotTweets = await this.twitterClient.v2.search({ 105 | query: prod ? query : devQuery, 106 | since_id: this.lastTweetId, 107 | }) 108 | } catch (error) { 109 | if ( 110 | error instanceof ApiResponseError && 111 | error.rateLimitError && 112 | error.rateLimit 113 | ) { 114 | console.log( 115 | `Search rate limit hit! Limit will reset at timestamp ${error.rateLimit.reset}` 116 | ) 117 | } else if ( 118 | error?.code === 400 && 119 | error.errors[0] && 120 | error.errors[0]?.message && 121 | error.errors[0]?.message.includes('since_id') 122 | ) { 123 | const messageSplit = error.errors[0]?.message.split(' ') 124 | const lastId = BigInt(messageSplit[messageSplit.length - 1]) 125 | const adjustedLastId = (lastId + BigInt('100000000000')).toString() 126 | console.log( 127 | 'TwitterBot since_id is invalid - setting to', 128 | adjustedLastId 129 | ) 130 | this.lastTweetId = adjustedLastId 131 | } else { 132 | console.error('Error searching Twitter:', error) 133 | } 134 | return 135 | } 136 | 137 | console.log('artbotTweets', artbotTweets?.meta?.result_count) 138 | 139 | if (artbotTweets.meta.result_count === 0) { 140 | console.log('No new tweets found') 141 | return 142 | } 143 | 144 | for await (const tweet of artbotTweets) { 145 | try { 146 | this.replyToTweet(tweet) 147 | } catch (e) { 148 | console.error(`Error responding to ${tweet.text}:`, e) 149 | } 150 | } 151 | this.updateLastTweetId(artbotTweets.meta.newest_id) 152 | } 153 | async updateLastTweetId(tweetId: string) { 154 | if (tweetId === this.lastTweetId) { 155 | return 156 | } 157 | this.lastTweetId = tweetId 158 | await updateLastTweetId(tweetId, prod) 159 | } 160 | async replyToTweet(tweet: TweetV2) { 161 | const cleanedTweet = tweet.text 162 | .replaceAll(/@\w+/g, '') // Remove all mentions 163 | .match(/#(\?|\d*).+/g)?.[0] // Fetch the first hashtag and everything after it (until a newline) 164 | ?.trim() 165 | if (!cleanedTweet) { 166 | console.warn(`Tweet '${tweet.text}' is not a supported action`) 167 | return 168 | } 169 | console.log(`Handling tweet ${tweet.id}: ${cleanedTweet}`) 170 | 171 | let projectBot 172 | let tokenId 173 | try { 174 | const { projectBot: p, tokenId: t } = 175 | await artIndexerBot.handleNumberTweet(cleanedTweet) 176 | 177 | projectBot = p 178 | tokenId = t 179 | } catch (e) { 180 | console.error(e) 181 | return 182 | } 183 | 184 | const { data: artBlocksData } = await axios.get( 185 | getTokenApiUrl(projectBot.coreContract, tokenId) 186 | ) 187 | 188 | const assetUrl = artBlocksData.preview_asset_url 189 | 190 | let media_id: string 191 | try { 192 | media_id = await this.uploadMedia(assetUrl) 193 | } catch (error) { 194 | console.error(`Error uploading media for ${assetUrl}:`, error) 195 | return 196 | } 197 | 198 | const tokenUrl = 199 | getTokenUrl( 200 | artBlocksData.external_url, 201 | projectBot.coreContract, 202 | tokenId 203 | ) + TWITTER_PROJECTBOT_UTM 204 | 205 | let platform = '' 206 | // If Engine project, add Engine platform name 207 | if ( 208 | artBlocksData.platform && 209 | artBlocksData.platform !== '' && 210 | !artBlocksData.platform.includes('Art Blocks') 211 | ) { 212 | if (artBlocksData.platform === 'MOMENT') { 213 | artBlocksData.platform = 'Bright Moments' 214 | } 215 | 216 | platform = `on ${artBlocksData.platform} (Art Blocks Engine)` 217 | } 218 | 219 | const tweetMessage = `${artBlocksData.name} by ${artBlocksData.artist} ${platform}\n\n${tokenUrl}` 220 | 221 | console.log(`Replying to ${tweet.id} with ${artBlocksData.name}`) 222 | for (let i = 0; i < NUM_RETRIES; i++) { 223 | try { 224 | await this.twitterClient.v2.reply(tweetMessage, tweet.id, { 225 | media: { 226 | media_ids: [media_id], 227 | }, 228 | }) 229 | return 230 | } catch (error) { 231 | if (error instanceof ApiResponseError && error.rateLimit) { 232 | console.log( 233 | `Rate limit hit on tweet sending: \nLimit: ${error.rateLimit.limit}\nRemaining: ${error.rateLimit.remaining}\nReset: ${error.rateLimit.reset}` 234 | ) 235 | const reset = new Date(error.rateLimit.reset * 1000) 236 | const now = new Date() 237 | const diff = reset.getTime() - now.getTime() 238 | const diffMinutes = Math.ceil(diff / 60000) 239 | try { 240 | await this.sendStatusMessage( 241 | `@${ARTBOT_TWITTER_HANDLE} has been rate limited by Twitter :( Please try again in ${diffMinutes} minutes`, 242 | tweet.id 243 | ) 244 | } catch (e) { 245 | console.error('Error sending status message:', e) 246 | } 247 | return 248 | } else { 249 | console.log(`Error replying to ${tweet.id}:`, error) 250 | console.log(`Retrying (attempt ${i} of ${NUM_RETRIES})...`) 251 | await delay(RETRY_DELAY_MS) 252 | } 253 | } 254 | } 255 | console.error('Retry attempts failed :( - dropping tweet') 256 | } 257 | 258 | async uploadMedia(assetUrl: string): Promise { 259 | const downStream = await axios({ 260 | method: 'GET', 261 | responseType: 'arraybuffer', 262 | url: assetUrl, 263 | }) 264 | 265 | let buff = downStream.data as Buffer 266 | 267 | while (buff.length > TWITTER_MEDIA_BYTE_LIMIT) { 268 | if (assetUrl.includes('.mp4')) { 269 | // Can't resize videos, so try again with the png 270 | return await this.uploadMedia(assetUrl.replace('.mp4', '.png')) 271 | } 272 | console.log('Resizing...') 273 | const ratio = TWITTER_MEDIA_BYTE_LIMIT / buff.length 274 | const metadata = await sharp(buff).metadata() 275 | buff = await sharp(buff) 276 | .resize({ width: Math.floor((metadata.width ?? 0) * ratio) }) 277 | .toBuffer() 278 | } 279 | 280 | console.log('Uploading media to twitter...', assetUrl) 281 | for (let i = 0; i < NUM_RETRIES; i++) { 282 | try { 283 | const mediaId = await this.twitterClient.v1.uploadMedia(buff, { 284 | mimeType: this.getMimeType(assetUrl), 285 | }) 286 | return mediaId 287 | } catch (err) { 288 | console.log(`Error uploading ${assetUrl}:`, err) 289 | console.log(`Retrying (attempt ${i} of ${NUM_RETRIES})...`) 290 | await delay(RETRY_DELAY_MS) 291 | } 292 | } 293 | throw new Error("Couldn't upload media - dropping tweet") 294 | } 295 | 296 | getMimeType(assetUrl: string): EUploadMimeType { 297 | if (assetUrl.includes('.gif')) { 298 | return EUploadMimeType.Gif 299 | } else if (assetUrl.includes('.mp4')) { 300 | return EUploadMimeType.Mp4 301 | } else { 302 | return EUploadMimeType.Png 303 | } 304 | } 305 | 306 | async tweetArtblock(artBlock: Mint) { 307 | const assetUrl = artBlock.image 308 | if (!artBlock.image) { 309 | console.error('No artblock image defined', JSON.stringify(artBlock)) 310 | return 311 | } 312 | 313 | const mediaId = await this.uploadMedia(assetUrl) 314 | 315 | if (!mediaId) { 316 | console.error('no media id returned, not tweeting') 317 | return 318 | } 319 | 320 | const ownerText = await ensOrAddress(artBlock.owner) 321 | 322 | const tweetText = `${artBlock.tokenName} minted${ 323 | ownerText ? ` by ${ownerText}` : '' 324 | }. \n\n${artBlock.artblocksUrl + TWITTER_PROJECTBOT_UTM}` 325 | console.log(`Tweeting ${tweetText}`) 326 | 327 | const tweetRes = await this.twitterClient.v2.tweet(tweetText, { 328 | text: tweetText, 329 | media: { media_ids: [mediaId] }, 330 | }) 331 | 332 | return { 333 | tweetRes, 334 | } 335 | } 336 | 337 | async sendToTwitter(mint: Mint) { 338 | try { 339 | await this.tweetArtblock(mint) 340 | } catch (e) { 341 | console.error('Error posting to Twitter: ', e) 342 | } 343 | } 344 | 345 | // Have to log in with OAuth2 to access status account 346 | // Refresh token is stored in supabase 347 | async signIntoStatusAccount() { 348 | console.log('Signing in to status account') 349 | const token = await getStatusRefreshToken() 350 | const API = new TwitterApi({ 351 | clientId: process.env.AB_TWITTER_CLIENT_ID ?? '', 352 | clientSecret: process.env.AB_TWITTER_CLIENT_SECRET ?? '', 353 | }) 354 | console.log('Connecting with', token) 355 | const { client: refreshedClient, refreshToken: newRefreshToken } = 356 | await API.refreshOAuth2Token(token) 357 | console.log('Connected to status account! New token:', newRefreshToken) 358 | await updateStatusRefreshToken(newRefreshToken ?? '') 359 | console.log('Saved new refresh token') 360 | this.twitterStatusAccount = refreshedClient 361 | } 362 | 363 | async sendStatusMessage(message: string, replyId?: string) { 364 | if (!this.twitterStatusAccount) { 365 | await this.signIntoStatusAccount() 366 | } 367 | 368 | console.log(`Sending status message ${replyId ? `to ${replyId}` : ''}`) 369 | 370 | if (replyId) { 371 | await this.twitterStatusAccount?.v2.reply(message, replyId) 372 | return 373 | } 374 | await this.twitterStatusAccount?.v2.tweet(message) 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/Data/graphql/artbot-hasura-queries.graphql: -------------------------------------------------------------------------------- 1 | fragment ProjectDetail on projects_metadata { 2 | id 3 | project_id 4 | name 5 | description 6 | invocations 7 | max_invocations 8 | active 9 | paused 10 | complete 11 | artist_name 12 | contract_address 13 | start_datetime 14 | vertical_name 15 | is_artblocks 16 | vertical { 17 | category_name 18 | } 19 | tags { 20 | tag_name 21 | } 22 | contract { 23 | name 24 | } 25 | } 26 | 27 | fragment TokenDetail on tokens_metadata { 28 | invocation 29 | project { 30 | name 31 | artist_name 32 | } 33 | contract { 34 | token_base_url 35 | name 36 | } 37 | preview_asset_url 38 | live_view_url 39 | owner { 40 | public_address 41 | } 42 | is_flagged 43 | } 44 | 45 | fragment ProjectTokenDetail on tokens_metadata { 46 | id 47 | project_id 48 | invocation 49 | } 50 | 51 | fragment ContractDetail on contracts_metadata { 52 | address 53 | name 54 | } 55 | 56 | query getAllProjects($first: Int!, $skip: Int, $blocked_addresses: [String!]) { 57 | projects_metadata( 58 | limit: $first 59 | offset: $skip 60 | order_by: { project_id: asc } 61 | where: { 62 | active: { _eq: true } 63 | contract_address: { _nin: $blocked_addresses } 64 | } 65 | ) { 66 | ...ProjectDetail 67 | } 68 | } 69 | 70 | query getProjectInContracts($contracts: [String!], $projectId: String!) { 71 | projects_metadata( 72 | where: { 73 | project_id: { _eq: $projectId } 74 | contract_address: { _in: $contracts } 75 | active: { _eq: true } 76 | } 77 | limit: 1 78 | ) { 79 | ...ProjectDetail 80 | } 81 | } 82 | 83 | query getWalletTokens( 84 | $wallet: String! 85 | $contracts: [String!]! 86 | $first: Int! 87 | $skip: Int 88 | ) { 89 | tokens_metadata( 90 | limit: $first 91 | offset: $skip 92 | where: { 93 | owner_address: { _eq: $wallet } 94 | contract_address: { _in: $contracts } 95 | project: { active: { _eq: true } } 96 | } 97 | ) { 98 | ...TokenDetail 99 | } 100 | } 101 | 102 | query getToken($token_id: String!) { 103 | tokens_metadata(limit: 1, where: { id: { _eq: $token_id } }) { 104 | ...TokenDetail 105 | } 106 | } 107 | 108 | query getContractProjects($contract: String!, $first: Int!, $skip: Int) { 109 | projects_metadata( 110 | limit: $first 111 | offset: $skip 112 | order_by: { project_id: asc } 113 | where: { contract_address: { _eq: $contract }, active: { _eq: true } } 114 | ) { 115 | ...ProjectDetail 116 | } 117 | } 118 | 119 | query getOpenProjects($contracts: [String!], $first: Int!, $skip: Int) { 120 | projects_metadata( 121 | limit: $first 122 | offset: $skip 123 | order_by: { project_id: asc } 124 | where: { 125 | paused: { _eq: false } 126 | active: { _eq: true } 127 | complete: { _eq: false } 128 | contract_address: { _in: $contracts } 129 | } 130 | ) { 131 | ...ProjectDetail 132 | } 133 | } 134 | 135 | query getProject($id: String!) { 136 | projects_metadata(where: { id: { _eq: $id }, active: { _eq: true } }) { 137 | ...ProjectDetail 138 | } 139 | } 140 | 141 | query getEngineContracts($ids: [String!]) { 142 | contracts_metadata(where: { address: { _nin: $ids } }) { 143 | address 144 | } 145 | } 146 | 147 | query getProjectInvocations($id: String!) { 148 | projects_metadata(where: { id: { _eq: $id } }) { 149 | invocations 150 | } 151 | } 152 | 153 | query getAllContracts { 154 | contracts_metadata { 155 | ...ContractDetail 156 | } 157 | } 158 | 159 | query getMostRecentMintedTokenByContract($contracts: [String!]!) { 160 | tokens_metadata( 161 | order_by: [{ minted_at: desc }, { invocation: desc }] 162 | limit: 1 163 | where: { 164 | contract_address: { _in: $contracts } 165 | image_id: { _is_null: false } 166 | project: { active: { _eq: true } } 167 | } 168 | ) { 169 | ...ProjectTokenDetail 170 | } 171 | } 172 | 173 | query getMostRecentMintedFlagshipToken { 174 | tokens_metadata( 175 | order_by: [{ minted_at: desc }, { invocation: desc }] 176 | limit: 1 177 | where: { 178 | project: { 179 | vertical: { category: { hosted: { _eq: true } } } 180 | active: { _eq: true } 181 | } 182 | image_id: { _is_null: false } 183 | } 184 | ) { 185 | ...ProjectTokenDetail 186 | } 187 | } 188 | 189 | fragment UpcomingProjectDetail on projects_metadata { 190 | ...ProjectDetail 191 | auction_end_time 192 | auction_start_time 193 | charitable_giving_details 194 | minter_configuration { 195 | base_price 196 | minter { 197 | minter_type 198 | } 199 | extra_minter_details 200 | } 201 | tokens(limit: 1, order_by: { invocation: asc_nulls_last }) { 202 | preview_asset_url 203 | } 204 | } 205 | 206 | query GetNextUpcomingProject { 207 | projects_metadata( 208 | where: { start_datetime: { _gte: "NOW" } } 209 | order_by: { start_datetime: asc_nulls_last } 210 | limit: 1 211 | ) { 212 | ...UpcomingProjectDetail 213 | } 214 | } 215 | 216 | fragment OobTokenDetail on oob_tokens_metadata { 217 | media_url 218 | live_view_url 219 | } 220 | query GetProjectRandomOOB($project_id: String!, $seed: float8!) { 221 | projects_metadata(where: { id: { _eq: $project_id } }) { 222 | random_oob_token(args: { seed: $seed }) { 223 | ...OobTokenDetail 224 | } 225 | } 226 | } 227 | 228 | query GetStudioContracts { 229 | contracts_metadata(where: { default_vertical: { name: { _eq: studio } } }) { 230 | ...ContractDetail 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Data/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | import { CURRENT_SEASON } from '../Classes/TriviaBot' 3 | 4 | const supabaseClient = 5 | process.env.SUPABASE_URL && process.env.SUPABASE_API_KEY 6 | ? createClient(process.env.SUPABASE_URL, process.env.SUPABASE_API_KEY) 7 | : undefined 8 | 9 | export const updateTriviaScore = async (username: string): Promise => { 10 | if (!supabaseClient) { 11 | throw new Error('No Supabase client configured') 12 | } 13 | let totalScore = 1 14 | let seasonScore = 1 15 | 16 | const { data } = await supabaseClient 17 | .from(process.env.TRIVIA_TABLE ?? '') 18 | .select('*') 19 | .eq('user', `${username}`) 20 | 21 | if (data?.length) { 22 | totalScore = parseInt(data[0].score ?? 0) + 1 23 | seasonScore = parseInt(data[0][CURRENT_SEASON] ?? 0) + 1 24 | } 25 | 26 | const { error } = await supabaseClient 27 | .from(process.env.TRIVIA_TABLE ?? '') 28 | .upsert({ 29 | user: `${username}`, 30 | score: totalScore, 31 | [CURRENT_SEASON]: seasonScore, 32 | }) 33 | 34 | if (error) { 35 | console.error('Error updating trivia score', error) 36 | throw new Error(error.message) 37 | } 38 | 39 | return seasonScore 40 | } 41 | 42 | export const getAllTriviaScores = async (): Promise => { 43 | if (!supabaseClient) { 44 | throw new Error('No Supabase client configured') 45 | } 46 | 47 | const { data } = await supabaseClient 48 | .from(process.env.TRIVIA_TABLE ?? '') 49 | .select(`*`) 50 | 51 | return data 52 | } 53 | 54 | export const getLastTweetId = async (prod: boolean): Promise => { 55 | if (!supabaseClient) { 56 | throw new Error('No Supabase client configured') 57 | } 58 | 59 | const { data } = await supabaseClient 60 | .from('twitter') 61 | .select(`lastTweetId`) 62 | .eq('production', prod) 63 | .limit(1) 64 | 65 | if (!data?.length) { 66 | throw new Error('No last tweet id found') 67 | } 68 | 69 | return data[0].lastTweetId 70 | } 71 | 72 | export const updateLastTweetId = async (tweetId: string, prod: boolean) => { 73 | if (!supabaseClient) { 74 | throw new Error('No Supabase client configured') 75 | } 76 | const { error } = await supabaseClient 77 | .from('twitter') 78 | .upsert({ lastTweetId: tweetId, production: prod }) 79 | 80 | if (error) { 81 | throw new Error(`Error updating last tweet id ${error}`) 82 | } 83 | } 84 | 85 | export const getStatusRefreshToken = async () => { 86 | if (!supabaseClient) { 87 | throw new Error('No Supabase client configured') 88 | } 89 | const { data } = await supabaseClient 90 | .from('twitter_tokens') 91 | .select(`token`) 92 | .eq('id', 'statusRefresh') 93 | .limit(1) 94 | 95 | if (!data?.length) { 96 | throw new Error('No last tweet id found') 97 | } 98 | 99 | return data[0].token 100 | } 101 | 102 | export const updateStatusRefreshToken = async (token: string) => { 103 | if (!supabaseClient) { 104 | throw new Error('No Supabase client configured') 105 | } 106 | const { error } = await supabaseClient 107 | .from('twitter_tokens') 108 | .upsert({ id: 'statusRefresh', token: token }) 109 | 110 | if (error) { 111 | throw new Error(`Error updating status token ${error}`) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/NamedMappings/apparitionSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "highlighter": 1080, 3 | "noir": 1026, 4 | "crane": 635, 5 | "iceberg": 320, 6 | "hungary": 325, 7 | "oogieboogie": 84, 8 | "sutter": 898, 9 | "red-carpet": 660, 10 | "redcarpet": 660, 11 | "bryce-canyon": 1407, 12 | "brycecanyon": 1407, 13 | "rainbow-road": 175, 14 | "face": 1465, 15 | "mt-fuji": 1475, 16 | "mtfuji": 1475, 17 | "horns": 705, 18 | "sothebys": 648, 19 | "🐝": 1223, 20 | "corset": 993 21 | } 22 | -------------------------------------------------------------------------------- /src/NamedMappings/archetypeSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "cube": [122, 213, 216, 250, 327, 397, 457, 471, 582, 596], 3 | "corner": [ 4 | 19, 20, 64, 83, 84, 104, 118, 159, 186, 199, 206, 240, 258, 268, 269, 287, 5 | 306, 314, 318, 367, 392, 413, 429, 489, 520, 527, 569 6 | ], 7 | "flat": [ 8 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 22, 9 | 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 10 | 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 11 | 61, 62, 63, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 12 | 81, 82, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 13 | 101, 102, 103, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 14 | 117, 119, 120, 121, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 15 | 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 16 | 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 160, 161, 162, 163, 164, 17 | 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 18 | 180, 181, 182, 183, 184, 185, 187, 188, 189, 190, 191, 192, 193, 194, 195, 19 | 196, 197, 198, 200, 201, 202, 203, 204, 205, 207, 208, 209, 210, 211, 212, 20 | 214, 215, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 21 | 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 241, 242, 243, 244, 245, 22 | 246, 247, 248, 249, 251, 252, 253, 254, 255, 256, 257, 259, 260, 261, 262, 23 | 263, 264, 265, 266, 267, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 24 | 280, 281, 282, 283, 284, 285, 286, 288, 289, 290, 291, 292, 293, 294, 295, 25 | 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 307, 308, 309, 310, 311, 26 | 312, 313, 315, 316, 317, 319, 320, 321, 322, 323, 324, 325, 326, 328, 329, 27 | 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 28 | 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 29 | 360, 361, 362, 363, 364, 365, 366, 368, 369, 370, 371, 372, 373, 374, 375, 30 | 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 31 | 391, 393, 394, 395, 396, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 32 | 408, 409, 410, 411, 412, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 33 | 424, 425, 426, 427, 428, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 34 | 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 35 | 455, 456, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 36 | 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 37 | 487, 488, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 38 | 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 39 | 518, 519, 521, 522, 523, 524, 525, 526, 528, 529, 530, 531, 532, 533, 534, 40 | 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 41 | 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 42 | 565, 566, 567, 568, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 43 | 581, 583, 584, 585, 586, 587, 588, 589, 590, 591, 592, 593, 594, 595, 597, 44 | 598, 599 45 | ], 46 | "red-spider": [ 47 | 0, 20, 33, 57, 79, 93, 136, 166, 217, 219, 240, 244, 252, 254, 288, 291, 48 | 305, 310, 315, 333, 351, 371, 504, 508, 512, 523, 529, 534, 540, 583 49 | ], 50 | "atlas": [ 51 | 1, 19, 32, 37, 40, 53, 56, 87, 108, 123, 147, 176, 211, 214, 216, 236, 245, 52 | 249, 336, 343, 344, 347, 392, 398, 402, 423, 431, 433, 434, 470, 488, 506, 53 | 528, 578 54 | ], 55 | "punk": [2, 25, 81, 190, 258, 282, 383, 393, 415, 426, 435, 477, 530, 545], 56 | "paddle": [3, 36, 45, 54, 70, 135, 237, 250, 314, 449, 467, 491], 57 | "yellow-spider": [ 58 | 4, 97, 115, 140, 153, 173, 178, 225, 247, 270, 294, 298, 317, 367, 395, 403, 59 | 409, 417, 430, 441, 490, 526, 558, 561, 572, 585, 589 60 | ], 61 | "mysore": [ 62 | 5, 34, 67, 69, 75, 105, 106, 168, 180, 181, 199, 261, 308, 330, 345, 349, 63 | 373, 381, 406, 407, 412, 422, 482, 486, 495, 522, 538, 559, 591 64 | ], 65 | "main-course": [ 66 | 6, 21, 44, 76, 142, 150, 172, 338, 346, 360, 382, 489, 524, 525, 532, 544, 67 | 554 68 | ], 69 | "green-spider": [ 70 | 7, 29, 46, 47, 95, 103, 128, 184, 197, 253, 277, 335, 348, 363, 380, 389, 71 | 397, 454, 485, 517, 521, 594 72 | ], 73 | "hotshot": [ 74 | 8, 11, 22, 38, 100, 111, 145, 146, 151, 163, 185, 195, 198, 246, 260, 281, 75 | 306, 320, 337, 352, 361, 384, 429, 450, 457, 472, 480, 547, 550, 557, 574, 76 | 580 77 | ], 78 | "tropico": [ 79 | 9, 71, 121, 139, 174, 231, 290, 321, 366, 387, 408, 420, 587, 595 80 | ], 81 | "jolly": [10, 49, 127, 133, 226, 555], 82 | "cherfi": [12, 68, 92, 263, 353, 443, 469, 541, 552], 83 | "mantis": [ 84 | 13, 90, 107, 114, 117, 129, 154, 223, 273, 325, 438, 497, 563, 577 85 | ], 86 | "hotspot": [ 87 | 14, 31, 42, 58, 63, 73, 77, 113, 119, 159, 162, 171, 179, 191, 194, 213, 88 | 224, 255, 259, 269, 278, 286, 304, 311, 442, 448, 484, 516, 533, 535, 542, 89 | 543, 564, 570, 588, 592 90 | ], 91 | "blue-spider": [ 92 | 15, 109, 143, 144, 193, 201, 248, 265, 266, 303, 324, 334, 355, 374, 385, 93 | 440, 462, 473, 478, 481, 527, 536 94 | ], 95 | "nightlife": [ 96 | 16, 66, 96, 130, 157, 189, 232, 239, 326, 356, 359, 410, 452, 518 97 | ], 98 | "slapdash": [ 99 | 17, 98, 177, 208, 215, 235, 257, 309, 411, 446, 498, 519, 551, 598 100 | ], 101 | "bangalore": [18, 88, 149, 164, 170, 230, 256, 537], 102 | "hurdles": [23, 152, 169, 203, 502], 103 | "warm-duo": [24, 55, 83, 102, 220, 268, 272, 364, 416, 418, 444, 539, 546], 104 | "giftcard": [ 105 | 26, 51, 59, 82, 110, 122, 132, 202, 204, 221, 292, 358, 372, 390, 391, 394, 106 | 396, 459, 479, 514, 579, 581, 596 107 | ], 108 | "golden-duo": [27, 357, 458, 556, 566, 576], 109 | "sprague": [ 110 | 28, 84, 89, 126, 160, 186, 222, 293, 331, 362, 375, 386, 401, 510, 584 111 | ], 112 | "romeo": [30, 200, 227, 243, 262, 365, 414, 461, 496], 113 | "pink-spider": [ 114 | 35, 52, 72, 155, 156, 175, 192, 229, 241, 280, 297, 299, 307, 313, 399, 451, 115 | 460, 569 116 | ], 117 | "nowak": [39, 85, 124, 131, 188, 206, 275, 279, 323, 328, 369, 511, 597], 118 | "revolucion": [ 119 | 41, 61, 74, 99, 116, 118, 120, 134, 183, 209, 234, 251, 312, 340, 350, 388, 120 | 425, 439, 463, 501, 520, 571 121 | ], 122 | "shelter": [43, 65, 370, 476, 507], 123 | "docks": [ 124 | 48, 94, 182, 187, 205, 233, 276, 329, 436, 437, 445, 447, 465, 466, 531, 565 125 | ], 126 | "arcade": [50, 80, 86, 101, 196, 207, 296, 428, 505, 560, 575], 127 | "verena": [ 128 | 60, 62, 158, 165, 218, 228, 242, 271, 283, 285, 289, 319, 322, 327, 341, 129 | 376, 404, 499, 513, 515, 553, 568, 573, 582, 599 130 | ], 131 | "rosewood": [64, 301, 354, 421, 427, 493, 549], 132 | "honeypot": [78, 104, 161, 210, 212, 378, 405, 456, 471, 475], 133 | "delphi": [91, 112, 148, 316, 368, 377, 494, 562, 593], 134 | "mural": [ 135 | 125, 274, 295, 300, 302, 332, 400, 413, 455, 468, 483, 492, 500, 509, 586 136 | ], 137 | "mono": [137], 138 | "truce": [138, 141, 432, 487], 139 | "cold-duo": [167, 264, 267, 284, 318, 339, 453, 474, 503, 567], 140 | "alpha-dog": [238, 287, 342, 464, 590], 141 | "ducci": [379], 142 | "spider-king": [419], 143 | "miradors": [424, 548], 144 | "mono-color": [61, 141, 144, 151, 241, 334, 439, 498, 507], 145 | "framed": [ 146 | 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 147 | 23, 24, 25, 26, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 148 | 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 149 | 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 150 | 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 151 | 101, 102, 103, 104, 105, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 152 | 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 153 | 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 154 | 147, 148, 149, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 155 | 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 156 | 178, 179, 180, 181, 182, 183, 185, 186, 187, 188, 189, 191, 192, 193, 194, 157 | 195, 196, 197, 198, 200, 201, 202, 203, 204, 205, 206, 207, 209, 210, 211, 158 | 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 159 | 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 160 | 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 161 | 257, 258, 259, 260, 261, 263, 264, 265, 266, 267, 268, 270, 271, 272, 273, 162 | 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 163 | 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 164 | 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 317, 318, 319, 165 | 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 166 | 335, 336, 337, 338, 339, 340, 341, 342, 343, 345, 346, 347, 348, 349, 350, 167 | 351, 352, 353, 354, 355, 356, 357, 358, 360, 361, 362, 363, 364, 365, 366, 168 | 367, 368, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 169 | 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 170 | 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 171 | 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 172 | 429, 430, 431, 432, 434, 435, 436, 437, 438, 439, 441, 442, 443, 444, 445, 173 | 446, 447, 448, 449, 450, 451, 454, 455, 456, 457, 458, 459, 460, 461, 462, 174 | 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 474, 475, 476, 477, 478, 175 | 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 176 | 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 177 | 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 178 | 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 179 | 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 180 | 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 181 | 569, 570, 571, 572, 573, 574, 575, 577, 578, 579, 581, 582, 583, 584, 585, 182 | 586, 587, 588, 589, 590, 591, 592, 593, 595, 596, 597, 598, 599 183 | ], 184 | "unframed": [ 185 | 6, 13, 27, 28, 106, 150, 184, 190, 199, 208, 262, 269, 316, 344, 359, 369, 186 | 370, 433, 440, 452, 453, 473, 576, 580, 594 187 | ], 188 | "balance": [ 189 | 0, 2, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 25, 190 | 27, 29, 30, 32, 33, 34, 35, 37, 38, 43, 44, 47, 48, 49, 50, 53, 55, 56, 57, 191 | 59, 63, 64, 70, 74, 76, 77, 78, 81, 82, 83, 84, 85, 86, 89, 90, 91, 92, 93, 192 | 98, 101, 105, 106, 107, 111, 112, 118, 120, 125, 126, 128, 134, 135, 137, 193 | 139, 140, 142, 148, 149, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 194 | 166, 170, 171, 172, 174, 175, 177, 178, 179, 180, 182, 183, 186, 188, 190, 195 | 194, 195, 197, 198, 199, 202, 204, 208, 211, 212, 214, 218, 219, 220, 221, 196 | 223, 225, 228, 234, 235, 236, 238, 239, 240, 242, 244, 245, 249, 251, 253, 197 | 255, 256, 259, 260, 262, 263, 266, 268, 269, 270, 272, 273, 274, 276, 277, 198 | 278, 281, 285, 287, 288, 289, 290, 291, 292, 294, 295, 296, 297, 298, 299, 199 | 301, 302, 305, 306, 310, 311, 312, 314, 317, 319, 320, 321, 323, 327, 329, 200 | 330, 331, 332, 335, 338, 340, 341, 343, 345, 346, 347, 349, 350, 353, 354, 201 | 358, 361, 362, 363, 365, 366, 368, 369, 370, 376, 377, 380, 381, 383, 384, 202 | 385, 386, 387, 388, 390, 391, 393, 394, 395, 397, 398, 399, 400, 402, 405, 203 | 406, 407, 409, 411, 415, 418, 420, 422, 423, 424, 425, 428, 429, 430, 433, 204 | 435, 438, 440, 448, 449, 450, 452, 453, 456, 457, 458, 459, 460, 461, 464, 205 | 465, 469, 470, 472, 474, 475, 477, 478, 483, 485, 486, 487, 488, 490, 496, 206 | 497, 501, 503, 504, 505, 509, 510, 511, 513, 518, 521, 522, 523, 524, 529, 207 | 530, 531, 533, 534, 536, 537, 539, 543, 544, 552, 553, 556, 557, 558, 560, 208 | 562, 563, 564, 565, 567, 568, 569, 571, 572, 574, 576, 577, 578, 579, 580, 209 | 584, 585, 586, 587, 588, 589, 590, 592, 593, 594, 597 210 | ], 211 | "order": [ 212 | 1, 3, 5, 12, 22, 36, 39, 42, 45, 46, 51, 52, 61, 66, 67, 68, 71, 75, 80, 87, 213 | 94, 96, 97, 108, 110, 114, 115, 116, 117, 121, 122, 123, 124, 130, 131, 132, 214 | 136, 138, 141, 143, 144, 145, 147, 150, 151, 152, 153, 164, 176, 181, 184, 215 | 185, 187, 191, 193, 196, 201, 203, 206, 207, 209, 210, 215, 216, 217, 222, 216 | 226, 229, 231, 232, 233, 237, 241, 246, 247, 248, 252, 257, 258, 261, 267, 217 | 271, 275, 279, 280, 282, 283, 286, 300, 303, 307, 308, 316, 318, 322, 324, 218 | 325, 326, 328, 333, 334, 348, 352, 356, 360, 364, 371, 372, 373, 378, 379, 219 | 382, 389, 392, 396, 401, 403, 404, 410, 413, 416, 419, 421, 427, 431, 432, 220 | 434, 436, 437, 439, 441, 442, 443, 444, 445, 446, 447, 451, 455, 462, 463, 221 | 466, 467, 468, 473, 476, 479, 481, 489, 491, 493, 494, 495, 498, 502, 507, 222 | 512, 514, 515, 520, 525, 527, 528, 535, 541, 542, 549, 550, 554, 555, 559, 223 | 561, 570, 573, 575, 582, 583, 591, 595 224 | ], 225 | "chaos": [ 226 | 26, 28, 31, 40, 41, 54, 58, 60, 62, 65, 69, 72, 73, 79, 88, 95, 99, 100, 227 | 102, 103, 104, 109, 113, 119, 127, 129, 133, 146, 165, 167, 168, 169, 173, 228 | 189, 192, 200, 205, 213, 224, 227, 230, 243, 250, 254, 264, 265, 284, 293, 229 | 304, 309, 313, 315, 336, 337, 339, 342, 344, 351, 355, 357, 359, 367, 374, 230 | 375, 408, 412, 414, 417, 426, 454, 471, 480, 482, 484, 492, 499, 500, 506, 231 | 508, 516, 517, 519, 526, 532, 538, 540, 545, 546, 547, 548, 551, 566, 581, 232 | 596, 598, 599 233 | ] 234 | } 235 | -------------------------------------------------------------------------------- /src/NamedMappings/autologySingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "samurai": 737, 3 | "pug": 911, 4 | "skull-feathers": 26, 5 | "the-dragon": 77, 6 | "tide-pools": 532, 7 | "bridge-over-black-hole": 917, 8 | "remnants-of-the-duel": 962, 9 | "the-beauty-of-chaos": 435, 10 | "jovial-owl": 125, 11 | "dancing-aztec": 51, 12 | 13 | "mantis": 115, 14 | "unibrow": 143, 15 | "furby": 183, 16 | "kitty": 197, 17 | "chief": 271, 18 | "serious-owl": 283, 19 | "smiley": 339, 20 | "alien": 636, 21 | "bust": 797, 22 | "yogi": 870, 23 | "splinters": 995 24 | } 25 | -------------------------------------------------------------------------------- /src/NamedMappings/dreamSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "asteeland": 101, 3 | "asteog": 212, 4 | "astera": 388, 5 | "astere": 275, 6 | "astesion": 544, 7 | "beory": 599, 8 | "bere": 682, 9 | "besion": 137, 10 | "foir": 51, 11 | "lomary": 571, 12 | "lora": 25, 13 | "memir": 110, 14 | "memmary": 104, 15 | "memre": 337, 16 | "pasog": 642, 17 | "pasory": 24, 18 | "paspic": 565, 19 | "pasre": 302, 20 | "priir": 427, 21 | "propaeland": 41, 22 | "propaog": 290, 23 | "propaory": 38, 24 | "propapic": 538, 25 | "propara": 451, 26 | "rura": 508, 27 | "rure": 666, 28 | "troeland": 96, 29 | "trora": 588, 30 | "wastir": 499, 31 | "wastory": 454, 32 | "watermelon-chair": 173, 33 | "stealth-hemisphere": 334, 34 | "arrow": 28, 35 | "hook": 481, 36 | "facade": 20, 37 | "undercover-minimal": 691, 38 | "lobster": 333, 39 | "rocket": 595, 40 | "nightlight": 680, 41 | "bananas": 509, 42 | "gradients": 144, 43 | "static": 254, 44 | "library": 327, 45 | "pocket": 26, 46 | "bauhaus": 59, 47 | "waves": 208, 48 | "coffee-stain": 221, 49 | "dam": 220, 50 | "tundra": 685, 51 | "roses": 350, 52 | "🌹": 350, 53 | "☕": 221, 54 | "🌊": 208, 55 | "🍌": 509, 56 | "🚀": 595, 57 | "🟪": 448, 58 | "🕵️": 691, 59 | "🦞": 333 60 | } 61 | -------------------------------------------------------------------------------- /src/NamedMappings/elementalsSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "five": [526, 493, 251, 395, 523], 3 | "ideal": [ 4 | 19, 48, 65, 70, 86, 103, 105, 126, 188, 232, 247, 251, 287, 312, 395, 404, 5 | 427, 440, 450, 475, 493, 523, 526, 554, 560, 563 6 | ], 7 | "ideals": [ 8 | 19, 48, 65, 70, 86, 103, 105, 126, 188, 232, 247, 251, 287, 312, 395, 404, 9 | 427, 440, 450, 475, 493, 523, 526, 554, 560, 563 10 | ], 11 | "minimal": [ 12 | 25, 30, 34, 82, 97, 106, 138, 148, 155, 186, 189, 311, 316, 333, 339, 343, 13 | 419, 431, 438, 473, 536, 545, 548, 567, 593 14 | ], 15 | "minimals": [ 16 | 25, 30, 34, 82, 97, 106, 138, 148, 155, 186, 189, 311, 316, 333, 339, 343, 17 | 419, 431, 438, 473, 536, 545, 548, 567, 593 18 | ], 19 | "red": [ 20 | 5, 9, 27, 37, 60, 63, 77, 78, 93, 94, 95, 142, 152, 106, 175, 183, 202, 209, 21 | 213, 224, 225, 237, 226, 245, 248, 262, 264, 274, 279, 292, 331, 334, 336, 22 | 353, 361, 373, 384, 444, 469, 473, 486, 498, 531, 561, 563, 586, 598 23 | ], 24 | "reds": [ 25 | 5, 9, 27, 37, 60, 63, 77, 78, 93, 94, 95, 142, 152, 106, 175, 183, 202, 209, 26 | 213, 224, 225, 237, 226, 245, 248, 262, 264, 274, 279, 292, 331, 334, 336, 27 | 353, 361, 373, 384, 444, 469, 473, 486, 498, 531, 561, 563, 586, 598 28 | ], 29 | "westerly": [ 30 | 4, 7, 9, 10, 43, 44, 51, 84, 91, 95, 106, 123, 127, 130, 131, 132, 142, 145, 31 | 147, 148, 151, 153, 179, 189, 198, 211, 226, 233, 237, 238, 245, 263, 268, 32 | 269, 286, 290, 297, 332, 340, 346, 347, 365, 378, 383, 419, 420, 429, 437, 33 | 438, 447, 468, 471, 474, 482, 490, 508, 510, 512, 548, 556, 560, 569, 596, 34 | 598 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/NamedMappings/elementalsSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "dog": 473, 3 | "🐕": 473, 4 | "emoji": 526, 5 | "most-blinds": 262, 6 | "most-combined": 493, 7 | "most-dots": 12, 8 | "most-minimal": 593, 9 | "rarest": 410, 10 | "wizard": 359, 11 | "wizard-reading-on-toilet": 359, 12 | "🧙‍♂️🚽": 359 13 | } 14 | -------------------------------------------------------------------------------- /src/NamedMappings/fidenzaSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "golf-socks": [ 3 | 0, 23, 26, 32, 33, 37, 39, 64, 85, 91, 138, 152, 156, 159, 165, 166, 173, 4 | 180, 183, 187, 202, 207, 221, 245, 246, 250, 252, 271, 281, 286, 292, 297, 5 | 304, 327, 342, 346, 347, 358, 378, 397, 418, 422, 461, 465, 469, 471, 477, 6 | 481, 485, 486, 491, 494, 496, 510, 538, 546, 549, 573, 601, 605, 619, 622, 7 | 664, 674, 675, 687, 689, 697, 700, 702, 709, 717, 721, 722, 726, 743, 753, 8 | 761, 771, 773, 777, 784, 789, 796, 799, 802, 819, 827, 831, 837, 846, 855, 9 | 867, 869, 878, 881, 883, 887, 895, 905, 915, 922, 925, 947, 965, 969, 975, 10 | 986, 987, 990, 991 11 | ], 12 | "luxe": [ 13 | 1, 3, 8, 9, 10, 11, 15, 16, 17, 20, 25, 27, 29, 30, 36, 38, 41, 42, 43, 46, 14 | 48, 51, 52, 53, 54, 55, 56, 58, 63, 65, 66, 67, 69, 71, 72, 74, 80, 82, 86, 15 | 87, 89, 90, 93, 94, 99, 100, 101, 102, 103, 104, 105, 108, 109, 116, 118, 16 | 120, 121, 123, 125, 126, 129, 131, 132, 133, 135, 136, 139, 140, 141, 142, 17 | 143, 146, 147, 149, 150, 151, 153, 154, 155, 157, 158, 160, 161, 162, 163, 18 | 164, 167, 170, 171, 176, 177, 178, 179, 181, 182, 184, 185, 189, 190, 192, 19 | 194, 196, 197, 198, 199, 204, 205, 206, 209, 210, 211, 213, 214, 216, 217, 20 | 218, 219, 220, 222, 223, 225, 227, 232, 236, 238, 240, 242, 243, 244, 248, 21 | 249, 251, 255, 257, 258, 259, 260, 261, 265, 266, 270, 272, 274, 276, 277, 22 | 279, 280, 283, 285, 287, 288, 289, 290, 293, 295, 296, 298, 299, 300, 302, 23 | 303, 305, 306, 308, 311, 312, 313, 317, 318, 319, 322, 323, 326, 329, 331, 24 | 332, 335, 338, 339, 340, 341, 343, 345, 350, 351, 352, 353, 354, 357, 360, 25 | 362, 363, 364, 365, 366, 367, 372, 373, 374, 375, 376, 377, 379, 380, 381, 26 | 382, 386, 387, 390, 392, 393, 394, 395, 399, 401, 406, 409, 412, 413, 415, 27 | 417, 419, 420, 421, 425, 426, 427, 429, 432, 434, 436, 437, 439, 441, 442, 28 | 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 454, 455, 457, 460, 462, 29 | 463, 464, 467, 468, 470, 472, 473, 474, 478, 482, 483, 484, 487, 488, 489, 30 | 490, 492, 495, 497, 498, 499, 500, 504, 505, 508, 509, 511, 517, 519, 520, 31 | 522, 523, 524, 525, 526, 528, 530, 532, 533, 534, 535, 540, 542, 543, 545, 32 | 547, 548, 551, 554, 558, 559, 562, 563, 565, 566, 568, 569, 570, 572, 574, 33 | 578, 580, 582, 584, 585, 587, 590, 591, 592, 599, 600, 603, 604, 606, 608, 34 | 612, 613, 614, 618, 620, 623, 624, 626, 627, 631, 632, 633, 634, 635, 636, 35 | 637, 639, 641, 642, 645, 648, 652, 653, 654, 656, 669, 676, 677, 679, 681, 36 | 682, 684, 685, 690, 691, 692, 693, 694, 698, 699, 701, 703, 706, 707, 708, 37 | 710, 714, 719, 724, 725, 727, 728, 729, 730, 731, 733, 737, 739, 740, 741, 38 | 742, 745, 746, 748, 749, 751, 754, 755, 756, 762, 763, 764, 765, 767, 769, 39 | 770, 775, 778, 779, 780, 781, 782, 785, 787, 790, 793, 794, 795, 797, 798, 40 | 800, 804, 805, 806, 807, 808, 810, 811, 812, 813, 814, 815, 816, 817, 818, 41 | 820, 821, 823, 824, 825, 826, 828, 829, 833, 834, 839, 840, 841, 842, 845, 42 | 847, 848, 849, 850, 851, 856, 858, 859, 860, 864, 865, 868, 870, 872, 874, 43 | 875, 877, 879, 880, 882, 888, 889, 890, 891, 894, 897, 898, 899, 900, 902, 44 | 904, 907, 909, 910, 912, 913, 914, 918, 919, 920, 921, 924, 928, 929, 931, 45 | 932, 933, 934, 935, 936, 937, 938, 939, 941, 942, 944, 946, 948, 949, 953, 46 | 955, 957, 958, 959, 960, 961, 962, 963, 964, 971, 972, 973, 974, 976, 978, 47 | 980, 981, 985, 988, 989, 993, 996, 997 48 | ], 49 | "white-mono": [ 50 | 2, 34, 47, 98, 110, 169, 186, 200, 203, 334, 405, 410, 438, 453, 512, 514, 51 | 555, 597, 611, 625, 671, 720, 744, 766, 792, 801, 835, 836, 852, 893, 903, 52 | 916, 926, 943, 954, 977, 984 53 | ], 54 | "baked": [ 55 | 4, 14, 78, 81, 106, 115, 117, 119, 122, 134, 145, 201, 212, 233, 262, 278, 56 | 310, 320, 328, 348, 359, 368, 391, 398, 414, 503, 560, 567, 571, 577, 651, 57 | 661, 686, 736, 760, 776, 843, 857, 863, 866, 896, 930, 940, 945, 950, 966, 58 | 970 59 | ], 60 | "politique": [ 61 | 5, 7, 22, 24, 28, 62, 70, 79, 84, 95, 113, 130, 174, 193, 226, 234, 239, 62 | 253, 264, 273, 282, 316, 321, 325, 369, 383, 384, 385, 396, 407, 408, 416, 63 | 428, 459, 476, 480, 501, 502, 516, 550, 556, 579, 581, 586, 593, 615, 630, 64 | 640, 644, 655, 659, 663, 666, 711, 716, 747, 758, 774, 788, 854, 884, 901, 65 | 923, 982 66 | ], 67 | "party-time": [6, 31, 61, 77, 237, 267, 576, 704, 885], 68 | "rad": [ 69 | 12, 13, 35, 45, 50, 59, 68, 73, 83, 96, 97, 107, 114, 124, 127, 128, 137, 70 | 144, 148, 172, 191, 195, 208, 230, 241, 254, 256, 263, 268, 284, 309, 315, 71 | 324, 333, 355, 400, 403, 404, 424, 430, 431, 433, 466, 493, 515, 521, 537, 72 | 539, 557, 575, 583, 594, 595, 596, 609, 621, 629, 643, 647, 649, 660, 667, 73 | 668, 670, 688, 695, 715, 718, 732, 734, 735, 752, 768, 791, 803, 809, 830, 74 | 844, 876, 911, 951, 952, 968, 979, 983, 995 75 | ], 76 | "rose": [ 77 | 18, 75, 175, 224, 229, 291, 307, 330, 336, 337, 361, 458, 506, 544, 616, 78 | 650, 678, 759, 822, 832, 886, 992 79 | ], 80 | "cool": [19, 269, 294, 344, 402, 411, 589, 680, 772, 871, 917, 994], 81 | "white-on-cream": [21, 314, 423, 602, 683, 705, 712, 738, 861, 967], 82 | "am": [ 83 | 40, 57, 60, 111, 112, 215, 231, 349, 356, 388, 389, 435, 475, 507, 518, 552, 84 | 588, 598, 607, 617, 638, 657, 658, 696, 757, 853, 862, 927, 956, 998 85 | ], 86 | "luxe-derived": [44, 88, 168, 228, 301, 529, 672, 673, 783, 892, 906], 87 | "black": [ 88 | 49, 76, 92, 188, 235, 247, 275, 370, 440, 456, 479, 513, 527, 536, 541, 561, 89 | 564, 610, 628, 646, 713, 750, 786, 838, 873, 908 90 | ], 91 | "dark-lifestyle": [371, 531, 553, 662, 665, 723], 92 | "spiral": [ 93 | 49, 53, 99, 113, 123, 154, 182, 214, 216, 298, 395, 397, 415, 427, 434, 475, 94 | 500, 502, 514, 527, 537, 544, 575, 592, 593, 612, 636, 663, 692, 745, 772, 95 | 849, 938, 984, 985 96 | ], 97 | "soft-shapes": [ 98 | 4, 7, 8, 14, 24, 26, 27, 28, 36, 52, 53, 55, 56, 61, 70, 83, 95, 96, 97, 99 | 102, 109, 111, 116, 117, 121, 133, 134, 136, 137, 148, 150, 160, 161, 168, 100 | 180, 182, 184, 190, 192, 210, 220, 222, 231, 243, 245, 247, 249, 251, 252, 101 | 258, 261, 267, 268, 269, 277, 286, 291, 297, 300, 306, 318, 320, 341, 348, 102 | 349, 367, 368, 371, 373, 388, 390, 392, 403, 410, 418, 420, 422, 426, 436, 103 | 440, 450, 454, 455, 462, 466, 485, 516, 529, 538, 540, 548, 549, 553, 555, 104 | 559, 560, 570, 572, 584, 590, 592, 597, 606, 610, 633, 636, 644, 645, 655, 105 | 656, 659, 665, 680, 708, 717, 721, 722, 732, 742, 743, 749, 750, 762, 773, 106 | 774, 777, 789, 810, 817, 826, 840, 861, 862, 870, 874, 876, 898, 909, 927, 107 | 931, 940, 948, 954, 956, 958, 959, 961, 970, 993, 995 108 | ], 109 | "super-blocks": [ 110 | 0, 11, 15, 20, 22, 23, 32, 37, 40, 51, 60, 64, 67, 77, 84, 85, 100, 103, 111 | 104, 105, 112, 114, 118, 119, 120, 126, 127, 140, 145, 157, 163, 164, 166, 112 | 169, 170, 174, 177, 178, 185, 186, 191, 193, 198, 204, 207, 219, 221, 227, 113 | 229, 232, 234, 238, 250, 255, 256, 266, 273, 278, 281, 287, 292, 294, 295, 114 | 305, 307, 308, 313, 317, 319, 321, 322, 331, 343, 347, 355, 357, 359, 362, 115 | 364, 377, 378, 382, 387, 391, 394, 395, 400, 401, 408, 414, 416, 417, 427, 116 | 430, 432, 435, 445, 447, 453, 456, 459, 461, 469, 470, 472, 479, 483, 484, 117 | 487, 488, 489, 495, 499, 506, 508, 509, 510, 513, 514, 515, 520, 523, 526, 118 | 530, 531, 543, 544, 546, 547, 552, 564, 568, 574, 575, 582, 583, 588, 593, 119 | 594, 602, 604, 611, 612, 613, 615, 616, 617, 619, 621, 626, 635, 642, 643, 120 | 646, 647, 649, 660, 662, 671, 677, 683, 684, 691, 695, 697, 710, 714, 715, 121 | 716, 726, 729, 730, 733, 735, 748, 752, 755, 764, 767, 776, 779, 788, 790, 122 | 792, 793, 797, 799, 804, 805, 806, 808, 812, 815, 824, 825, 828, 830, 845, 123 | 846, 847, 854, 859, 866, 869, 871, 872, 873, 882, 883, 886, 892, 907, 910, 124 | 916, 917, 919, 920, 926, 928, 929, 930, 932, 933, 934, 936, 938, 943, 947, 125 | 949, 951, 952, 953, 965, 969, 971, 973, 974, 977, 980, 983, 990 126 | ], 127 | "sharp": [ 128 | 19, 75, 103, 126, 188, 213, 225, 227, 286, 301, 304, 316, 323, 342, 343, 129 | 362, 396, 402, 430, 543, 573, 581, 587, 637, 647, 650, 676, 718, 819, 831, 130 | 832, 854, 885, 957 131 | ], 132 | "anything-goes": [ 133 | 20, 25, 48, 78, 90, 97, 105, 123, 135, 147, 176, 211, 294, 316, 338, 364, 134 | 378, 388, 419, 430, 490, 634, 693, 732, 773, 830, 920, 929, 935, 937 135 | ], 136 | "relaxed": [ 137 | 5, 87, 101, 104, 119, 151, 360, 420, 499, 714, 740, 777, 796, 821, 828, 889, 138 | 938 139 | ], 140 | "large": [ 141 | 0, 4, 8, 12, 34, 36, 37, 41, 44, 47, 55, 57, 64, 66, 71, 80, 83, 84, 90, 142 | 107, 108, 118, 119, 126, 143, 148, 153, 156, 160, 176, 183, 188, 191, 199, 143 | 206, 209, 210, 225, 229, 230, 237, 241, 270, 272, 277, 284, 286, 288, 297, 144 | 302, 306, 309, 319, 320, 321, 325, 326, 331, 341, 342, 345, 350, 355, 361, 145 | 362, 368, 371, 375, 379, 382, 387, 388, 391, 393, 400, 403, 408, 410, 411, 146 | 418, 426, 430, 433, 438, 442, 443, 448, 464, 465, 472, 473, 474, 484, 486, 147 | 487, 488, 492, 495, 498, 503, 506, 516, 521, 522, 523, 527, 529, 535, 537, 148 | 553, 556, 571, 573, 574, 576, 582, 597, 602, 610, 619, 634, 635, 637, 645, 149 | 647, 661, 665, 687, 700, 703, 705, 706, 715, 727, 737, 745, 747, 748, 749, 150 | 755, 761, 765, 768, 777, 779, 784, 793, 799, 809, 813, 817, 824, 833, 835, 151 | 836, 840, 843, 844, 852, 857, 859, 869, 880, 881, 884, 890, 895, 906, 910, 152 | 915, 925, 927, 934, 955, 964, 969, 985, 986, 987, 988, 991 153 | ], 154 | "jumbo": [ 155 | 1, 2, 5, 6, 7, 10, 11, 16, 17, 18, 19, 21, 22, 23, 24, 25, 27, 28, 29, 30, 156 | 31, 32, 33, 35, 43, 45, 46, 49, 50, 51, 52, 53, 58, 61, 67, 68, 72, 75, 77, 157 | 78, 79, 82, 85, 86, 89, 91, 92, 94, 96, 97, 100, 101, 102, 103, 104, 105, 158 | 106, 109, 110, 112, 114, 115, 116, 117, 120, 123, 124, 125, 127, 130, 131, 159 | 133, 134, 137, 139, 140, 141, 142, 146, 149, 150, 151, 152, 154, 157, 163, 160 | 165, 166, 167, 168, 169, 170, 171, 172, 174, 175, 178, 179, 180, 181, 182, 161 | 184, 185, 186, 189, 193, 194, 195, 196, 197, 198, 200, 203, 207, 208, 214, 162 | 216, 217, 218, 219, 220, 222, 223, 224, 226, 227, 231, 232, 233, 234, 236, 163 | 238, 240, 242, 243, 244, 245, 246, 247, 252, 253, 254, 256, 258, 259, 260, 164 | 261, 262, 263, 265, 266, 267, 269, 273, 276, 278, 279, 282, 289, 291, 296, 165 | 298, 299, 307, 308, 311, 312, 316, 317, 318, 322, 324, 327, 329, 332, 333, 166 | 334, 336, 338, 340, 346, 347, 348, 353, 356, 357, 358, 360, 366, 372, 373, 167 | 376, 378, 380, 381, 383, 385, 386, 389, 390, 396, 398, 401, 404, 406, 407, 168 | 409, 414, 416, 417, 419, 421, 422, 424, 427, 428, 431, 432, 434, 435, 436, 169 | 437, 439, 441, 444, 445, 446, 447, 451, 452, 453, 455, 457, 459, 462, 463, 170 | 466, 467, 468, 470, 471, 477, 478, 480, 482, 483, 491, 494, 496, 497, 499, 171 | 500, 502, 505, 507, 508, 510, 511, 512, 513, 514, 515, 517, 518, 519, 524, 172 | 525, 528, 530, 531, 533, 534, 536, 538, 539, 541, 544, 547, 548, 549, 551, 173 | 554, 555, 560, 561, 562, 564, 566, 568, 569, 570, 575, 577, 578, 579, 580, 174 | 583, 584, 585, 587, 589, 590, 593, 594, 596, 599, 601, 604, 605, 608, 609, 175 | 613, 615, 616, 617, 621, 624, 625, 627, 628, 629, 630, 633, 636, 638, 639, 176 | 641, 642, 643, 646, 649, 650, 652, 658, 662, 663, 664, 666, 670, 671, 672, 177 | 676, 677, 678, 679, 683, 684, 685, 686, 688, 692, 693, 694, 695, 699, 704, 178 | 707, 708, 709, 711, 712, 716, 717, 719, 720, 723, 730, 733, 734, 735, 736, 179 | 738, 740, 741, 743, 746, 751, 752, 757, 758, 759, 760, 762, 763, 766, 769, 180 | 770, 772, 774, 775, 776, 778, 780, 781, 783, 785, 787, 789, 790, 791, 792, 181 | 794, 801, 802, 804, 805, 806, 808, 811, 814, 815, 819, 820, 822, 823, 825, 182 | 826, 827, 829, 832, 838, 839, 842, 846, 849, 850, 851, 853, 855, 858, 861, 183 | 862, 863, 864, 872, 873, 874, 875, 876, 877, 882, 883, 885, 886, 887, 888, 184 | 894, 896, 897, 898, 899, 900, 901, 902, 903, 905, 907, 908, 911, 913, 914, 185 | 916, 917, 919, 920, 922, 924, 928, 929, 930, 931, 932, 933, 936, 937, 940, 186 | 942, 943, 946, 947, 948, 949, 951, 952, 953, 954, 956, 959, 960, 961, 962, 187 | 963, 965, 967, 968, 970, 972, 973, 974, 975, 978, 979, 980, 982, 984, 989, 188 | 990, 992, 993, 995, 996, 998 189 | ], 190 | "uniform": [ 191 | 3, 13, 15, 20, 26, 38, 39, 42, 54, 56, 59, 60, 62, 65, 73, 74, 88, 93, 99, 192 | 111, 113, 121, 122, 128, 132, 135, 136, 138, 144, 145, 147, 159, 161, 164, 193 | 187, 190, 192, 201, 202, 204, 211, 212, 213, 228, 235, 239, 249, 251, 255, 194 | 268, 274, 281, 283, 285, 287, 293, 294, 295, 300, 301, 303, 305, 315, 323, 195 | 330, 335, 339, 343, 351, 359, 363, 364, 369, 370, 377, 384, 392, 395, 397, 196 | 399, 402, 420, 423, 425, 449, 454, 458, 461, 481, 485, 489, 490, 493, 501, 197 | 520, 532, 540, 542, 546, 550, 552, 557, 558, 559, 563, 565, 572, 581, 588, 198 | 592, 600, 606, 611, 620, 622, 626, 640, 648, 651, 653, 654, 656, 657, 659, 199 | 660, 668, 669, 673, 675, 682, 690, 691, 697, 702, 710, 713, 721, 722, 729, 200 | 732, 739, 742, 744, 750, 754, 756, 767, 773, 782, 786, 788, 795, 796, 797, 201 | 807, 810, 812, 816, 821, 828, 830, 837, 845, 854, 856, 860, 866, 868, 871, 202 | 878, 879, 891, 893, 904, 918, 921, 923, 926, 935, 939, 941, 945, 950, 957, 203 | 966, 976, 977, 981, 994 204 | ], 205 | "micro-uniform": [ 206 | 9, 40, 48, 76, 173, 205, 215, 221, 248, 280, 292, 310, 328, 352, 413, 415, 207 | 460, 479, 526, 595, 603, 607, 623, 631, 667, 718, 724, 764, 771, 803, 834, 208 | 841, 867, 889, 892, 938, 944, 971 209 | ], 210 | "medium": [ 211 | 14, 95, 98, 155, 158, 177, 250, 264, 271, 304, 313, 314, 344, 354, 374, 440, 212 | 450, 476, 504, 543, 598, 612, 614, 632, 674, 680, 681, 689, 701, 753, 800, 213 | 870, 958, 983, 997 214 | ], 215 | "jumbo-xl": [ 216 | 69, 70, 87, 257, 290, 337, 349, 367, 394, 405, 412, 456, 469, 475, 509, 567, 217 | 644, 655, 696, 714, 726, 728, 731, 798, 818, 831, 848, 865, 909, 912 218 | ], 219 | "small": [63, 81, 129, 162, 275, 365, 429, 545, 586, 591, 618, 698, 725, 847] 220 | } 221 | -------------------------------------------------------------------------------- /src/NamedMappings/geometryRunnersSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "single": [ 3 | 68, 166, 184, 289, 328, 376, 450, 528, 642, 662, 698, 759, 904, 925, 995 4 | ], 5 | "wire": [146, 212, 567, 642, 667, 698, 737, 748, 971] 6 | } 7 | -------------------------------------------------------------------------------- /src/NamedMappings/geometryRunnersSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "fastest": 706, 3 | "slowest": 698 4 | } 5 | -------------------------------------------------------------------------------- /src/NamedMappings/paperArmadaSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "moby": 1333, 3 | "🐋": 1333 4 | } 5 | -------------------------------------------------------------------------------- /src/NamedMappings/qilinSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "leviathan": 102, 3 | "uncoiling-dragon": 134, 4 | "anteater": 189, 5 | "black-heart": 191, 6 | "diving-swan": 388, 7 | "rude-snail": 494, 8 | "lion": 808, 9 | "horse": 887, 10 | "orca": 335, 11 | "bear": 370, 12 | "gorilla": 950, 13 | "woodpecker": 1000, 14 | "roadrunner": 566, 15 | "dove": 938, 16 | "sahara": 191, 17 | "rhino": 863, 18 | "howling-wolves": 223, 19 | "horse head": 754, 20 | "crab": 219, 21 | "rearing-dragon": 324, 22 | "water-dragon": 959, 23 | "motherbird": 555, 24 | "zebra": 304, 25 | "dancers": 1003 26 | } 27 | -------------------------------------------------------------------------------- /src/NamedMappings/ringerSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "simpsons": [ 3 | "23", 4 | "30", 5 | "44", 6 | "47", 7 | "61", 8 | "72", 9 | "98", 10 | "105", 11 | "167", 12 | "168", 13 | "178", 14 | "193", 15 | "211", 16 | "213", 17 | "235", 18 | "250", 19 | "275", 20 | "288", 21 | "293", 22 | "342", 23 | "343", 24 | "361", 25 | "373", 26 | "375", 27 | "410", 28 | "425", 29 | "443", 30 | "452", 31 | "456", 32 | "460", 33 | "463", 34 | "559", 35 | "573", 36 | "590", 37 | "608", 38 | "649", 39 | "686", 40 | "711", 41 | "715", 42 | "770", 43 | "771", 44 | "796", 45 | "797", 46 | "839", 47 | "840", 48 | "866", 49 | "879", 50 | "889", 51 | "893", 52 | "952", 53 | "956", 54 | "958", 55 | "964", 56 | "996" 57 | ], 58 | "blue-moon": [ 59 | "945", 60 | "82", 61 | "828", 62 | "243", 63 | "95", 64 | "151", 65 | "219", 66 | "135", 67 | "478", 68 | "489", 69 | "459", 70 | "466", 71 | "517", 72 | "498", 73 | "159", 74 | "889", 75 | "6", 76 | "50", 77 | "994", 78 | "83", 79 | "376", 80 | "354", 81 | "998", 82 | "283", 83 | "172" 84 | ], 85 | "bluemoon": [ 86 | "945", 87 | "82", 88 | "828", 89 | "243", 90 | "95", 91 | "151", 92 | "219", 93 | "135", 94 | "478", 95 | "489", 96 | "459", 97 | "466", 98 | "517", 99 | "498", 100 | "159", 101 | "889", 102 | "6", 103 | "50", 104 | "994", 105 | "83", 106 | "376", 107 | "354", 108 | "998", 109 | "283", 110 | "172" 111 | ], 112 | "rarest": ["417", "50", "44", "109", "280", "360", "52", "263", "286", "789"], 113 | "least-rare": [ 114 | "469", 115 | "779", 116 | "836", 117 | "85", 118 | "114", 119 | "295", 120 | "909", 121 | "671", 122 | "712", 123 | "936", 124 | "833", 125 | "890" 126 | ], 127 | "leastrare": [ 128 | "469", 129 | "779", 130 | "836", 131 | "85", 132 | "114", 133 | "295", 134 | "909", 135 | "671", 136 | "712", 137 | "936", 138 | "833", 139 | "890" 140 | ], 141 | "block": [ 142 | "4", 143 | "65", 144 | "74", 145 | "124", 146 | "137", 147 | "202", 148 | "209", 149 | "242", 150 | "246", 151 | "255", 152 | "346", 153 | "394", 154 | "450", 155 | "473", 156 | "479", 157 | "485", 158 | "515", 159 | "547", 160 | "576", 161 | "694", 162 | "737", 163 | "774", 164 | "795", 165 | "908", 166 | "928", 167 | "987" 168 | ], 169 | "artist": [ 170 | "240", 171 | "57", 172 | "879", 173 | "266", 174 | "109", 175 | "181", 176 | "280", 177 | "327", 178 | "415", 179 | "439", 180 | "478", 181 | "483", 182 | "593", 183 | "360", 184 | "546", 185 | "638", 186 | "104", 187 | "873", 188 | "599", 189 | "614", 190 | "915", 191 | "974", 192 | "647", 193 | "819", 194 | "311", 195 | "962", 196 | "439", 197 | "35", 198 | "6", 199 | "765", 200 | "923", 201 | "973", 202 | "71", 203 | "993", 204 | "625", 205 | "376" 206 | ], 207 | "animal": [ 208 | "184", 209 | "666", 210 | "803", 211 | "333", 212 | "879", 213 | "532", 214 | "907", 215 | "57", 216 | "998", 217 | "389", 218 | "492", 219 | "704", 220 | "311", 221 | "52", 222 | "373", 223 | "676" 224 | ], 225 | "maximalist": [ 226 | "57", 227 | "81", 228 | "109", 229 | "160", 230 | "176", 231 | "206", 232 | "278", 233 | "319", 234 | "360", 235 | "637", 236 | "641", 237 | "700", 238 | "777", 239 | "892" 240 | ], 241 | "blood-clot": [ 242 | "33", 243 | "63", 244 | "67", 245 | "68", 246 | "132", 247 | "149", 248 | "297", 249 | "333", 250 | "360", 251 | "365", 252 | "429", 253 | "550", 254 | "585", 255 | "859", 256 | "957", 257 | "961", 258 | "963", 259 | "999" 260 | ], 261 | "bloodclot": [ 262 | "33", 263 | "63", 264 | "67", 265 | "68", 266 | "132", 267 | "149", 268 | "297", 269 | "333", 270 | "360", 271 | "365", 272 | "429", 273 | "550", 274 | "585", 275 | "859", 276 | "957", 277 | "961", 278 | "963", 279 | "999" 280 | ], 281 | "smol-boi": [ 282 | "1", 283 | "549", 284 | "945", 285 | "740", 286 | "916", 287 | "583", 288 | "492", 289 | "973", 290 | "979", 291 | "728", 292 | "747", 293 | "683", 294 | "766", 295 | "714", 296 | "775", 297 | "875", 298 | "829", 299 | "658", 300 | "813", 301 | "822", 302 | "112", 303 | "268", 304 | "286", 305 | "287", 306 | "152", 307 | "224", 308 | "435", 309 | "366", 310 | "213", 311 | "382", 312 | "160", 313 | "44", 314 | "159", 315 | "642", 316 | "291", 317 | "695" 318 | ], 319 | "smolboi": [ 320 | "1", 321 | "549", 322 | "945", 323 | "740", 324 | "916", 325 | "583", 326 | "492", 327 | "973", 328 | "979", 329 | "728", 330 | "747", 331 | "683", 332 | "766", 333 | "714", 334 | "775", 335 | "875", 336 | "829", 337 | "658", 338 | "813", 339 | "822", 340 | "112", 341 | "268", 342 | "286", 343 | "287", 344 | "152", 345 | "224", 346 | "435", 347 | "366", 348 | "213", 349 | "382", 350 | "160", 351 | "44", 352 | "159", 353 | "642", 354 | "291", 355 | "695" 356 | ], 357 | "green-background": ["625", "415"], 358 | "greenbackground": ["625", "415"], 359 | "tiled-5-6": ["277", "907"], 360 | "tiled56": ["277", "907"], 361 | "moneyball": ["616", "642", "29", "534"], 362 | "most-pegs": ["280", "44", "762", "789", "181", "646", "999", "423", "32"], 363 | "mostpegs": ["280", "44", "762", "789", "181", "646", "999", "423", "32"], 364 | "least-pegs": ["306", "461", "750", "949"], 365 | "leastpegs": ["306", "461", "750", "949"], 366 | "shrunken": [ 367 | "60", 368 | "284", 369 | "293", 370 | "327", 371 | "385", 372 | "420", 373 | "555", 374 | "593", 375 | "638", 376 | "673", 377 | "529", 378 | "793", 379 | "814", 380 | "879", 381 | "904", 382 | "952" 383 | ], 384 | "simplicity": ["84", "881", "760", "983", "891", "744", "355", "296", "177"], 385 | "diagonal-symmetry": [ 386 | "75", 387 | "114", 388 | "389", 389 | "969", 390 | "729", 391 | "781", 392 | "908", 393 | "909", 394 | "735" 395 | ], 396 | "diagonalsymmetry": [ 397 | "75", 398 | "114", 399 | "389", 400 | "969", 401 | "729", 402 | "781", 403 | "908", 404 | "909", 405 | "735" 406 | ] 407 | } 408 | -------------------------------------------------------------------------------- /src/NamedMappings/ringerSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "dmitri": "0", 3 | "goose": "879", 4 | "rabbit": "790", 5 | "rudolph": "311", 6 | "sisyphus": "173", 7 | "sandcrawler": "795", 8 | "ambulance": "911", 9 | "moose": "803", 10 | "bull": "803", 11 | "bear": "559", 12 | "ponyo": "791", 13 | "pup": "495", 14 | "floppy": "359", 15 | "juggler": "913", 16 | "whisperer": "932", 17 | "fishy": "666", 18 | "squid": "717", 19 | "taxi-flagger": "224", 20 | "cthulhu": "915", 21 | "half-dome": "346", 22 | "airplane": "406", 23 | "hands-raised": "70", 24 | "god": "360", 25 | "sothebys": "20", 26 | "grail": "923", 27 | "sid": "998", 28 | "christies": "322", 29 | "comet": "373", 30 | "golden-ticket": "718", 31 | "roll-of-the-dice": "729", 32 | "figure-skater": "754", 33 | "🙌": "70", 34 | "✈️": "406", 35 | "🐠": "666", 36 | "💾": "359", 37 | "🦑": "717", 38 | "🐂": "803", 39 | "🐻": "559", 40 | "🐇": "790", 41 | "🐰": "790", 42 | "🚑": "911", 43 | "🤹‍♂️": "913", 44 | "🤹‍♀️": "913", 45 | "🤹": "913", 46 | "🦢": "879" 47 | } 48 | -------------------------------------------------------------------------------- /src/NamedMappings/screensSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "space-station": 957, 3 | "faia": 395, 4 | "sailboat": 975 5 | } 6 | -------------------------------------------------------------------------------- /src/NamedMappings/scribbledSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "josef": [ 3 | 6, 17, 164, 241, 255, 267, 270, 280, 323, 330, 383, 389, 502, 688, 768, 901, 4 | 1012 5 | ], 6 | "gorchov": [ 7 | 22, 101, 141, 204, 214, 217, 367, 469, 479, 510, 555, 592, 595, 606, 653, 8 | 658, 672, 675, 802, 804, 915, 989, 990, 1000 9 | ], 10 | "helen": [ 11 | 12, 48, 56, 122, 142, 156, 160, 344, 351, 360, 374, 489, 581, 615, 693, 727, 12 | 737, 745, 766, 860, 866, 869, 874, 927, 938, 970, 992, 1009 13 | ], 14 | "sugimoto": [ 15 | 2, 4, 32, 47, 52, 58, 67, 92, 157, 189, 216, 239, 240, 253, 256, 288, 296, 16 | 382, 390, 428, 446, 456, 486, 492, 507, 516, 521, 537, 605, 739, 772, 787, 17 | 828, 881, 888, 904, 916, 925, 974, 996 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/NamedMappings/scribbledSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "tetris": "582", 3 | "order": "640", 4 | "chaos": "578", 5 | "baby": "310", 6 | "cotton-candy": "13", 7 | "parachute": "601", 8 | "towers": "1002", 9 | "3d-glasses": "1021" 10 | } 11 | -------------------------------------------------------------------------------- /src/NamedMappings/subscapeSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "starfall": [196, 377, 464, 506, 524, 606], 3 | "ballpointpencil": [ 4 | 15, 68, 81, 94, 100, 102, 108, 109, 114, 117, 121, 128, 130, 191, 207, 220, 5 | 238, 309, 330, 346, 356, 367, 409, 420, 440, 499, 510, 530, 536, 540, 548, 6 | 584, 607, 608, 611, 620, 635 7 | ], 8 | "wireframe": [ 9 | 150, 141, 552, 592, 129, 147, 152, 262, 283, 307, 365, 504, 524, 579, 588, 10 | 631, 349 11 | ], 12 | "hill": [ 13 | 22, 113, 477, 191, 611, 568, 316, 258, 626, 472, 234, 287, 423, 216, 438, 14 | 595, 479, 502, 528, 8, 9, 23, 145, 168, 205, 257, 272, 286, 298, 325, 410, 15 | 415, 427, 459, 461, 487, 504, 506, 516, 524, 525, 531, 565, 261, 350 16 | ], 17 | "summit": [ 18 | 578, 620, 192, 389, 357, 223, 562, 33, 265, 573, 78, 41, 50, 79, 96, 97, 19 | 193, 196, 218, 255, 296, 297, 334, 402, 421, 422, 430, 495, 508, 567, 599, 20 | 615, 28 21 | ], 22 | "waterfall": [25, 80, 115, 215], 23 | "rare": [ 24 | 165, 265, 521, 438, 552, 531, 362, 14, 92, 190, 641, 317, 389, 350, 218, 25 | 611, 223, 150, 210, 433, 616, 179, 368, 43, 192 26 | ], 27 | "grid": [ 28 | 265, 362, 218, 150, 210, 513, 599, 480, 535, 196, 454, 555, 402, 7, 3, 266, 29 | 86, 426, 292, 324, 637, 587, 12, 46, 451, 305, 202, 358, 201, 386, 211, 293, 30 | 570, 576, 597, 75, 572, 447, 640, 320, 489, 391, 161, 552, 531, 14, 433, 31 | 179, 390, 97, 42, 582, 285, 485, 512, 408, 131 32 | ], 33 | "stippled": [ 34 | 165, 616, 502, 191, 353, 471, 221, 334, 226, 309, 233, 260, 493, 416, 487, 35 | 253, 587, 342, 93, 219, 370, 177, 87, 624, 130, 367, 142, 625, 629, 494, 36 | 523, 537, 26, 303, 445, 24, 27, 407, 542, 281, 646, 108, 420, 355, 372, 395, 37 | 63, 120, 379, 396, 435, 544, 483 38 | ], 39 | "river": [ 40 | 438, 242, 125, 67, 168, 292, 290, 366, 452, 614, 618, 181, 188, 377, 380, 41 | 511, 51, 124, 58, 111, 246, 92, 190, 433, 179, 368, 43, 390, 497, 337, 388, 42 | 638 43 | ], 44 | "invert": [263, 615, 614, 146, 60, 623, 29, 259, 531, 42, 277, 378, 93, 219], 45 | "metallic": [ 46 | 402, 193, 41, 422, 511, 447, 640, 10, 106, 133, 268, 333, 154, 175, 229, 47 | 425, 469, 179, 497, 139, 213, 443, 547, 336, 352, 381, 449, 596 48 | ], 49 | "coast": [ 50 | 641, 332, 455, 137, 21, 347, 645, 384, 226, 521, 575, 282, 555, 209, 548, 51 | 13, 0, 289, 19, 468, 164, 475, 84, 146, 31, 71, 81, 375, 429, 424, 617, 48, 52 | 340, 632, 180, 284, 541, 317 53 | ], 54 | "drift": [ 55 | 21, 9, 136, 143, 170, 291, 360, 405, 589, 80, 430, 180, 284, 541, 161, 11, 56 | 163, 237, 354, 403, 507, 532, 556, 634 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /src/NamedMappings/subscapeSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "hellscape": 353 3 | } 4 | -------------------------------------------------------------------------------- /src/NamedMappings/watercolorDreamSets.json: -------------------------------------------------------------------------------- 1 | { 2 | "flexible": [ 3 | 28, 36, 47, 145, 156, 165, 197, 203, 272, 386, 390, 408, 419, 426, 466, 476, 4 | 505, 509, 516, 547, 573, 586 5 | ], 6 | "puzzling": [ 7 | 13, 14, 22, 33, 76, 117, 138, 171, 189, 196, 209, 261, 310, 321, 334, 359, 8 | 412, 477, 492, 519, 533, 549, 583, 592 9 | ], 10 | "lustful": [ 11 | 57, 102, 113, 139, 194, 229, 237, 245, 295, 362, 369, 434, 437, 469, 498, 12 | 512, 524, 538, 568, 596 13 | ], 14 | "passionate": [ 15 | 55, 100, 106, 121, 146, 205, 219, 273, 279, 281, 301, 373, 382, 393, 460, 16 | 570, 597 17 | ], 18 | "whimsical": [3, 25, 94, 114, 177, 220, 230, 327, 337, 411, 539, 551, 554], 19 | "ecstatic": [4, 127, 216, 218, 250, 329, 357, 363, 449, 457, 501, 560], 20 | "mysterious": [ 21 | 6, 69, 118, 131, 174, 178, 234, 343, 360, 367, 395, 413, 421, 423, 430, 470, 22 | 475, 485 23 | ], 24 | "vivacious": [ 25 | 66, 98, 135, 157, 188, 224, 225, 233, 243, 251, 264, 265, 278, 280, 283, 26 | 361, 371, 474, 486, 572, 576, 588, 598 27 | ], 28 | "bashful": [ 29 | 82, 120, 158, 201, 202, 232, 275, 292, 326, 345, 429, 433, 442, 546, 557, 30 | 584, 599 31 | ], 32 | "confident": [ 33 | 23, 35, 101, 200, 226, 256, 313, 341, 355, 356, 400, 405, 450, 468, 478, 503 34 | ], 35 | "fiery": [ 36 | 136, 140, 147, 190, 276, 309, 335, 385, 432, 440, 441, 445, 453, 510, 555 37 | ], 38 | "mellow": [ 39 | 7, 59, 71, 168, 179, 240, 293, 304, 324, 339, 406, 424, 431, 494, 515, 559 40 | ], 41 | "mystical": [ 42 | 0, 56, 61, 105, 119, 123, 176, 191, 208, 217, 284, 300, 325, 480, 529, 580 43 | ], 44 | "reserved": [ 45 | 63, 267, 296, 317, 383, 389, 403, 435, 479, 489, 493, 535, 536, 581, 593 46 | ], 47 | "secretive": [26, 45, 84, 95, 111, 122, 130, 192, 269, 289, 507], 48 | "toxic": [ 49 | 5, 9, 210, 221, 231, 255, 259, 274, 290, 298, 368, 374, 377, 401, 461, 481, 50 | 525, 564, 569 51 | ], 52 | "stimulated": [ 53 | 12, 32, 38, 64, 160, 166, 181, 239, 246, 299, 311, 323, 344, 366, 396, 410, 54 | 422, 463, 508, 548, 562, 590 55 | ], 56 | "peaceful": [ 57 | 24, 29, 50, 75, 112, 150, 151, 163, 170, 228, 297, 303, 319, 331, 436, 444, 58 | 452, 454, 487, 506, 567, 595 59 | ], 60 | "royal": [ 61 | 8, 15, 62, 80, 141, 161, 236, 244, 336, 340, 350, 351, 387, 446, 462, 471, 62 | 511, 543 63 | ], 64 | "fierce": [ 65 | 16, 18, 48, 91, 109, 115, 152, 187, 198, 241, 247, 249, 286, 320, 365, 380, 66 | 384, 388, 394, 417, 420, 425, 447, 520, 532 67 | ], 68 | "fearless": [ 69 | 21, 27, 31, 39, 41, 116, 129, 132, 137, 144, 148, 182, 211, 302, 306, 375, 70 | 376, 399, 472, 527, 553, 578, 587 71 | ], 72 | "reasonable": [ 73 | 2, 44, 70, 110, 164, 263, 314, 316, 358, 407, 409, 427, 473, 484, 521, 566, 74 | 585 75 | ], 76 | "organic": [ 77 | 19, 49, 51, 67, 74, 93, 107, 124, 223, 254, 288, 364, 391, 392, 418, 465, 78 | 488, 502, 518, 523, 563, 565, 571, 579, 591 79 | ], 80 | "dynamic": [ 81 | 17, 92, 96, 154, 159, 169, 252, 257, 270, 282, 287, 346, 414, 528, 545, 577 82 | ], 83 | "energetic": [ 84 | 34, 103, 125, 167, 262, 338, 348, 379, 397, 415, 455, 459, 495, 517 85 | ], 86 | "undomesticated": [ 87 | 65, 89, 108, 149, 183, 186, 195, 212, 227, 315, 381, 438, 448, 451, 531 88 | ], 89 | "boisterous": [ 90 | 30, 42, 68, 85, 88, 128, 134, 143, 162, 214, 242, 285, 308, 372, 402, 526, 91 | 552, 556, 575, 594 92 | ], 93 | "satisfied": [ 94 | 54, 78, 79, 86, 184, 193, 206, 260, 291, 328, 330, 342, 347, 354, 439, 456, 95 | 540, 558, 561, 582, 589 96 | ], 97 | "bubbly": [20, 81, 87, 90, 99, 173, 215, 312, 378, 416, 464, 499, 541, 542], 98 | "wild": [ 99 | 73, 77, 83, 126, 133, 155, 185, 352, 370, 458, 467, 504, 530, 537, 574 100 | ], 101 | "conflicted": [ 102 | 40, 43, 46, 53, 58, 72, 97, 104, 153, 238, 266, 482, 483, 491, 534, 550 103 | ], 104 | "balanced": [ 105 | 11, 37, 199, 213, 268, 271, 305, 318, 322, 332, 349, 490, 496, 500 106 | ], 107 | "perplexed": [10, 175, 204, 294, 398, 404, 428, 544], 108 | "dignified": [52, 172, 207, 222, 235, 253, 258, 277, 353, 443, 497, 514, 522], 109 | "playful": [1, 60, 142, 248, 307, 333, 513], 110 | "intense": [180], 111 | "centerCircles": [ 112 | 6, 26, 58, 80, 92, 98, 109, 130, 160, 174, 198, 220, 243, 262, 296, 313, 113 | 391, 402, 437, 448, 452, 465, 466, 483, 487, 490, 509, 518, 535, 553, 576, 114 | 582 115 | ], 116 | "centerSquares": [ 117 | 11, 84, 89, 105, 111, 115, 119, 133, 140, 148, 152, 154, 228, 236, 253, 258, 118 | 273, 291, 295, 305, 333, 345, 369, 386, 403, 436, 441, 478, 500, 548, 570, 119 | 588 120 | ], 121 | "verticalLines": [ 122 | 42, 60, 76, 82, 83, 93, 114, 132, 138, 168, 214, 223, 224, 229, 238, 276, 123 | 311, 312, 319, 342, 343, 352, 361, 363, 372, 374, 376, 396, 421, 423, 453, 124 | 523, 539, 575 125 | ], 126 | "horizontalLines": [ 127 | 38, 107, 164, 182, 194, 219, 222, 239, 245, 324, 347, 373, 380, 388, 389, 128 | 415, 430, 433, 434, 455, 458, 461, 464, 472, 511, 532, 542, 560, 565 129 | ], 130 | "verticalCircles": [ 131 | 17, 18, 23, 29, 36, 78, 104, 116, 144, 151, 155, 180, 184, 205, 206, 209, 132 | 213, 252, 287, 321, 326, 330, 378, 382, 383, 422, 425, 447, 469, 505, 522, 133 | 529, 545, 572, 573 134 | ], 135 | "horizontalCircles": [ 136 | 15, 20, 53, 59, 62, 63, 66, 71, 73, 77, 88, 131, 136, 158, 175, 187, 188, 137 | 193, 199, 207, 221, 227, 230, 242, 246, 264, 315, 331, 340, 341, 367, 384, 138 | 412, 450, 451, 454, 476, 477, 491, 493, 502, 527, 528, 531, 554, 584, 587, 139 | 589 140 | ], 141 | "cornerCircles": [ 142 | 30, 46, 51, 52, 65, 69, 101, 127, 141, 145, 157, 172, 177, 191, 204, 210, 143 | 234, 235, 265, 292, 298, 299, 327, 411, 413, 419, 428, 444, 467, 524, 525, 144 | 534, 549, 561, 593, 596 145 | ], 146 | "cornerSquares": [ 147 | 1, 40, 57, 94, 95, 143, 161, 162, 163, 197, 208, 237, 244, 248, 255, 259, 148 | 332, 338, 357, 360, 364, 399, 418, 420, 440, 442, 446, 457, 481, 489, 501, 149 | 504, 514, 516, 520, 563, 580, 591, 592 150 | ], 151 | "edgeCircles": [ 152 | 7, 44, 67, 79, 96, 120, 165, 178, 216, 260, 266, 271, 286, 294, 329, 355, 153 | 359, 365, 377, 392, 427, 449, 456, 506, 508, 510, 512, 515, 537, 543, 546, 154 | 547, 551, 586 155 | ], 156 | "hashtag": [ 157 | 2, 9, 13, 21, 37, 86, 118, 139, 156, 169, 176, 190, 274, 279, 320, 348, 349, 158 | 350, 371, 385, 394, 471, 474, 486, 492, 499, 503, 569, 571, 574, 581, 585, 159 | 599 160 | ], 161 | "horizontalRectangles": [ 162 | 3, 4, 12, 47, 90, 108, 110, 121, 146, 159, 179, 195, 196, 218, 247, 256, 163 | 263, 268, 275, 300, 302, 314, 316, 337, 401, 406, 408, 409, 417, 443, 473, 164 | 479, 497, 517, 567, 579, 595 165 | ], 166 | "verticalRectangles": [ 167 | 14, 32, 112, 125, 128, 150, 153, 170, 212, 269, 281, 288, 293, 353, 426, 168 | 445, 485, 550, 558, 559, 590 169 | ], 170 | "edgeRectangles": [ 171 | 0, 8, 28, 31, 33, 41, 49, 61, 97, 103, 147, 183, 200, 202, 215, 232, 277, 172 | 280, 289, 297, 303, 328, 339, 404, 410, 432, 463, 495, 541, 555, 564, 568 173 | ], 174 | "rotatedSquare": [ 175 | 5, 27, 35, 39, 48, 87, 122, 123, 171, 186, 201, 226, 251, 257, 272, 283, 176 | 304, 308, 310, 334, 336, 351, 368, 381, 395, 398, 435, 468, 484, 513, 521, 177 | 533, 556, 578 178 | ], 179 | "star": [ 180 | 10, 16, 19, 22, 24, 25, 70, 91, 106, 124, 129, 166, 181, 192, 203, 217, 231, 181 | 267, 285, 301, 306, 309, 318, 322, 323, 325, 354, 356, 366, 375, 405, 414, 182 | 416, 439, 460, 475, 498, 507, 526, 530, 577, 598 183 | ], 184 | "x": [ 185 | 54, 55, 64, 68, 85, 99, 100, 102, 113, 117, 135, 142, 167, 189, 233, 250, 186 | 254, 270, 282, 284, 290, 344, 358, 370, 387, 397, 400, 407, 429, 431, 462, 187 | 480, 519, 544, 583 188 | ], 189 | "corner x": [ 190 | 34, 43, 50, 56, 72, 74, 75, 81, 126, 134, 137, 149, 185, 211, 225, 241, 249, 191 | 278, 307, 317, 335, 346, 379, 390, 393, 424, 438, 459, 470, 488, 494, 496, 192 | 536, 538, 552, 557, 562, 566, 594 193 | ], 194 | "letter": [45, 240, 362, 482, 540, 597], 195 | "confetti": [173, 261] 196 | } 197 | -------------------------------------------------------------------------------- /src/NamedMappings/watercolorDreamSingles.json: -------------------------------------------------------------------------------- 1 | { 2 | "d": 240, 3 | "p": 45, 4 | "t": 540, 5 | "v": 482, 6 | "w": 362, 7 | "x": 597 8 | } 9 | -------------------------------------------------------------------------------- /src/ProjectConfig/blockedEngineContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "MONROE": "0xff124d975c7792e706552b18ec9da24781751cab", 3 | "DOODLE": "0x28f2d3805652fb5d359486dffb7d08320d403240" 4 | } 5 | -------------------------------------------------------------------------------- /src/ProjectConfig/channels.json: -------------------------------------------------------------------------------- 1 | { 2 | "822563871473795073": { 3 | "name": "factory-projects" 4 | }, 5 | "880468116226850866": { 6 | "name": "ab-art-chat" 7 | }, 8 | "859521842317754369": { 9 | "name": "prod_all_activity" 10 | }, 11 | "411959613370400780": { 12 | "name": "general" 13 | }, 14 | "815980444905373786": { 15 | "name": "help" 16 | }, 17 | "859312767105105941": { 18 | "name": "sales-feed" 19 | }, 20 | "830924481627291709": { 21 | "name": "block-talk" 22 | }, 23 | "862722281503326288": { 24 | "name": "marfa" 25 | }, 26 | "976944919757348965": { 27 | "name": "engine-chat" 28 | }, 29 | "983836283954667530": { 30 | "name": "art-blocks-x-pace" 31 | }, 32 | "1067538362275201045": { 33 | "name": "art-blocks-x-bright-moments" 34 | }, 35 | "830940204441272369": { 36 | "name": "listing-feed" 37 | }, 38 | "859316381517348874": { 39 | "name": "squiggle-listings" 40 | }, 41 | "874066035618250752": { 42 | "name": "trade-swaps" 43 | }, 44 | "782644400084877315": { 45 | "name": "for-sale-listings" 46 | }, 47 | "782311037172973618": { 48 | "name": "artblocks-mints" 49 | }, 50 | "1248032607900532876": { 51 | "name": "studio-mints" 52 | }, 53 | "1039653873066979410": { 54 | "name": "explorations-mints" 55 | }, 56 | "1039653771862610001": { 57 | "name": "explorations-sales" 58 | }, 59 | "1039653937810255872": { 60 | "name": "explorations-listings" 61 | }, 62 | "1014641051140624445": { 63 | "name": "engine-mints" 64 | }, 65 | "1039654066122399785": { 66 | "name": "engine-sales" 67 | }, 68 | "1039654132279156826": { 69 | "name": "engine-listings" 70 | }, 71 | "935377049089097758": { 72 | "name": "plottables-mints" 73 | }, 74 | "907425636455444520": { 75 | "name": "plottables-sales" 76 | }, 77 | "907425686531227678": { 78 | "name": "plottables-listings" 79 | }, 80 | "1062938848105078824": { 81 | "name": "flutter-sales" 82 | }, 83 | "987113563753701459": { 84 | "name": "tender-sales" 85 | }, 86 | "1076664032146235432": { 87 | "name": "hodlers-mints" 88 | }, 89 | "1074004089551196180": { 90 | "name": "hodlers-sales" 91 | }, 92 | "1050375648323051650": { 93 | "name": "hodlers-listings" 94 | }, 95 | "1109104401135718460": { 96 | "name": "rozendaal-mints" 97 | }, 98 | "919937880288284722": { 99 | "name": "plottables-numbersinmotion", 100 | "projectBotHandlers": { 101 | "default": "0-PLOTTABLES", 102 | "stringTriggers": { 103 | "0-PLOTTABLES": ["streamlines"] 104 | } 105 | } 106 | }, 107 | "919937961632600064": { 108 | "name": "plottables-generative-artworks", 109 | "projectBotHandlers": { 110 | "default": "1-PLOTTABLES", 111 | "stringTriggers": { 112 | "1-PLOTTABLES": ["implosion"] 113 | } 114 | } 115 | }, 116 | "888929189862977646": { 117 | "name": "generative-artworks-democracity", 118 | "projectBotHandlers": { 119 | "default": "162" 120 | } 121 | }, 122 | "888929212369600572": { 123 | "name": "generative-artworks-enchiridion", 124 | "projectBotHandlers": { 125 | "default": "101" 126 | } 127 | }, 128 | "888929231147499561": { 129 | "name": "generative-artworks-empyrean", 130 | "projectBotHandlers": { 131 | "default": "33" 132 | } 133 | }, 134 | "934860865713360976": { 135 | "name": "plottables-dandan-dca", 136 | "projectBotHandlers": { 137 | "default": "2-PLOTTABLES", 138 | "stringTriggers": { 139 | "2-PLOTTABLES": ["really random rock", "rocks", "rrr"] 140 | } 141 | } 142 | }, 143 | "938856354242773072": { 144 | "name": "plottables-joshua-schachter", 145 | "projectBotHandlers": { 146 | "default": "3-PLOTTABLES", 147 | "stringTriggers": { 148 | "3-PLOTTABLES": ["diatom"] 149 | } 150 | } 151 | }, 152 | "939205416754217021": { 153 | "name": "plottables-larswander", 154 | "projectBotHandlers": { 155 | "default": "4-PLOTTABLES", 156 | "stringTriggers": { 157 | "4-PLOTTABLES": ["lines walking", "lines"] 158 | } 159 | } 160 | }, 161 | "941381112280805396": { 162 | "name": "plottables-beervangeer", 163 | "projectBotHandlers": { 164 | "default": "5-PLOTTABLES", 165 | "stringTriggers": { 166 | "5-PLOTTABLES": ["coalescence"] 167 | } 168 | } 169 | }, 170 | "945430500082851861": { 171 | "name": "plottables-greweb", 172 | "projectBotHandlers": { 173 | "default": "6-PLOTTABLES", 174 | "stringTriggers": { 175 | "6-PLOTTABLES": ["shattered"] 176 | } 177 | } 178 | }, 179 | "948246487299661824": { 180 | "name": "plottables-oppos", 181 | "projectBotHandlers": { 182 | "default": "8-PLOTTABLES", 183 | "stringTriggers": { 184 | "8-PLOTTABLES": ["spectral"] 185 | } 186 | } 187 | }, 188 | "1037778085375905812": { 189 | "name": "stevie-p-sales" 190 | }, 191 | "1037834993004720158": { 192 | "name": "stevie-p-listings" 193 | }, 194 | "1061341199841099777": { 195 | "name": "ixnayokay-sales" 196 | }, 197 | "1077311249701945504": { 198 | "name": "owmo-sales" 199 | }, 200 | "1223717407747735568": { 201 | "name": "joshbagley-sales" 202 | }, 203 | "891419702608429056": { 204 | "name": "edg-sales" 205 | }, 206 | "1245439355837747220": { 207 | "name": "edg-listings" 208 | }, 209 | "891283669103280148": { 210 | "name": "edg-mints" 211 | }, 212 | "1160978810368426124": { 213 | "name": "remnynt-sales" 214 | }, 215 | "1161769999141187614": { 216 | "name": "artbot-test-channel" 217 | }, 218 | 219 | "947051892834443347": { 220 | "name": "phenomena-general", 221 | "projectBotHandlers": { 222 | "default": "472", 223 | "stringTriggers": { 224 | "291": ["apex", "apx"], 225 | "413": ["longing", "lng"] 226 | } 227 | } 228 | }, 229 | 230 | "1011909976178044969": { 231 | "name": "phenomena-longing", 232 | "projectBotHandlers": { 233 | "default": "472", 234 | "stringTriggers": { 235 | "413": ["longing", "lng"] 236 | } 237 | } 238 | }, 239 | "958643782725799988": { 240 | "name": "phenomena-apex", 241 | "projectBotHandlers": { 242 | "default": "472", 243 | "stringTriggers": { 244 | "291": ["apex", "apx"] 245 | } 246 | } 247 | }, 248 | "1238135049874833438": { 249 | "name": "proof-all" 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/ProjectConfig/channels_dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "800425629785784352": { 3 | "name": "snowfro", 4 | "projectBotHandlers": { 5 | "default": "0" 6 | } 7 | }, 8 | "813431078705561630": { 9 | "name": "dmitri-cherniak", 10 | "projectBotHandlers": { 11 | "default": "13", 12 | "stringTriggers": { 13 | "22": ["eternal", "pump"], 14 | "2-PLOTTABLES": ["plottables"], 15 | "1-EXPLORATIONS": ["marfa yucca", "yucca"], 16 | "0-AB_X_BM": ["metropolis", "metro"], 17 | "7-AB_X_PACE_V3": ["schema"] 18 | }, 19 | "tokenIdTriggers": [ 20 | { 21 | "0": [1000, null] 22 | } 23 | ] 24 | } 25 | }, 26 | "838422388478836786": { 27 | "name": "numbersinmotion", 28 | "projectBotHandlers": { 29 | "default": "59" 30 | } 31 | }, 32 | "785144843986665475": { 33 | "name": "general" 34 | }, 35 | "974344974852296705": { 36 | "name": "block-talk" 37 | }, 38 | "1138956741313310741": { 39 | "name": "marfa" 40 | }, 41 | "1005194129250197594": { 42 | "name": "engine-chat" 43 | }, 44 | "816571167150309377": { 45 | "name": "help" 46 | }, 47 | "925645754402492436": { 48 | "name": "ab-art-chat" 49 | }, 50 | "837163522801664010": { 51 | "name": "listing-feed" 52 | }, 53 | "837163500107071519": { 54 | "name": "sales-feed" 55 | }, 56 | "988891386373885962": { 57 | "name": "art-blocks-x-pace" 58 | }, 59 | "1007693989350211685": { 60 | "name": "trade-swaps" 61 | }, 62 | "981922516215955476": { 63 | "name": "artblocks-mints" 64 | }, 65 | "795703663955017782": { 66 | "name": "explorations-sales" 67 | }, 68 | "825988122122649621": { 69 | "name": "explorations-listings" 70 | }, 71 | "814275283951681558": { 72 | "name": "engine-sales" 73 | }, 74 | "1039671456080072736": { 75 | "name": "engine-listings" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ProjectConfig/collaborationContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "AB_X_PACE": "0x64780ce53f6e966e18a22af13a2f97369580ec11", 3 | "AB_X_PACE_V3": "0xea698596b6009a622c3ed00dd5a8b5d1cae4fc36", 4 | "AB_X_BM": "0x145789247973c5d612bf121e9e4eef84b63eb707" 5 | } 6 | -------------------------------------------------------------------------------- /src/ProjectConfig/contract_aliases.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "aliases": ["bm", "bright moments"], 4 | "named_contracts": [ 5 | "Bright Moments Flex", 6 | "MOMENT", 7 | "Art Blocks x Bright Moments", 8 | "Bright Moments II", 9 | "CryptoCitizens" 10 | ] 11 | }, 12 | { 13 | "aliases": ["plottables"], 14 | "named_contracts": ["Plottables Flex", "Plottables"] 15 | }, 16 | { 17 | "aliases": ["pace"], 18 | "named_contracts": ["Art Blocks x Pace (V3)", "Art Blocks x Pace"] 19 | }, 20 | { 21 | "aliases": ["hodler", "hodlers", "hodlers collective"], 22 | "named_contracts": ["Hodlers Projects"] 23 | }, 24 | { 25 | "aliases": ["verticalcrypto"], 26 | "named_contracts": ["VerticalCrypto Gen Art"] 27 | }, 28 | { 29 | "aliases": ["proof"], 30 | "named_contracts": ["PROOF Blocks", "PROOF Blocks 2"] 31 | }, 32 | { 33 | "aliases": ["sothebys"], 34 | "named_contracts": ["Sothebys Gen Art"] 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /src/ProjectConfig/coreContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "OG": "0x059edd72cd353df5106d2b9cc5ab83a52287ac3a", 3 | "V2": "0xa7d8d9ef8d8ce8992df33d8b8cf4aebabd5bd270", 4 | "V3": "0x99a9b7c1116f9ceeb1652de04d5969cce509b069", 5 | "CURATED_V1": "0xab0000000000aa06f89b268d604a9c1c41524ac6" 6 | } 7 | -------------------------------------------------------------------------------- /src/ProjectConfig/explorationsContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "EXPLORATIONS": "0x942bc2d3e7a589fe5bd4a5c6ef9727dfd82f5c8a" 3 | } 4 | -------------------------------------------------------------------------------- /src/ProjectConfig/mintBotConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "CORE": ["artblocks-mints"], 3 | "EXPLORATIONS": ["artblocks-mints"], 4 | "STAGING": ["ab-art-chat"], 5 | "COLLAB": ["artblocks-mints"], 6 | "ENGINE": ["engine-mints"], 7 | "PLOTTABLES": ["plottables-mints"], 8 | "PLOTTABLESV3": ["plottables-mints"], 9 | "ROZENDAAL": ["rozendaal-mints"], 10 | "HODLERS": ["hodlers-mints"], 11 | "HODLERS-PASS": ["hodlers-mints"], 12 | "STUDIO": ["studio-mints"] 13 | } 14 | -------------------------------------------------------------------------------- /src/ProjectConfig/partnerContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "PLOTTABLES": "0xa319c382a702682129fcbf55d514e61a16f97f9c", 3 | "PLOTTABLESV3": "0xac521ea7a83a3bc3f9f1e09f8300a6301743fb1f", 4 | "BM": "0x0a1bbd57033f57e7b6743621b79fcb9eb2ce3676", 5 | "ROZENDAAL": "0x68c01cb4733a82a58d5e7bb31bddbff26a3a35d5", 6 | "HODLERS": "0x9f79e46a309f804aa4b7b53a1f72c69137427794", 7 | "HODLERS-PASS": "0xd00495689d5161c511882364e0c342e12dcc5f08" 8 | } 9 | -------------------------------------------------------------------------------- /src/ProjectConfig/projectBots.json: -------------------------------------------------------------------------------- 1 | { 2 | "0": { 3 | "namedMappings": { 4 | "sets": "squiggleSets.json" 5 | } 6 | }, 7 | "13": { 8 | "namedMappings": { 9 | "sets": "ringerSets.json", 10 | "singles": "ringerSingles.json" 11 | } 12 | }, 13 | "23": { 14 | "namedMappings": { 15 | "sets": "archetypeSets.json" 16 | } 17 | }, 18 | "28": { 19 | "namedMappings": { 20 | "sets": "apparitionSets.json", 21 | "singles": "apparitionSingles.json" 22 | } 23 | }, 24 | "41": { 25 | "namedMappings": { 26 | "sets": "elementalsSets.json", 27 | "singles": "elementalsSingles.json" 28 | } 29 | }, 30 | "53": { 31 | "namedMappings": { 32 | "sets": "subscapeSets.json", 33 | "singles": "subscapeSingles.json" 34 | } 35 | }, 36 | "59": { 37 | "namedMappings": { 38 | "sets": "watercolorDreamSets.json", 39 | "singles": "watercolorDreamSingles.json" 40 | } 41 | }, 42 | "78": { 43 | "namedMappings": { 44 | "sets": "fidenzaSets.json" 45 | } 46 | }, 47 | "89": { 48 | "namedMappings": { 49 | "sets": "dreamSets.json", 50 | "singles": "dreamSingles.json" 51 | } 52 | }, 53 | "129": { 54 | "namedMappings": { 55 | "sets": "pigmentsSets.json" 56 | } 57 | }, 58 | "37": { 59 | "namedMappings": { 60 | "sets": "paperArmadaSets.json", 61 | "singles": "paperArmadaSingles.json" 62 | } 63 | }, 64 | "131": { 65 | "namedMappings": { 66 | "sets": "scribbledSets.json", 67 | "singles": "scribbledSingles.json" 68 | } 69 | }, 70 | "138": { 71 | "namedMappings": { 72 | "sets": "geometryRunnersSets.json", 73 | "singles": "geometryRunnersSingles.json" 74 | } 75 | }, 76 | "204": { 77 | "namedMappings": { 78 | "sets": "edificeSets.json" 79 | } 80 | }, 81 | "209": { 82 | "namedMappings": { 83 | "sets": "autologySets.json", 84 | "singles": "autologySingles.json" 85 | } 86 | }, 87 | "214": { 88 | "namedMappings": { 89 | "sets": "bentSets.json" 90 | } 91 | }, 92 | "255": { 93 | "namedMappings": { 94 | "sets": "screensSets.json", 95 | "singles": "screensSingles.json" 96 | } 97 | }, 98 | "282": { 99 | "namedMappings": { 100 | "singles": "qilinSingles.json" 101 | } 102 | }, 103 | "472": { 104 | "namedMappings": { 105 | "sets": "buuSets.json" 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/ProjectConfig/projectBots_dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "13": { 3 | "namedMappings": { 4 | "sets": "ringerSets.json", 5 | "singles": "ringerSingles.json" 6 | } 7 | }, 8 | "59": { 9 | "namedMappings": { 10 | "sets": "watercolorDreamSets.json", 11 | "singles": "watercolorDreamSingles.json" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ProjectConfig/projectConfig.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js' 2 | import * as dotenv from 'dotenv' 3 | dotenv.config() 4 | 5 | const ARTBOT_IS_PROD = 6 | process.env.ARTBOT_IS_PROD && 7 | process.env.ARTBOT_IS_PROD.toLowerCase() == 'true' 8 | console.log('ARTBOT_IS_PROD: ', ARTBOT_IS_PROD) 9 | const CHANNELS = ARTBOT_IS_PROD 10 | ? require('./channels.json') 11 | : require('./channels_dev.json') 12 | const PROJECT_BOTS = ARTBOT_IS_PROD 13 | ? require('./projectBots.json') 14 | : require('./projectBots_dev.json') 15 | import { ProjectBot } from '../Classes/ProjectBot' 16 | import { getProject } from '../Data/queryGraphQL' 17 | import { artIndexerBot } from '..' 18 | const PARTNER_CONTRACTS = require('../ProjectConfig/partnerContracts.json') 19 | const EXPLORATIONS_CONTRACTS = require('../ProjectConfig/explorationsContracts.json') 20 | const COLLAB_CONTRACTS = require('../ProjectConfig/collaborationContracts.json') 21 | 22 | type ProjectBotHandlers = { 23 | default: string 24 | stringTriggers?: { 25 | [projectId: string]: string[] 26 | } 27 | tokenIdTriggers?: { 28 | [projectId: string]: number[] 29 | }[] 30 | } 31 | 32 | type ChannelsJson = { 33 | [chId: string]: { 34 | name: string 35 | projectBotHandlers?: ProjectBotHandlers 36 | } 37 | } 38 | type ProjectBotsJson = { 39 | [projectId: string]: { 40 | namedMappings: { 41 | sets?: string 42 | singles?: string 43 | } 44 | } 45 | } 46 | 47 | // utility class that routes number messages for each channel 48 | class Channel { 49 | name: string 50 | hasProjectBotHandler: boolean 51 | default?: string 52 | stringTriggers?: { [key: string]: string[] } 53 | tokenIdTriggers?: { [key: string]: number[] }[] 54 | constructor(name: string, projectBotHandlers?: ProjectBotHandlers) { 55 | this.name = name 56 | this.hasProjectBotHandler = !!projectBotHandlers 57 | if (projectBotHandlers) { 58 | this.default = projectBotHandlers.default 59 | this.stringTriggers = projectBotHandlers.stringTriggers || undefined 60 | this.tokenIdTriggers = projectBotHandlers.tokenIdTriggers || undefined 61 | } 62 | } 63 | 64 | /* 65 | * This returns the appropriate project bot name to handle an incoming 66 | * number (^#) message, based on lowercase message content. 67 | * If any trigger words or trigger tokenID ranges are found, will 68 | * return name of appropriate non-default project bot. 69 | * If no trigger words or trigger tokenID ranges are found, will 70 | * return name of default project bot. 71 | * @return {string | null} name of project bot to handle message, null if 72 | * no project bot handlers defined for this Channel. 73 | */ 74 | botNameFromNumberMsgContent(msgContentLowercase: string) { 75 | if (!this.hasProjectBotHandler) { 76 | return null 77 | } 78 | // determine which project bot to send msg 79 | let projectBotName = this.default 80 | // match with any string triggers 81 | if (this.stringTriggers) { 82 | Object.entries(this.stringTriggers).forEach(([botName, triggers]) => { 83 | triggers.forEach((trigger) => { 84 | if (msgContentLowercase.includes(trigger)) { 85 | projectBotName = botName 86 | } 87 | }) 88 | }) 89 | } 90 | // match with any tokenID trigger ranges 91 | if (this.tokenIdTriggers) { 92 | const tokenRegEx = msgContentLowercase.match(/\d+/) 93 | if (tokenRegEx) { 94 | const tokenID = parseInt(tokenRegEx[0]) 95 | this.tokenIdTriggers.forEach((tokenIdTrigger) => { 96 | Object.entries(tokenIdTrigger).forEach(([botName, ranges]) => { 97 | if (tokenID >= ranges[0] && tokenID <= ranges[1]) { 98 | projectBotName = botName 99 | } 100 | }) 101 | }) 102 | } 103 | } 104 | // send to projectBot to handle the message 105 | return projectBotName 106 | } 107 | 108 | // this returns if val is in inclusive range [minVal, maxVal]. 109 | // treats null minVal as -inf, maxVal as +inf 110 | static _inRange(val: number, minVal: number, maxVal: number) { 111 | return ( 112 | (minVal === null || val >= minVal) && (maxVal === null || val <= maxVal) 113 | ) 114 | } 115 | } 116 | 117 | /* 118 | * An instance of this class is exported to provide: 119 | * - interface to lookup coreContracts by label (e.g. OG, V2) 120 | * - interface to lookup channelIDs by name 121 | * - interface to route incoming messages from identified project channels. 122 | */ 123 | export class ProjectConfig { 124 | channels: { [key: string]: Channel } 125 | projectBots: { [key: string]: ProjectBot } 126 | chIdByName: { [key: string]: string } 127 | projectToChannel: { [key: string]: string } 128 | constructor() { 129 | this.channels = ProjectConfig.buildChannelHandlers(CHANNELS) 130 | this.chIdByName = ProjectConfig.buildChannelIDByName(this.channels) 131 | this.projectToChannel = {} 132 | this.projectBots = {} 133 | } 134 | 135 | // Initialize async aspects of the ProjectConfig 136 | async initializeProjectBots() { 137 | try { 138 | this.projectBots = await this.buildProjectBots(CHANNELS, PROJECT_BOTS) 139 | } catch (err) { 140 | console.error(`Error while initializing ProjectBots: ${err}`) 141 | } 142 | } 143 | 144 | /* 145 | * This parses imported projectBotsJson, channelsJson, and subgraph data to 146 | * return an object with keys equal to project ID and values pointing to a new 147 | * instance of ProjectBot. Returned object is useful for getting project bot 148 | * instances by project ID. 149 | */ 150 | async buildProjectBots( 151 | channelsJson: ChannelsJson, 152 | projectBotsJson: ProjectBotsJson 153 | ): Promise<{ [key: string]: ProjectBot }> { 154 | const projectBots: { [key: string]: ProjectBot } = {} 155 | 156 | // Loops over channelsJson and adds all project IDs to a set of bots that 157 | // need to be instatiated. 158 | const botsToInstatiate = new Set() 159 | Object.keys(channelsJson).forEach((channel) => { 160 | const projectBotHandlers = channelsJson[channel].projectBotHandlers 161 | if (!projectBotHandlers) { 162 | return 163 | } 164 | botsToInstatiate.add(projectBotHandlers.default) 165 | this.projectToChannel[projectBotHandlers.default] = channel 166 | 167 | const { stringTriggers = {}, tokenIdTriggers = [] } = projectBotHandlers 168 | Object.keys(stringTriggers).forEach((key) => { 169 | botsToInstatiate.add(key) 170 | this.projectToChannel[key] = channel 171 | }) 172 | 173 | tokenIdTriggers.forEach((tokenTrigger) => { 174 | Object.keys(tokenTrigger).forEach((key) => { 175 | botsToInstatiate.add(key) 176 | this.projectToChannel[key] = channel 177 | }) 178 | }) 179 | }) 180 | 181 | // This loops through all bots that need to be instatiated asynchronously, 182 | // gets the relevant configuration from projectBotsJson, calls the subgraph 183 | // to get project information, and then initializes the project bot. 184 | console.log( 185 | `ProjectConfig: Initializing ${botsToInstatiate.size} project bots...` 186 | ) 187 | const promises = Array.from(botsToInstatiate).map(async (botId: string) => { 188 | const [projectId, contractName] = botId.split('-') 189 | const namedMappings = projectBotsJson[botId]?.namedMappings 190 | const configContract = 191 | PARTNER_CONTRACTS[contractName] ?? 192 | EXPLORATIONS_CONTRACTS[contractName] ?? 193 | COLLAB_CONTRACTS[contractName] 194 | 195 | if (contractName && !configContract) { 196 | console.warn( 197 | `Bot ${botId} had a contractName, but there was no matching contract in partnerContracts.json. Has it been defined?` 198 | ) 199 | return 200 | } 201 | const projectNumber = parseInt(projectId) 202 | const { name } = await getProject(projectNumber, configContract) 203 | 204 | const projBot = 205 | artIndexerBot.projects[artIndexerBot.toProjectKey(name ?? '')] 206 | 207 | const singlesMap = namedMappings?.singles 208 | ? require(`../NamedMappings/${namedMappings.singles}`) 209 | : null 210 | const setsMap = namedMappings?.sets 211 | ? require(`../NamedMappings/${namedMappings.sets}`) 212 | : null 213 | 214 | projBot.setNamedMappings(singlesMap, setsMap) 215 | 216 | projectBots[botId] = projBot 217 | }) 218 | 219 | await Promise.all(promises) 220 | return projectBots 221 | } 222 | 223 | /* 224 | * This parses imported channels json data and returns an object with 225 | * keys equal to channel name, values pointing to a new instance of Channel. 226 | * Returned object is useful for getting channel instances by channel ID. 227 | */ 228 | static buildChannelHandlers(ChannelsJson: ChannelsJson): { 229 | [key: string]: Channel 230 | } { 231 | const channels: { [key: string]: Channel } = {} 232 | Object.entries(ChannelsJson).forEach(([chID, chParams]) => { 233 | channels[chID] = new Channel(chParams.name, chParams.projectBotHandlers) 234 | }) 235 | return channels 236 | } 237 | 238 | /* 239 | * This parses imported channels json data and returns an object with 240 | * keys equal to channel name, values equal to channel ID. 241 | * Returned object is useful for getting channel ID by channel name. 242 | */ 243 | static buildChannelIDByName(channels: { [key: string]: Channel }): { 244 | [key: string]: string 245 | } { 246 | const chIdByName: { [key: string]: string } = {} 247 | Object.entries(channels).forEach(([chID, channel]) => { 248 | chIdByName[channel.name] = chID 249 | }) 250 | return chIdByName 251 | } 252 | 253 | /* 254 | * This routes an incoming number (^#) message intended to be routed to a 255 | * projectBot. It utilizes the logic in Channel method 256 | * botNameFromNumberMsgContent to determine which project bot should 257 | * handle the message (trigger words, token ID ranges, etc.). 258 | * @param {string} channelID Channel ID the incoming msg has been sent from. 259 | * @param msg Incoming discord.js message object 260 | */ 261 | routeProjectNumberMsg(channelID: string, msg: Message) { 262 | const channel = this.channels[channelID] 263 | const botName = channel.botNameFromNumberMsgContent( 264 | msg.content.toLowerCase() 265 | ) 266 | if (!botName) { 267 | // only occurs when # messages are sent in observed channels without project bots 268 | console.error(`Channel ID: ${channelID} does not have a ProjectBot`) 269 | return 270 | } 271 | this.projectBots[botName].handleNumberMessage(msg) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/ProjectConfig/project_aliases.json: -------------------------------------------------------------------------------- 1 | { 2 | "70spopghost": "70s Pop Ghost Bonus Pack 👻", 3 | "70spopsummertime": "70s Pop Super Fun Summertime Bonus Pack 🍸", 4 | "80spop": "80s Pop Variety Pack - for experts only 🕹", 5 | "acofr": "Ancient Courses of Fictional Rivers", 6 | "aet": "Ad Extremum Terrae", 7 | "aka": "Alan Ki Aankhen", 8 | "bones": "Spaghetti Bones", 9 | "buu": "because unless until", 10 | "cryptocountries": "CryptoCountries: The Unpublished Archives of a Mythical World Traveler", 11 | "dudes": "Mister Shifty and the Drifty Dudes", 12 | "entre": "entretiempos", 13 | "fb": "Friendship Bracelets", 14 | "fim": "Fake Internet Money", 15 | "fitymi": "fake it till you make it", 16 | "flora": "flora, fauna, false gods & floods", 17 | "fragments": "Fragments of an Infinite Field", 18 | "frags": "Fragments of an Infinite Field", 19 | "fushi": "Fushi No Reality - フシノゲンジツ", 20 | "gcm": "glitch crystal monsters", 21 | "ham": "sail-o-bots", 22 | "hams": "sail-o-bots", 23 | "imps": "Implications", 24 | "jio": "Jiometory No Compute - ジオメトリ ハ ケイサンサレマセン", 25 | "kitty": "CatBlocks", 26 | "letters": "Letters to My Future Self", 27 | "lgg": "LeWitt Generator Generator", 28 | "littleboxes": "little boxes on the hillsides, child", 29 | "lovely time": "Such A Lovely Time", 30 | "lp": "Libertad Parametrizada", 31 | "mfs": "Montreal Friend Scale", 32 | "neophyte": "neophyte MMXXII", 33 | "pa": "Paper Armada", 34 | "polis": "Ecumenopolis", 35 | "qilin": "Memories of Qilin", 36 | "rainstorms": "the spring begins with the first rainstorm", 37 | "rivers": "Ancient Courses of Fictional Rivers", 38 | "rm": "Running Moon", 39 | "salt": "Such A Lovely Time", 40 | "seaham": "sail-o-bots", 41 | "seahams": "sail-o-bots", 42 | "siempre": "siempre en mí, siempre en ti", 43 | "spaghetti": "Spaghettification", 44 | "squigs": "Chromie Squiggle", 45 | "tavbvmicwmg": "Themes and Variations", 46 | "tsbwtfr": "the spring begins with the first rainstorm", 47 | "voxel": "VOXΞL", 48 | "wcd": "Watercolor Dreams", 49 | "apps": "Apparitions", 50 | "dm": "Dopamine Machines", 51 | "mdd": "Memories of Digital Data", 52 | "modd": "Memories of Digital Data", 53 | "720": "720 Minutes", 54 | "hu": "Human Unreadable", 55 | "auto": "Automatism", 56 | "autos": "Automatism", 57 | "pm": "Polychrome Music", 58 | "spin": "SPINᵗ", 59 | "mna": "Mono no Aware", 60 | "mmm": "Melancholic Magical Maiden" 61 | } 62 | -------------------------------------------------------------------------------- /src/ProjectConfig/stagingContracts.json: -------------------------------------------------------------------------------- 1 | { 2 | "STAGING": "0xda62f67be7194775a75be91cbf9feedcc5776d4b" 3 | } 4 | -------------------------------------------------------------------------------- /src/Utils/activityTriager.ts: -------------------------------------------------------------------------------- 1 | import { Client, EmbedBuilder, TextChannel } from 'discord.js' 2 | import * as dotenv from 'dotenv' 3 | import { CollectionType } from '../Classes/MintBot' 4 | import { projectConfig } from '..' 5 | dotenv.config() 6 | 7 | // Trade activity Discord channel IDs. 8 | const CHANNEL_SALES_CHAT = projectConfig.chIdByName['block-talk'] 9 | const CHANNEL_SALES = projectConfig.chIdByName['sales-feed'] 10 | const CHANNEL_LISTINGS = projectConfig.chIdByName['listing-feed'] 11 | const ENGINE_SALES = projectConfig.chIdByName['engine-sales'] 12 | const ENGINE_LISTINGS = projectConfig.chIdByName['engine-listings'] 13 | const EXPLORATIONS_SALES = projectConfig.chIdByName['explorations-sales'] 14 | const EXPLORATIONS_LISTINGS = projectConfig.chIdByName['explorations-listings'] 15 | 16 | const CHANNEL_SQUIGGLE_SALES = projectConfig.chIdByName['squiggle_square'] 17 | const CHANNEL_SQUIGGLE_LISTINGS = projectConfig.chIdByName['squiggle-listings'] 18 | 19 | // Artist Servers 20 | const STEVIE_P_SALES = projectConfig.chIdByName['stevie-p-sales'] 21 | const STEVIE_P_LISTINGS = projectConfig.chIdByName['stevie-p-listings'] 22 | const IXNAYOKAY_SALES = projectConfig.chIdByName['ixnayokay-sales'] 23 | const EDG_SALES = projectConfig.chIdByName['edg-sales'] 24 | const EDG_LISTINGS = projectConfig.chIdByName['edg-listings'] 25 | const OWMO_SALES = projectConfig.chIdByName['owmo-sales'] 26 | const JOSHBAGLEY_SALES = projectConfig.chIdByName['joshbagley-sales'] 27 | const REMNYNT_SALES = projectConfig.chIdByName['remnynt-sales'] 28 | 29 | // Engine Partner Servers 30 | const PLOTTABLES_SALES = projectConfig.chIdByName['plottables-sales'] 31 | const PLOTTABLES_LISTINGS = projectConfig.chIdByName['plottables-listings'] 32 | const FLUTTER_SALES = projectConfig.chIdByName['flutter-sales'] 33 | const TENDER_SALES = projectConfig.chIdByName['tender-sales'] 34 | const HODLERS_SALES = projectConfig.chIdByName['hodlers-sales'] 35 | const HODLERS_LISTINGS = projectConfig.chIdByName['hodlers-listings'] 36 | const PROOF = projectConfig.chIdByName['proof-all'] 37 | 38 | // Addresses which should be omitted entirely from event feeds. 39 | export const BAN_ADDRESSES = new Set([ 40 | '0x8cf11506812f224af5c01c5f9dce5431ec3d60fd', 41 | '0x2897a0ad0032df254c74e9f17e76b474eec8ed38', 42 | '0xc7f45f209a925f9ae6af2043cf44e6570be5b21c', 43 | '0x9b397d50f662d5d39e88e4b886571581ccf48188', 44 | '0xe1770cf5274084db23bca6c921fa51cf62a37eda', 45 | '0xa7d8d9ef8d8ce8992df33d8b8cf4aebabd5bd270', 46 | '0x7b77ebc4bab939071543243a4909ca4c51c9c1fd', 47 | '0x45d91f318b2abc6b569b6637c84cdb66486eb9ee', 48 | '0x435de9e65aae5c1324d5c2359f23db32e882512c', 49 | '0x3c3fb7e51d8cfcc9451100dddf59255c6d7fc5c2', 50 | '0x7058634bc1394af83aa0a3589d6b818e4c35295a', 51 | '0x8491fc2625aeece9abc897ef29544e825a72d66e', 52 | '0xbcba11ef0dc585f028d8f4442e82ee6ceecbcbba', 53 | '0x33d27f0bb797de3d20e519f4a192b7570c56681b', 54 | '0xed30fdda2d9c605ee9c519581d65de65fb58daed', 55 | '0x9048d24577d4c4bbf14f1020f20640b334f8c762', 56 | '0x3c6137504c38215fea30605b3e364a23c1d3e14f', 57 | '0xbd67097f6ef72f5337cb4932391e04d2d07e1a61', 58 | '0xb1e6f68aa3ab791f2e835d84a9c1c2b054aa3598', 59 | '0x2ad7d5ac35319d221b2d1c7ee9edb2e3d106962e', 60 | '0x438681aa97bf5ecf1fe9110d1b04ed8230e2bfad', 61 | '0x7342948869d97e6fe1bcf8d717a9024a43225654', 62 | '0xa7a61f59ed97a8ccd4c9f4cb28c382b72b2446f8', 63 | '0xcaa6cbff376018a5e38238d6166b6b4f2ecf49c9', 64 | '0x7eea64bd72fdbc1d78c908be7f70f1daeb249951', 65 | '0x39b99f561eac03e150eca45254d0bc0b9e0404fb', 66 | '0x39b99f561eac03e150eca45254d0bc0b9e0404fb', 67 | '0xde52ed2a4ac7aa814ae3fda95d32aa419f45200d', 68 | '0x5a3a9d7c2f2d2fb9dfae78fda79134ba6d706352', 69 | '0x52238ce4a874356cc64e2eaf67d7265b53b427b1', 70 | '0xc4a66617ba07758f6f23efa1b90aba46ed4c4729', 71 | '0xd523c78cdc2ddbaafa0db1a3f4b35baf799501ff', 72 | '0x907df6e3ef654854520bb7c71f8b6c2f14ca3a87', 73 | '0x9fde734f42920221db35fe7e2405c8a68b7539df', 74 | '0x406dd0831439abb26c51de18baa031dbb267cb7e', 75 | '0x5a3a9d7c2f2d2fb9dfae78fda79134ba6d706352', 76 | '0xcd969f0eb423c2e6eb486da3268c048e04963c12', 77 | '0xb08a13cbc99c9631b7b2593e22ec803af23fe97d', 78 | '0x9aaacea197b3315068b8ef9c98219382b168d4b8', 79 | '0x72c0877d82f4fbc7ab7da21077ef152107ccd471', 80 | '0x120e30953b191998e13d670be4a6f3a7f181a060', 81 | '0xff789ac0443b50d184232ce90479ab75d9e3602f', 82 | '0xf2c447108e057ae6d2d1855bcde61cbfc15ec3fd', 83 | '0x9c7769a6dc4202b779e4d4da57737d76d13dec0f', 84 | '0x15737b0699e11fd34dffbdb05255e5c852f56fad', 85 | '0x1de3b4839d3767335435387e7b468346ae152885', 86 | '0xbe59cb2cdaba0034b98f15decbb4b500811e0020', 87 | '0xf4510444931bf5fd1666700597c18941e8465019', 88 | '0xe0f0abd8e2d15a67f242fa9aa43620cee9b354eb', 89 | '0x68fc3ec2f29ba49e2a79df34cdaa3c127a207097', 90 | '0x7de871f520228b7a9b9fe2c718766f07a261c56c', 91 | '0x7c97af86a0f92beceb712417fa0856188bb6b337', 92 | '0xd5a5d0b4566322173b1aea3a669b684129edfc8a', 93 | '0x7b77ebc4bab939071543243a4909ca4c51c9c1fd', 94 | '0x4ef4e75c6b27b6a3f1d8a331894392692633284d', 95 | '0x0f87d3a46cc9bd339b28020f737e37e0ddd728bf', 96 | '0xe1770cf5274084db23bca6c921fa51cf62a37eda', 97 | '0xc7f45f209a925f9ae6af2043cf44e6570be5b21c', 98 | '0x592a6119e24013e4e7d02259e1c9b7148fec7677', 99 | '0xaee378ba81e5ccba714c3c455a93abddd956f533', 100 | '0xf95323e393b776acc2f9e1de83be1094f22ca703', 101 | '0x706b5d16ad3027b9120ac270f66992079aacd8b2', 102 | '0x8d4be76c4113046d9d7ac34a11adecea91b977cd', 103 | '0x489f2dec2c7482faeae99d851aeda13625ea35ff', 104 | '0x3656d9a9ced1909981bc3d6feb7b54b9dbd25173', 105 | '0x872ea485576a569b06861a94946f04c08c510358', 106 | '0x592a6119e24013e4e7d02259e1c9b7148fec7677', 107 | '0x9b397d50f662d5d39e88e4b886571581ccf48188', 108 | '0x664e3a3a6a6fa524d06f4d612fe8440b923574bd', 109 | '0x43fcf64fd6dde18272b62a5b365dd8e70bdde38d', 110 | '0x8f4b93b496de681f9f9a629704da1ff90da8c93c', 111 | '0xa60e9090ac0553a199671a4b9067b7814385228f', 112 | '0xada6cbd477311409df392f869c21f384a2d9d1ff', 113 | '0xa7d8d9ef8d8ce8992df33d8b8cf4aebabd5bd270', 114 | ]) 115 | 116 | function sendEmbedToChannel( 117 | bot: Client, 118 | embed: EmbedBuilder, 119 | channelId: string 120 | ) { 121 | const channel = bot.channels?.cache?.get(channelId) as TextChannel 122 | if (!channel) { 123 | console.log('Channel not found', channelId) 124 | return 125 | } 126 | channel 127 | .send({ 128 | embeds: [embed], 129 | }) 130 | .catch((err) => { 131 | console.log( 132 | `Error posting message in channel ${projectConfig.channels[channelId].name} (id: ${channelId})`, 133 | err.message 134 | ) 135 | }) 136 | } 137 | 138 | /** 139 | * Helper function to send embed message to all proper sales channels 140 | */ 141 | export function sendEmbedToSaleChannels( 142 | bot: Client, 143 | embed: EmbedBuilder, 144 | artBlocksData: any, 145 | collectionType: CollectionType 146 | ) { 147 | try { 148 | switch (collectionType) { 149 | case CollectionType.ENGINE: 150 | sendEmbedToChannel(bot, embed, ENGINE_SALES) 151 | break 152 | case CollectionType.EXPLORATIONS: 153 | sendEmbedToChannel(bot, embed, EXPLORATIONS_SALES) 154 | sendEmbedToChannel(bot, embed, CHANNEL_SALES_CHAT) 155 | break 156 | case CollectionType.COLLAB: 157 | case CollectionType.CORE: 158 | case CollectionType.STUDIO: 159 | sendEmbedToChannel(bot, embed, CHANNEL_SALES) 160 | sendEmbedToChannel(bot, embed, CHANNEL_SALES_CHAT) 161 | break 162 | default: 163 | break 164 | } 165 | // Forward all Chromie Squiggles sales on to the DAO. 166 | if (artBlocksData.collection_name.includes('Chromie Squiggle')) { 167 | sendEmbedToChannel(bot, embed, CHANNEL_SQUIGGLE_SALES) 168 | } 169 | 170 | // Non-AB Discord servers 171 | if (artBlocksData.artist.includes('Steve Pikelny')) { 172 | sendEmbedToChannel(bot, embed, STEVIE_P_SALES) 173 | } 174 | if (artBlocksData.artist.includes('ixnayokay')) { 175 | sendEmbedToChannel(bot, embed, IXNAYOKAY_SALES) 176 | } 177 | if (artBlocksData.artist.includes('Eric De Giuli')) { 178 | sendEmbedToChannel(bot, embed, EDG_SALES) 179 | } 180 | if ( 181 | artBlocksData.artist.includes('Owen Moore') || 182 | artBlocksData.collection_name.includes('WaveShapes') 183 | ) { 184 | sendEmbedToChannel(bot, embed, OWMO_SALES) 185 | } 186 | if (artBlocksData.artist.includes('Joshua Bagley')) { 187 | sendEmbedToChannel(bot, embed, JOSHBAGLEY_SALES) 188 | } 189 | if (artBlocksData.artist.includes('remnynt')) { 190 | sendEmbedToChannel(bot, embed, REMNYNT_SALES) 191 | } 192 | if (artBlocksData.platform.includes('Plottables')) { 193 | sendEmbedToChannel(bot, embed, PLOTTABLES_SALES) 194 | } 195 | if (artBlocksData.platform.includes('Flutter')) { 196 | sendEmbedToChannel(bot, embed, FLUTTER_SALES) 197 | } 198 | if (artBlocksData.platform.includes('Hodlers')) { 199 | sendEmbedToChannel(bot, embed, HODLERS_SALES) 200 | } 201 | 202 | if ( 203 | artBlocksData.platform.includes('Tender') || 204 | artBlocksData.platform.includes('MOMENT') || 205 | artBlocksData.platform.includes('Bright Moments') || 206 | artBlocksData.platform.includes('Art Blocks') || 207 | artBlocksData.platform.includes('Grailers') || 208 | artBlocksData.platform.includes('Sotheby') 209 | ) { 210 | sendEmbedToChannel(bot, embed, TENDER_SALES) 211 | } 212 | if ( 213 | artBlocksData.platform.toLowerCase().includes('proof') || 214 | artBlocksData.platform.includes('Art Blocks') 215 | ) { 216 | sendEmbedToChannel(bot, embed, PROOF) 217 | } 218 | } catch (e) { 219 | console.warn(e) 220 | } 221 | } 222 | 223 | /** 224 | * Helper function to send embed message to all proper listing channels 225 | */ 226 | export function sendEmbedToListChannels( 227 | bot: Client, 228 | embed: EmbedBuilder, 229 | artBlocksData: any, 230 | collectionType: CollectionType 231 | ) { 232 | try { 233 | switch (collectionType) { 234 | case CollectionType.ENGINE: 235 | sendEmbedToChannel(bot, embed, ENGINE_LISTINGS) 236 | break 237 | case CollectionType.EXPLORATIONS: 238 | sendEmbedToChannel(bot, embed, EXPLORATIONS_LISTINGS) 239 | break 240 | case CollectionType.COLLAB: 241 | case CollectionType.CORE: 242 | case CollectionType.STUDIO: 243 | sendEmbedToChannel(bot, embed, CHANNEL_LISTINGS) 244 | break 245 | default: 246 | break 247 | } 248 | // Forward all Chromie Squiggles sales on to the DAO. 249 | if (artBlocksData.collection_name.includes('Chromie Squiggle')) { 250 | sendEmbedToChannel(bot, embed, CHANNEL_SQUIGGLE_LISTINGS) 251 | } 252 | 253 | // Non-AB Discord servers 254 | if (artBlocksData.artist.includes('Steve Pikelny')) { 255 | sendEmbedToChannel(bot, embed, STEVIE_P_LISTINGS) 256 | } 257 | if (artBlocksData.artist.includes('Eric De Giuli')) { 258 | sendEmbedToChannel(bot, embed, EDG_LISTINGS) 259 | } 260 | if (artBlocksData.platform.includes('Plottables')) { 261 | sendEmbedToChannel(bot, embed, PLOTTABLES_LISTINGS) 262 | } 263 | if (artBlocksData.platform.includes('Hodlers')) { 264 | sendEmbedToChannel(bot, embed, HODLERS_LISTINGS) 265 | } 266 | } catch (e) { 267 | console.warn(e) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/Utils/common.ts: -------------------------------------------------------------------------------- 1 | const DAY_NAMES = [ 2 | 'sunday', 3 | 'monday', 4 | 'tuesday', 5 | 'wednesday', 6 | 'thursday', 7 | 'friday', 8 | 'saturday', 9 | ] 10 | 11 | const MONTH_NAMES = [ 12 | 'january', 13 | 'february', 14 | 'march', 15 | 'april', 16 | 'may', 17 | 'june', 18 | 'july', 19 | 'august', 20 | 'september', 21 | 'october', 22 | 'november', 23 | 'december', 24 | ] 25 | 26 | /** 27 | * Given a date, gets the name of the day for that date. 28 | * E.g. when called on a Friday, it returns "friday". 29 | * @param date A date may be provided as input. If none is provided, use the current date. 30 | */ 31 | export const getDayName = (date = new Date()) => DAY_NAMES[date.getDay()] 32 | 33 | /** 34 | * Given a date, gets the name of the month for that date. 35 | * E.g. when called in September, it returns "september". 36 | * @param date @param date A date may be provided as input. If none is provided, use the current date. 37 | */ 38 | export const getMonthName = (date = new Date()) => MONTH_NAMES[date.getMonth()] 39 | 40 | /** 41 | * Given a date, gets the numbered day of the month for that date. 42 | * E.g. when called on May 3rd, it returns 3. 43 | * @param date @param date A date may be provided as input. If none is provided, use the current date. 44 | */ 45 | export const getDayOfMonth = (date = new Date()) => date.getDate() 46 | -------------------------------------------------------------------------------- /src/Utils/twitterUtils.ts: -------------------------------------------------------------------------------- 1 | import { TwitterApi } from 'twitter-api-v2' 2 | import { updateStatusRefreshToken } from '../Data/supabase' 3 | 4 | let _codeVerifier = '' 5 | let _state = '' 6 | 7 | // NOTE: You'll need to update this callback URL to match your own ngrok thing 8 | const CALLBACK_URL = process.env.TWITTER_CALLBACK_URL ?? '' 9 | 10 | // Use these two functions if we need to regenerate the status account tokens 11 | export function start() { 12 | const API = new TwitterApi({ 13 | clientId: process.env.AB_TWITTER_CLIENT_ID ?? '', 14 | clientSecret: process.env.AB_TWITTER_CLIENT_SECRET ?? '', 15 | }) 16 | 17 | const { url, codeVerifier, state } = API.generateOAuth2AuthLink( 18 | CALLBACK_URL, 19 | { 20 | scope: ['offline.access', 'tweet.write', 'tweet.read', 'users.read'], 21 | } 22 | ) 23 | _codeVerifier = codeVerifier 24 | _state = state 25 | 26 | console.log(url) 27 | } 28 | 29 | export function verifyTwitter(res: any, req: any) { 30 | const { state, code } = req.query 31 | const codeVerifier = _codeVerifier 32 | const sessionState = _state 33 | 34 | if (!codeVerifier || !state || !sessionState || !code) { 35 | return res.status(400).send('You denied the app or your session expired!') 36 | } 37 | if (state !== sessionState) { 38 | return res.status(400).send('Stored tokens didnt match!') 39 | } 40 | // Obtain access token 41 | const client = new TwitterApi({ 42 | clientId: process.env.AB_TWITTER_CLIENT_ID ?? '', 43 | clientSecret: process.env.AB_TWITTER_CLIENT_SECRET ?? '', 44 | }) 45 | 46 | client 47 | .loginWithOAuth2({ 48 | code, 49 | codeVerifier, 50 | redirectUri: CALLBACK_URL, 51 | }) 52 | .then(async ({ refreshToken }) => { 53 | console.log('\nrefresh:', refreshToken) 54 | updateStatusRefreshToken(refreshToken ?? '') 55 | }) 56 | .catch(() => res.status(403).send('Invalid verifier or access tokens!')) 57 | } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | import { Client, Events, GatewayIntentBits } from 'discord.js' 4 | const express = require('express') 5 | const bodyParser = require('body-parser') 6 | import { ProjectConfig } from './ProjectConfig/projectConfig' 7 | export let STUDIO_CONTRACTS: string[] = [] 8 | export let ENGINE_CONTRACTS: string[] = [] 9 | export let ARBITRUM_CONTRACTS: string[] = [] 10 | export const projectConfig = new ProjectConfig() 11 | 12 | import { ArtIndexerBot } from './Classes/ArtIndexerBot' 13 | import { MintBot } from './Classes/MintBot' 14 | import { ReservoirListBot } from './Classes/APIBots/ReservoirListBot' 15 | import { ReservoirSaleBot } from './Classes/APIBots/ReservoirSaleBot' 16 | import { 17 | getArbitrumContracts, 18 | getArtBlocksXBMProjects, 19 | getArtBlocksXPaceProjects, 20 | getEngineContracts, 21 | getEngineProjects, 22 | getStudioContracts, 23 | } from './Data/queryGraphQL' 24 | import { InsightsBot } from './Classes/InsightsBot' 25 | import { TriviaBot } from './Classes/TriviaBot' 26 | import { 27 | waitForEngineContracts, 28 | waitForStudioContracts, 29 | } from './Classes/APIBots/utils' 30 | import { ScheduleBot } from './Classes/SchedulerBot' 31 | import { verifyTwitter } from './Utils/twitterUtils' 32 | import axios from 'axios' 33 | import { paths } from '@reservoir0x/reservoir-sdk' 34 | 35 | const smartBotResponse = require('./Utils/smartBotResponse').smartBotResponse 36 | 37 | // Misc. server configuration info. 38 | const DISCORD_TOKEN = process.env.DISCORD_TOKEN 39 | const PORT = process.env.PORT || 3001 40 | 41 | export const CORE_CONTRACTS: { 42 | [id: string]: string 43 | } = require('./ProjectConfig/coreContracts.json') 44 | export const EXPLORATIONS_CONTRACTS: { 45 | [id: string]: string 46 | } = require('./ProjectConfig/explorationsContracts.json') 47 | export const COLLAB_CONTRACTS: { 48 | [id: string]: string 49 | } = require('./ProjectConfig/collaborationContracts.json') 50 | getStudioContracts().then((contracts) => { 51 | STUDIO_CONTRACTS = contracts ?? [] 52 | }) 53 | getEngineContracts().then((contracts) => { 54 | ENGINE_CONTRACTS = contracts ?? [] 55 | }) 56 | getArbitrumContracts().then((contracts) => { 57 | ARBITRUM_CONTRACTS = contracts ?? [] 58 | }) 59 | 60 | export const artIndexerBot = new ArtIndexerBot() 61 | const pbabIndexerBot = new ArtIndexerBot(getEngineProjects) 62 | const abXpaceIndexerBot = new ArtIndexerBot(getArtBlocksXPaceProjects) 63 | const abXbmIndexerBot = new ArtIndexerBot(getArtBlocksXBMProjects) 64 | 65 | // Factory Channel 66 | const CHANNEL_FACTORY = projectConfig.chIdByName['factory-projects'] 67 | 68 | // Block Talk 69 | export const CHANNEL_BLOCK_TALK = projectConfig.chIdByName['block-talk'] 70 | 71 | // PBAB Chat 72 | const CHANNEL_ENGINE_CHAT = projectConfig.chIdByName['engine-chat'] 73 | 74 | // AB x Pace 75 | const CHANNEL_AB_X_PACE = projectConfig.chIdByName['art-blocks-x-pace'] 76 | const CHANNEL_AB_X_BM = projectConfig.chIdByName['art-blocks-x-bright-moments'] 77 | 78 | // AB Art Chat 79 | const CHANNEL_ART_CHAT = projectConfig.chIdByName['ab-art-chat'] 80 | 81 | const CHANNEL_ARTBOT_TESTING = projectConfig.chIdByName['artbot-test-channel'] 82 | 83 | // Rate (in ms) to poll API endpoints 84 | const API_POLL_TIME_MS = 10000 85 | const reservoirListLimit = 50 86 | const reservoirSaleLimit = 100 87 | 88 | // Note: Please set PRODUCTION_MODE to true if testing locally 89 | const PRODUCTION_MODE = 90 | process.env.PRODUCTION_MODE && 91 | process.env.PRODUCTION_MODE.toLowerCase() === 'true' 92 | 93 | console.log('PRODUCTION_MODE: ', PRODUCTION_MODE) 94 | 95 | // App setup. 96 | const app = express() 97 | 98 | app.use(bodyParser.json()) 99 | 100 | app.post('/update', function (req: any, res: any) { 101 | console.log( 102 | 'received update with body:\n', 103 | JSON.stringify(req.body, null, 2), 104 | '\n' 105 | ) 106 | 107 | res.setHeader('Content-Type', 'application/json') 108 | res.json({ 109 | success: true, 110 | }) 111 | }) 112 | 113 | app.get('/update', function (req: any, res: any) { 114 | console.log('received get with body:\n', req.body, '\n') 115 | 116 | res.setHeader('Content-Type', 'application/json') 117 | res.json({ 118 | success: true, 119 | }) 120 | }) 121 | 122 | type MintEvent = { 123 | event: { 124 | data: { 125 | new: { 126 | contract_address: string 127 | owner_address: string 128 | project_name: string 129 | token_id: string 130 | minted_at: string 131 | invocation: string 132 | project_id: string 133 | } 134 | } 135 | } 136 | } 137 | 138 | app.post('/new-mint', function (req: any, res: any) { 139 | const mintEvent = req.body as MintEvent 140 | const mintData = mintEvent.event.data.new 141 | 142 | if (req.headers.webhook_secret !== process.env.MINT_WEBHOOK_SECRET) { 143 | console.log('Invalid mint webhook secret') 144 | res.status(401).json({ status: 'unauthorized' }) 145 | return 146 | } 147 | 148 | mintBot.addMint( 149 | mintData.contract_address, 150 | mintData.token_id, 151 | mintData.owner_address, 152 | mintData.invocation, 153 | mintData.project_id 154 | ) 155 | res.setHeader('Content-Type', 'application/json') 156 | res.json({ 157 | success: true, 158 | }) 159 | }) 160 | 161 | app.listen(PORT, '0.0.0.0', function () { 162 | console.log('Server is listening on port', PORT) 163 | }) 164 | 165 | app.get('/callback', (req: any, res: any) => { 166 | // Used for Twitter OAuth 167 | verifyTwitter(res, req) 168 | }) 169 | 170 | // Store references to all bots that need cleanup 171 | const botsToCleanup: { cleanup: () => void }[] = [] 172 | 173 | // Bot setup. 174 | export const discordClient = new Client({ 175 | intents: [ 176 | GatewayIntentBits.Guilds, 177 | GatewayIntentBits.GuildMessages, 178 | GatewayIntentBits.MessageContent, 179 | ], 180 | }) 181 | 182 | console.log('Discord client created') 183 | console.log('PRODUCTION_MODE:', PRODUCTION_MODE) 184 | console.log('DISCORD_TOKEN exists:', !!DISCORD_TOKEN) 185 | 186 | const DISCORD_LOGIN_TIMEOUT = 30000 // 30 seconds 187 | const MAX_LOGIN_RETRIES = 5 188 | let loginRetryCount = 0 189 | 190 | async function attemptDiscordLogin() { 191 | console.log('Attempting Discord login...') 192 | 193 | try { 194 | const loginPromise = discordClient.login(DISCORD_TOKEN) 195 | const timeoutPromise = new Promise((_, reject) => { 196 | setTimeout( 197 | () => reject(new Error('Discord login timed out')), 198 | DISCORD_LOGIN_TIMEOUT 199 | ) 200 | }) 201 | 202 | await Promise.race([loginPromise, timeoutPromise]) 203 | console.log('Discord login attempt successful') 204 | loginRetryCount = 0 205 | } catch (error) { 206 | console.error('Discord login failed:', error) 207 | 208 | if (loginRetryCount < MAX_LOGIN_RETRIES) { 209 | loginRetryCount++ 210 | // Exponential backoff: 5s, 10s, 20s, 40s, 80s 211 | const backoffTime = Math.min( 212 | 5000 * Math.pow(2, loginRetryCount - 1), 213 | 80000 214 | ) 215 | console.log( 216 | `Retrying login attempt ${loginRetryCount}/${MAX_LOGIN_RETRIES} in ${ 217 | backoffTime / 1000 218 | }s...` 219 | ) 220 | setTimeout(attemptDiscordLogin, backoffTime) 221 | } else { 222 | console.error('Max login retries reached. Giving up.') 223 | process.exit(1) 224 | } 225 | } 226 | } 227 | 228 | discordClient.on('ready', () => { 229 | console.log(`Logged in as ${discordClient.user?.tag}!`) 230 | }) 231 | 232 | discordClient.on('error', (error) => { 233 | console.error('Discord client error:', error) 234 | }) 235 | 236 | discordClient.on('disconnect', () => { 237 | console.log('Discord client disconnected') 238 | // Cleanup all bots 239 | botsToCleanup.forEach((bot) => bot.cleanup()) 240 | }) 241 | 242 | export const triviaBot = new TriviaBot(discordClient) 243 | export const insightsBot = new InsightsBot() 244 | 245 | new ScheduleBot(discordClient.channels.cache, projectConfig) 246 | 247 | discordClient.on(Events.MessageCreate, async (msg) => { 248 | const msgAuthor = msg.author.username 249 | const msgContent = msg.content 250 | const msgContentLowercase = msgContent.toLowerCase() 251 | const channelID = msg.channel.id 252 | 253 | // If there is not a channel ID configured where the message was sent 254 | // short-circuit handling the message 255 | const channel = projectConfig.channels[channelID] 256 | if (!channel) { 257 | return 258 | } 259 | 260 | try { 261 | // Handle piece # requests. 262 | if (msgContent.startsWith('#')) { 263 | switch (channelID) { 264 | case CHANNEL_ENGINE_CHAT: 265 | pbabIndexerBot.handleNumberMessage(msg) 266 | break 267 | case CHANNEL_AB_X_PACE: 268 | abXpaceIndexerBot.handleNumberMessage(msg) 269 | break 270 | case CHANNEL_AB_X_BM: 271 | abXbmIndexerBot.handleNumberMessage(msg) 272 | break 273 | case CHANNEL_BLOCK_TALK: 274 | case CHANNEL_FACTORY: 275 | case CHANNEL_ARTBOT_TESTING: 276 | case CHANNEL_ART_CHAT: 277 | artIndexerBot.handleNumberMessage(msg) 278 | break 279 | // Fall-back - expect a project bot to handle 280 | default: 281 | projectConfig.routeProjectNumberMsg(channelID, msg) 282 | break 283 | } 284 | return 285 | } 286 | } catch (e) { 287 | console.error('Error handling number message: ', e) 288 | } 289 | // Handle special info questions that ArtBot knows how to answer. 290 | const artBotID = discordClient.user?.id 291 | // TODO: refactor smartbotresponse to be less irritating / have fewer args 292 | smartBotResponse( 293 | msgContentLowercase, 294 | msgAuthor, 295 | artBotID, 296 | channelID, 297 | msg 298 | ).then((smartResponse: string) => { 299 | if (smartResponse !== null && smartResponse !== undefined) { 300 | if (typeof smartResponse === 'string') { 301 | msg.reply(smartResponse) 302 | } else { 303 | msg.reply({ embeds: [smartResponse] }) 304 | } 305 | } 306 | }) 307 | }) 308 | 309 | const initReservoirBots = async () => { 310 | const studioContracts = await waitForStudioContracts() 311 | const engineContracts = await waitForEngineContracts() 312 | 313 | const allContracts = Object.values(CORE_CONTRACTS) 314 | .concat(Object.values(COLLAB_CONTRACTS)) 315 | .concat(Object.values(EXPLORATIONS_CONTRACTS)) 316 | .concat(studioContracts ?? []) 317 | .concat(engineContracts ?? []) 318 | 319 | type ReservoirContractSetResponse = 320 | paths['/contracts-sets/v1']['post']['responses']['200']['schema'] 321 | 322 | const response = await axios.request({ 323 | method: 'POST', 324 | url: 'https://api.reservoir.tools/contracts-sets/v1', 325 | headers: { 326 | accept: '*/*', 327 | 'content-type': 'application/json', 328 | 'x-api-key': process.env.RESERVOIR_API_KEY, 329 | }, 330 | data: { contracts: allContracts }, 331 | }) 332 | 333 | const contractSetID = response.data.contractsSetId 334 | 335 | // List endpoint lets you use contractSet param which is very nice 336 | const listBot = new ReservoirListBot( 337 | `https://api.reservoir.tools/orders/asks/v5?contractsSetId=${contractSetID}&sortBy=createdAt&limit=${reservoirListLimit}&normalizeRoyalties=true`, 338 | API_POLL_TIME_MS, 339 | discordClient, 340 | { 341 | Accept: 'application/json', 342 | 'x-api-key': process.env.RESERVOIR_API_KEY, 343 | } 344 | ) 345 | botsToCleanup.push(listBot) 346 | 347 | // Sadly sales endpoint does not support contractSet param yet - need to batch by 20 contracts 348 | const RESERVOIR_CONTRACT_LIMIT = 20 349 | const numBotInstances = Math.ceil( 350 | allContracts.length / RESERVOIR_CONTRACT_LIMIT 351 | ) 352 | for (let i = 0; i < numBotInstances; i++) { 353 | const start = i * RESERVOIR_CONTRACT_LIMIT 354 | const end = start + RESERVOIR_CONTRACT_LIMIT 355 | const saleParams = 356 | 'contract=' + allContracts.slice(start, end).join('&contract=') 357 | 358 | const saleBot = new ReservoirSaleBot( 359 | `https://api.reservoir.tools/sales/v4?${saleParams}&limit=${reservoirSaleLimit}`, 360 | API_POLL_TIME_MS + i * 3000, 361 | discordClient, 362 | { 363 | Accept: 'application/json', 364 | 'x-api-key': process.env.RESERVOIR_API_KEY, 365 | } 366 | ) 367 | botsToCleanup.push(saleBot) 368 | } 369 | } 370 | 371 | export const mintBot = new MintBot(discordClient) 372 | 373 | // Instantiate API Pollers (if not in test mode) 374 | if (PRODUCTION_MODE) { 375 | initReservoirBots() 376 | } 377 | 378 | if (PRODUCTION_MODE) { 379 | attemptDiscordLogin() 380 | } 381 | 382 | // Handle application shutdown 383 | process.on('SIGINT', () => { 384 | console.log('Received SIGINT. Cleaning up...') 385 | // Cleanup all bots 386 | botsToCleanup.forEach((bot) => bot.cleanup()) 387 | // Disconnect Discord client 388 | discordClient.destroy() 389 | process.exit(0) 390 | }) 391 | 392 | process.on('SIGTERM', () => { 393 | console.log('Received SIGTERM. Cleaning up...') 394 | // Cleanup all bots 395 | botsToCleanup.forEach((bot) => bot.cleanup()) 396 | // Disconnect Discord client 397 | discordClient.destroy() 398 | process.exit(0) 399 | }) 400 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2021"], 4 | "module": "commonjs", 5 | "target": "es2021", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "alwaysStrict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noImplicitAny": true, 18 | "useUnknownInCatchVariables": false, 19 | "outDir": "./build" 20 | }, 21 | "include": ["./**/*", "src/**/*.ts", "src/**/*.js"], 22 | "exclude": ["generated", "node_modules"] 23 | } 24 | --------------------------------------------------------------------------------