├── .bash_history ├── .config └── glitch-package-manager ├── .env ├── .gitignore ├── .glitch-assets ├── LICENSE ├── README.md ├── app.js ├── commands.js ├── examples ├── app.js ├── button.js ├── command.js ├── modal.js └── selectMenu.js ├── game.js ├── package-lock.json ├── package.json ├── shrinkwrap.yaml ├── utils.js └── watchDog.js /.bash_history: -------------------------------------------------------------------------------- 1 | npm i web3 --save 2 | npm i chalk --save 3 | node run start 4 | npm run start 5 | -------------------------------------------------------------------------------- /.config/glitch-package-manager: -------------------------------------------------------------------------------- 1 | pnpm 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # https://discord.com/channels/123456/xxx 2 | GUILD_ID=<频道ID 类似上面123456> 3 | DISCORD_TOKEN=<机器人token> 4 | PUBLIC_KEY=<机器人公钥> 5 | APP_ID=<应用ID> 6 | ALCHEMY_AK= 7 | OS_AK= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .glitchdotcom.json 2 | .node-gyp 3 | node_modules -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"illustration.svg","date":"2021-04-12T02:54:29.568Z","url":"https://cdn.glitch.com/c62efef6-1e75-45cf-b248-afeccdda9477%2Fillustration.svg","type":"image/svg+xml","size":17429,"imageWidth":620,"imageHeight":587,"thumbnail":"https://cdn.glitch.com/c62efef6-1e75-45cf-b248-afeccdda9477%2Fthumbnails%2Fillustration.svg","thumbnailWidth":330,"thumbnailHeight":313,"uuid":"T5MUea9IPDI0uFCw"} 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shay DeWael 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # freemint-bot 2 | ## 背景 3 | 这是一个DC机器人项目,用于监控链上产生free mint的项目, 目前我自己部署在 https://glitch.com/ 作为自己的机器人使用. 4 | 5 | ![image](https://user-images.githubusercontent.com/5353946/171440775-ff354ad1-92bf-41f3-823f-3b9bea537dbf.png) 6 | 7 | ## 使用 8 | 核心代码在 `watchDog.js` 里, 可以直接调用start方法启动, `app.js` 里是DC机器人的触发指令, 目前支持的指令有 9 | 10 | 1、`start` 启动脚本监控 11 | 12 | ![image](https://user-images.githubusercontent.com/5353946/171442161-4b7f3eed-fd62-4786-890c-a3fb10d973c2.png) 13 | 14 | 15 | 2、`stop` 停止脚本监控 16 | 17 | ![image](https://user-images.githubusercontent.com/5353946/171442192-4e00a15a-ec79-4a02-95e4-c036e15d2827.png) 18 | 19 | 20 | 3、`test` 检测运行状态 21 | 22 | ![image](https://user-images.githubusercontent.com/5353946/171442264-baefe61c-0954-4236-b307-7ce065826b91.png) 23 | 24 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | InteractionType, 4 | InteractionResponseType, 5 | } from 'discord-interactions'; 6 | import { VerifyDiscordRequest, getRandomEmoji } from './utils.js'; 7 | import { 8 | START_COMMAND, 9 | STOP_COMMAND, 10 | INFO_COMMAND, 11 | HasGuildCommands, 12 | } from './commands.js'; 13 | import WatchDog from './watchDog.js'; 14 | 15 | const app = express(); 16 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 17 | 18 | app.post('/interactions', async function (req, res) { 19 | const { type, id, data, channel_id } = req.body; 20 | 21 | if (type === InteractionType.PING) { 22 | return res.send({ type: InteractionResponseType.PONG }); 23 | } 24 | 25 | if (type === InteractionType.APPLICATION_COMMAND) { 26 | const { name } = data; 27 | // 初始化 28 | WatchDog.init(channel_id); 29 | 30 | if (name === 'start') { 31 | WatchDog.start(channel_id); 32 | return res.send({ 33 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 34 | data: { 35 | content: `你踢了一脚🤖️, 它动了${getRandomEmoji()}`, 36 | }, 37 | }); 38 | } else if (name === 'stop') { 39 | WatchDog.stop(channel_id); 40 | return res.send({ 41 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 42 | data: { 43 | content: `你踢了一脚🤖️, 它不动了${getRandomEmoji()}`, 44 | }, 45 | }); 46 | } else if (name === 'test') { 47 | WatchDog.info(channel_id); 48 | return res.send({ 49 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 50 | data: { 51 | content: `你踢了一脚🤖️, 看它咋样了${getRandomEmoji()}`, 52 | }, 53 | }); 54 | } 55 | } 56 | }); 57 | 58 | app.listen(3000, () => { 59 | console.log('Listening on port 3000'); 60 | HasGuildCommands(process.env.APP_ID, process.env.GUILD_ID, [ 61 | START_COMMAND, 62 | STOP_COMMAND, 63 | INFO_COMMAND, 64 | ]); 65 | }); -------------------------------------------------------------------------------- /commands.js: -------------------------------------------------------------------------------- 1 | import { getRPSChoices } from './game.js'; 2 | import { capitalize, DiscordRequest } from './utils.js'; 3 | 4 | export async function HasGuildCommands(appId, guildId, commands) { 5 | if (guildId === '' || appId === '') return; 6 | 7 | guildId.split(',').forEach(guildId => { 8 | console.log(appId); 9 | console.log(guildId); 10 | guildId && commands.forEach((c) => HasGuildCommand(appId, guildId, c)); 11 | }); 12 | } 13 | 14 | // Checks for a command 15 | async function HasGuildCommand(appId, guildId, command) { 16 | // API endpoint to get and post guild commands 17 | const endpoint = `applications/${appId}/guilds/${guildId}/commands`; 18 | 19 | try { 20 | const res = await DiscordRequest(endpoint, { method: 'GET' }); 21 | const data = await res.json(); 22 | 23 | if (data) { 24 | const installedNames = data.map((c) => c['name']); 25 | // This is just matching on the name, so it's not good for updates 26 | if (!installedNames.includes(command['name'])) { 27 | console.log(`Installing "${command['name']}"`); 28 | InstallGuildCommand(appId, guildId, command); 29 | } else { 30 | console.log(`"${command['name']}" command already installed`); 31 | } 32 | } 33 | } catch (err) { 34 | console.error(err); 35 | } 36 | } 37 | 38 | // Installs a command 39 | export async function InstallGuildCommand(appId, guildId, command) { 40 | // API endpoint to get and post guild commands 41 | const endpoint = `applications/${appId}/guilds/${guildId}/commands`; 42 | // install command 43 | try { 44 | console.log('------>', command); 45 | await DiscordRequest(endpoint, { method: 'POST', body: command }); 46 | } catch (err) { 47 | console.error(err); 48 | } 49 | } 50 | 51 | export const START_COMMAND = { 52 | name: 'start', 53 | description: '开启监控', 54 | type: 2, 55 | }; 56 | 57 | export const STOP_COMMAND = { 58 | name: 'stop', 59 | description: '关闭监控', 60 | type: 2, 61 | }; 62 | 63 | export const INFO_COMMAND = { 64 | name: 'test', 65 | description: '查看状态', 66 | type: 2, 67 | }; 68 | 69 | -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | InteractionType, 4 | InteractionResponseType, 5 | InteractionResponseFlags, 6 | MessageComponentTypes, 7 | ButtonStyleTypes, 8 | } from "discord-interactions"; 9 | import { 10 | VerifyDiscordRequest, 11 | getRandomEmoji, 12 | DiscordRequest, 13 | } from "./utils.js"; 14 | import { getShuffledOptions, getResult } from "./game.js"; 15 | import { 16 | CHALLENGE_COMMAND, 17 | TEST_COMMAND, 18 | HasGuildCommands, 19 | } from "./commands.js"; 20 | 21 | // Create an express app 22 | const app = express(); 23 | // Parse request body and verifies incoming requests using discord-interactions package 24 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 25 | 26 | // Store for in-progress games. In production, you'd want to use a DB 27 | const activeGames = {}; 28 | 29 | /** 30 | * Interactions endpoint URL where Discord will send HTTP requests 31 | */ 32 | app.post("/interactions", async function (req, res) { 33 | // Interaction type and data 34 | const { type, id, data } = req.body; 35 | 36 | /** 37 | * Handle verification requests 38 | */ 39 | if (type === InteractionType.PING) { 40 | return res.send({ type: InteractionResponseType.PONG }); 41 | } 42 | 43 | /** 44 | * Handle slash command requests 45 | * See https://discord.com/developers/docs/interactions/application-commands#slash-commands 46 | */ 47 | if (type === InteractionType.APPLICATION_COMMAND) { 48 | const { name } = data; 49 | 50 | // "test" guild command 51 | if (name === "test") { 52 | // Send a message into the channel where command was triggered from 53 | return res.send({ 54 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 55 | data: { 56 | // Fetches a random emoji to send from a helper function 57 | content: "hello world " + getRandomEmoji(), 58 | }, 59 | }); 60 | } 61 | // "challenge" guild command 62 | if (name === "challenge" && id) { 63 | const userId = req.body.member.user.id; 64 | // User's object choice 65 | const objectName = req.body.data.options[0].value; 66 | 67 | // Create active game using message ID as the game ID 68 | activeGames[id] = { 69 | id: userId, 70 | objectName, 71 | }; 72 | 73 | return res.send({ 74 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 75 | data: { 76 | // Fetches a random emoji to send from a helper function 77 | content: `Rock papers scissors challenge from <@${userId}>`, 78 | components: [ 79 | { 80 | type: MessageComponentTypes.ACTION_ROW, 81 | components: [ 82 | { 83 | type: MessageComponentTypes.BUTTON, 84 | // Append the game ID to use later on 85 | custom_id: `accept_button_${req.body.id}`, 86 | label: "Accept", 87 | style: ButtonStyleTypes.PRIMARY, 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | }); 94 | } 95 | } 96 | 97 | /** 98 | * Handle requests from interactive components 99 | * See https://discord.com/developers/docs/interactions/message-components#responding-to-a-component-interaction 100 | */ 101 | if (type === InteractionType.MESSAGE_COMPONENT) { 102 | // custom_id set in payload when sending message component 103 | const componentId = data.custom_id; 104 | 105 | if (componentId.startsWith("accept_button_")) { 106 | // get the associated game ID 107 | const gameId = componentId.replace("accept_button_", ""); 108 | // Delete message with token in request body 109 | const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; 110 | try { 111 | await res.send({ 112 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 113 | data: { 114 | // Fetches a random emoji to send from a helper function 115 | content: "What is your object of choice?", 116 | // Indicates it'll be an ephemeral message 117 | flags: InteractionResponseFlags.EPHEMERAL, 118 | components: [ 119 | { 120 | type: MessageComponentTypes.ACTION_ROW, 121 | components: [ 122 | { 123 | type: MessageComponentTypes.STRING_SELECT, 124 | // Append game ID 125 | custom_id: `select_choice_${gameId}`, 126 | options: getShuffledOptions(), 127 | }, 128 | ], 129 | }, 130 | ], 131 | }, 132 | }); 133 | // Delete previous message 134 | await DiscordRequest(endpoint, { method: "DELETE" }); 135 | } catch (err) { 136 | console.error("Error sending message:", err); 137 | } 138 | } else if (componentId.startsWith("select_choice_")) { 139 | // get the associated game ID 140 | const gameId = componentId.replace("select_choice_", ""); 141 | 142 | if (activeGames[gameId]) { 143 | // Get user ID and object choice for responding user 144 | const userId = req.body.member.user.id; 145 | const objectName = data.values[0]; 146 | // Calculate result from helper function 147 | const resultStr = getResult(activeGames[gameId], { 148 | id: userId, 149 | objectName, 150 | }); 151 | 152 | // Remove game from storage 153 | delete activeGames[gameId]; 154 | // Update message with token in request body 155 | const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; 156 | 157 | try { 158 | // Send results 159 | await res.send({ 160 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 161 | data: { content: resultStr }, 162 | }); 163 | // Update ephemeral message 164 | await DiscordRequest(endpoint, { 165 | method: "PATCH", 166 | body: { 167 | content: "Nice choice " + getRandomEmoji(), 168 | components: [], 169 | }, 170 | }); 171 | } catch (err) { 172 | console.error("Error sending message:", err); 173 | } 174 | } 175 | } 176 | } 177 | }); 178 | 179 | app.listen(3000, () => { 180 | console.log("Listening on port 3000"); 181 | 182 | // Check if guild commands from commands.json are installed (if not, install them) 183 | HasGuildCommands(process.env.APP_ID, process.env.GUILD_ID, [ 184 | TEST_COMMAND, 185 | CHALLENGE_COMMAND, 186 | ]); 187 | }); 188 | -------------------------------------------------------------------------------- /examples/button.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | InteractionType, 4 | InteractionResponseType, 5 | MessageComponentTypes, 6 | ButtonStyleTypes, 7 | } from 'discord-interactions'; 8 | import { VerifyDiscordRequest } from '../utils.js'; 9 | 10 | // Create and configure express app 11 | const app = express(); 12 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 13 | 14 | app.post('/interactions', function (req, res) { 15 | // Interaction type and data 16 | const { type, data } = req.body; 17 | /** 18 | * Handle slash command requests 19 | */ 20 | if (type === InteractionType.APPLICATION_COMMAND) { 21 | // Slash command with name of "test" 22 | if (data.name === 'test') { 23 | // Send a message with a button 24 | return res.send({ 25 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 26 | data: { 27 | content: 'A message with a button', 28 | // Buttons are inside of action rows 29 | components: [ 30 | { 31 | type: MessageComponentTypes.ACTION_ROW, 32 | components: [ 33 | { 34 | type: MessageComponentTypes.BUTTON, 35 | // Value for your app to identify the button 36 | custom_id: 'my_button', 37 | label: 'Click', 38 | style: ButtonStyleTypes.PRIMARY, 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | }); 45 | } 46 | } 47 | 48 | /** 49 | * Handle requests from interactive components 50 | */ 51 | if (type === InteractionType.MESSAGE_COMPONENT) { 52 | // custom_id set in payload when sending message component 53 | const componentId = data.custom_id; 54 | // user who clicked button 55 | const userId = req.body.member.user.id; 56 | 57 | if (componentId === 'my_button') { 58 | console.log(req.body); 59 | return res.send({ 60 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 61 | data: { content: `<@${userId} clicked the button` }, 62 | }); 63 | } 64 | } 65 | }); 66 | 67 | app.listen(3000, () => { 68 | console.log('Listening on port 3000'); 69 | }); 70 | -------------------------------------------------------------------------------- /examples/command.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { InteractionType, InteractionResponseType } from 'discord-interactions'; 3 | import { VerifyDiscordRequest, DiscordRequest } from '../utils.js'; 4 | 5 | // Create and configure express app 6 | const app = express(); 7 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 8 | 9 | app.post('/interactions', function (req, res) { 10 | // Interaction type and data 11 | const { type, data } = req.body; 12 | /** 13 | * Handle slash command requests 14 | */ 15 | if (type === InteractionType.APPLICATION_COMMAND) { 16 | // Slash command with name of "test" 17 | if (data.name === 'test') { 18 | // Send a message as response 19 | return res.send({ 20 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 21 | data: { content: 'A wild message appeared' }, 22 | }); 23 | } 24 | } 25 | }); 26 | 27 | async function createCommand() { 28 | const appId = process.env.APP_ID; 29 | const guildId = process.env.GUILD_ID; 30 | 31 | /** 32 | * Globally-scoped slash commands (generally only recommended for production) 33 | * See https://discord.com/developers/docs/interactions/application-commands#create-global-application-command 34 | */ 35 | // const globalEndpoint = `applications/${appId}/commands`; 36 | 37 | /** 38 | * Guild-scoped slash commands 39 | * See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command 40 | */ 41 | const guildEndpoint = `applications/${appId}/guilds/${guildId}/commands`; 42 | const commandBody = { 43 | name: 'test', 44 | description: 'Just your average command', 45 | // chat command (see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types) 46 | type: 1, 47 | }; 48 | 49 | try { 50 | // Send HTTP request with bot token 51 | const res = await DiscordRequest(guildEndpoint, { 52 | method: 'POST', 53 | body: commandBody, 54 | }); 55 | console.log(await res.json()); 56 | } catch (err) { 57 | console.error('Error installing commands: ', err); 58 | } 59 | } 60 | 61 | app.listen(3000, () => { 62 | console.log('Listening on port 3000'); 63 | 64 | createCommand(); 65 | }); 66 | -------------------------------------------------------------------------------- /examples/modal.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | InteractionType, 4 | InteractionResponseType, 5 | MessageComponentTypes, 6 | } from 'discord-interactions'; 7 | import { VerifyDiscordRequest } from '../utils.js'; 8 | 9 | // Create and configure express app 10 | const app = express(); 11 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 12 | 13 | app.post('/interactions', function (req, res) { 14 | // Interaction type and data 15 | const { type, data } = req.body; 16 | /** 17 | * Handle slash command requests 18 | */ 19 | if (type === InteractionType.APPLICATION_COMMAND) { 20 | // Slash command with name of "test" 21 | if (data.name === 'test') { 22 | // Send a modal as response 23 | return res.send({ 24 | type: InteractionResponseType.APPLICATION_MODAL, 25 | data: { 26 | custom_id: 'my_modal', 27 | title: 'Modal title', 28 | components: [ 29 | { 30 | // Text inputs must be inside of an action component 31 | type: MessageComponentTypes.ACTION_ROW, 32 | components: [ 33 | { 34 | // See https://discord.com/developers/docs/interactions/message-components#text-inputs-text-input-structure 35 | type: MessageComponentTypes.INPUT_TEXT, 36 | custom_id: 'my_text', 37 | style: 1, 38 | label: 'Type some text', 39 | }, 40 | ], 41 | }, 42 | { 43 | type: MessageComponentTypes.ACTION_ROW, 44 | components: [ 45 | { 46 | type: MessageComponentTypes.INPUT_TEXT, 47 | custom_id: 'my_longer_text', 48 | // Bigger text box for input 49 | style: 2, 50 | label: 'Type some (longer) text', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | }); 57 | } 58 | } 59 | 60 | /** 61 | * Handle modal submissions 62 | */ 63 | if (type === InteractionType.APPLICATION_MODAL_SUBMIT) { 64 | // custom_id of modal 65 | const modalId = data.custom_id; 66 | // user ID of member who filled out modal 67 | const userId = req.body.member.user.id; 68 | 69 | if (modalId === 'my_modal') { 70 | let modalValues = ''; 71 | // Get value of text inputs 72 | for (let action of data.components) { 73 | let inputComponent = action.components[0]; 74 | modalValues += `${inputComponent.custom_id}: ${inputComponent.value}\n`; 75 | } 76 | 77 | return res.send({ 78 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 79 | data: { 80 | content: `<@${userId}> typed the following (in a modal):\n\n${modalValues}`, 81 | }, 82 | }); 83 | } 84 | } 85 | }); 86 | 87 | app.listen(3000, () => { 88 | console.log('Listening on port 3000'); 89 | }); 90 | -------------------------------------------------------------------------------- /examples/selectMenu.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | InteractionType, 4 | InteractionResponseType, 5 | MessageComponentTypes, 6 | } from 'discord-interactions'; 7 | import { VerifyDiscordRequest } from '../utils.js'; 8 | 9 | // Create and configure express app 10 | const app = express(); 11 | app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) })); 12 | 13 | app.post('/interactions', function (req, res) { 14 | // Interaction type and data 15 | const { type, data } = req.body; 16 | /** 17 | * Handle slash command requests 18 | */ 19 | if (type === InteractionType.APPLICATION_COMMAND) { 20 | // Slash command with name of "test" 21 | if (data.name === 'test') { 22 | // Send a message with a button 23 | return res.send({ 24 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 25 | data: { 26 | content: 'A message with a button', 27 | // Selects are inside of action rows 28 | components: [ 29 | { 30 | type: MessageComponentTypes.ACTION_ROW, 31 | components: [ 32 | { 33 | type: MessageComponentTypes.STRING_SELECT, 34 | // Value for your app to identify the select menu interactions 35 | custom_id: 'my_select', 36 | // Select options - see https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure 37 | options: [ 38 | { 39 | label: 'Option #1', 40 | value: 'option_1', 41 | description: 'The very first option', 42 | }, 43 | { 44 | label: 'Second option', 45 | value: 'option_2', 46 | description: 'The second AND last option', 47 | }, 48 | ], 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | }); 55 | } 56 | } 57 | 58 | /** 59 | * Handle requests from interactive components 60 | */ 61 | if (type === InteractionType.MESSAGE_COMPONENT) { 62 | // custom_id set in payload when sending message component 63 | const componentId = data.custom_id; 64 | 65 | if (componentId === 'my_select') { 66 | console.log(req.body); 67 | 68 | // Get selected option from payload 69 | const selectedOption = data.values[0]; 70 | const userId = req.body.member.user.id; 71 | 72 | // Send results 73 | return res.send({ 74 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 75 | data: { content: `<@${userId}> selected ${selectedOption}` }, 76 | }); 77 | } 78 | } 79 | }); 80 | 81 | app.listen(3000, () => { 82 | console.log('Listening on port 3000'); 83 | }); 84 | -------------------------------------------------------------------------------- /game.js: -------------------------------------------------------------------------------- 1 | import { capitalize } from './utils.js'; 2 | 3 | export function getResult(p1, p2) { 4 | let gameResult; 5 | if (RPSChoices[p1.objectName] && RPSChoices[p1.objectName][p2.objectName]) { 6 | // o1 wins 7 | gameResult = { 8 | win: p1, 9 | lose: p2, 10 | verb: RPSChoices[p1.objectName][p2.objectName], 11 | }; 12 | } else if ( 13 | RPSChoices[p2.objectName] && 14 | RPSChoices[p2.objectName][p1.objectName] 15 | ) { 16 | // o2 wins 17 | gameResult = { 18 | win: p2, 19 | lose: p1, 20 | verb: RPSChoices[p2.objectName][p1.objectName], 21 | }; 22 | } else { 23 | // tie -- win/lose don't 24 | gameResult = { win: p1, lose: p2, verb: 'tie' }; 25 | } 26 | 27 | return formatResult(gameResult); 28 | } 29 | 30 | function formatResult(result) { 31 | const { win, lose, verb } = result; 32 | return verb === 'tie' 33 | ? `<@${win.id}> and <@${lose.id}> draw with **${win.objectName}**` 34 | : `<@${win.id}>'s **${win.objectName}** ${verb} <@${lose.id}>'s **${lose.objectName}**`; 35 | } 36 | 37 | // this is just to figure out winner + verb 38 | const RPSChoices = { 39 | rock: { 40 | description: 'sedimentary, igneous, or perhaps even metamorphic', 41 | virus: 'outwaits', 42 | computer: 'smashes', 43 | scissors: 'crushes', 44 | }, 45 | cowboy: { 46 | description: 'yeehaw~', 47 | scissors: 'puts away', 48 | wumpus: 'lassos', 49 | rock: 'steel-toe kicks', 50 | }, 51 | scissors: { 52 | description: 'careful ! sharp ! edges !!', 53 | paper: 'cuts', 54 | computer: 'cuts cord of', 55 | virus: 'cuts DNA of', 56 | }, 57 | virus: { 58 | description: 'genetic mutation, malware, or something inbetween', 59 | cowboy: 'infects', 60 | computer: 'corrupts', 61 | wumpus: 'infects', 62 | }, 63 | computer: { 64 | description: 'beep boop beep bzzrrhggggg', 65 | cowboy: 'overwhelms', 66 | paper: 'uninstalls firmware for', 67 | wumpus: 'deletes assets for', 68 | }, 69 | wumpus: { 70 | description: 'the purple Discord fella', 71 | paper: 'draws picture on', 72 | rock: 'paints cute face on', 73 | scissors: 'admires own reflection in', 74 | }, 75 | paper: { 76 | description: 'versatile and iconic', 77 | virus: 'ignores', 78 | cowboy: 'gives papercut to', 79 | rock: 'covers', 80 | }, 81 | }; 82 | 83 | export function getRPSChoices() { 84 | return Object.keys(RPSChoices); 85 | } 86 | 87 | // Function to fetch shuffled options for select menu 88 | export function getShuffledOptions() { 89 | const allChoices = getRPSChoices(); 90 | const options = []; 91 | 92 | for (let c of allChoices) { 93 | // Formatted for select menus 94 | // https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure 95 | options.push({ 96 | label: capitalize(c), 97 | value: c.toLowerCase(), 98 | description: RPSChoices[c]['description'], 99 | }); 100 | } 101 | 102 | return options.sort(() => Math.random() - 0.5); 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freemint-bot", 3 | "version": "1.0.0", 4 | "description": "Basic Discord app using interactions", 5 | "main": "app.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node app.js" 9 | }, 10 | "author": "Shay DeWael", 11 | "license": "MIT", 12 | "engines": { 13 | "node": "16.x" 14 | }, 15 | "dependencies": { 16 | "axios": "^0.26.1", 17 | "chalk": "^5.0.1", 18 | "discord-interactions": "^3.2.0", 19 | "express": "^4.17.3", 20 | "node-fetch": "^3.2.3", 21 | "web3": "^1.7.3" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^2.0.15" 25 | }, 26 | "keywords": [ 27 | "discord", 28 | "node" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /shrinkwrap.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | axios: 0.26.1 3 | discord-interactions: 3.1.0 4 | express: 4.17.3 5 | packages: 6 | /accepts/1.3.8: 7 | dependencies: 8 | mime-types: 2.1.35 9 | negotiator: 0.6.3 10 | dev: false 11 | engines: 12 | node: '>= 0.6' 13 | resolution: 14 | integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== 15 | /array-flatten/1.1.1: 16 | dev: false 17 | resolution: 18 | integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 19 | /axios/0.26.1: 20 | dependencies: 21 | follow-redirects: 1.14.9 22 | dev: false 23 | resolution: 24 | integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== 25 | /body-parser/1.19.2: 26 | dependencies: 27 | bytes: 3.1.2 28 | content-type: 1.0.4 29 | debug: 2.6.9 30 | depd: 1.1.2 31 | http-errors: 1.8.1 32 | iconv-lite: 0.4.24 33 | on-finished: 2.3.0 34 | qs: 6.9.7 35 | raw-body: 2.4.3 36 | type-is: 1.6.18 37 | dev: false 38 | engines: 39 | node: '>= 0.8' 40 | resolution: 41 | integrity: sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw== 42 | /bytes/3.1.2: 43 | dev: false 44 | engines: 45 | node: '>= 0.8' 46 | resolution: 47 | integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 48 | /content-disposition/0.5.4: 49 | dependencies: 50 | safe-buffer: 5.2.1 51 | dev: false 52 | engines: 53 | node: '>= 0.6' 54 | resolution: 55 | integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== 56 | /content-type/1.0.4: 57 | dev: false 58 | engines: 59 | node: '>= 0.6' 60 | resolution: 61 | integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 62 | /cookie-signature/1.0.6: 63 | dev: false 64 | resolution: 65 | integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw= 66 | /cookie/0.4.2: 67 | dev: false 68 | engines: 69 | node: '>= 0.6' 70 | resolution: 71 | integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== 72 | /debug/2.6.9: 73 | dependencies: 74 | ms: 2.0.0 75 | dev: false 76 | resolution: 77 | integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 78 | /depd/1.1.2: 79 | dev: false 80 | engines: 81 | node: '>= 0.6' 82 | resolution: 83 | integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= 84 | /destroy/1.0.4: 85 | dev: false 86 | resolution: 87 | integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= 88 | /discord-interactions/3.1.0: 89 | dependencies: 90 | tweetnacl: 1.0.3 91 | dev: false 92 | engines: 93 | node: '>=12' 94 | resolution: 95 | integrity: sha512-AslkLAwaiq7ulFcurMoDf+FXINZWiF7/i+7U3tcKJHq/2zpxYKortPhaisM2f4c3hIL8ioddSCwVbRR4ngPIwA== 96 | /ee-first/1.1.1: 97 | dev: false 98 | resolution: 99 | integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 100 | /encodeurl/1.0.2: 101 | dev: false 102 | engines: 103 | node: '>= 0.8' 104 | resolution: 105 | integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= 106 | /escape-html/1.0.3: 107 | dev: false 108 | resolution: 109 | integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= 110 | /etag/1.8.1: 111 | dev: false 112 | engines: 113 | node: '>= 0.6' 114 | resolution: 115 | integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 116 | /express/4.17.3: 117 | dependencies: 118 | accepts: 1.3.8 119 | array-flatten: 1.1.1 120 | body-parser: 1.19.2 121 | content-disposition: 0.5.4 122 | content-type: 1.0.4 123 | cookie: 0.4.2 124 | cookie-signature: 1.0.6 125 | debug: 2.6.9 126 | depd: 1.1.2 127 | encodeurl: 1.0.2 128 | escape-html: 1.0.3 129 | etag: 1.8.1 130 | finalhandler: 1.1.2 131 | fresh: 0.5.2 132 | merge-descriptors: 1.0.1 133 | methods: 1.1.2 134 | on-finished: 2.3.0 135 | parseurl: 1.3.3 136 | path-to-regexp: 0.1.7 137 | proxy-addr: 2.0.7 138 | qs: 6.9.7 139 | range-parser: 1.2.1 140 | safe-buffer: 5.2.1 141 | send: 0.17.2 142 | serve-static: 1.14.2 143 | setprototypeof: 1.2.0 144 | statuses: 1.5.0 145 | type-is: 1.6.18 146 | utils-merge: 1.0.1 147 | vary: 1.1.2 148 | dev: false 149 | engines: 150 | node: '>= 0.10.0' 151 | resolution: 152 | integrity: sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== 153 | /finalhandler/1.1.2: 154 | dependencies: 155 | debug: 2.6.9 156 | encodeurl: 1.0.2 157 | escape-html: 1.0.3 158 | on-finished: 2.3.0 159 | parseurl: 1.3.3 160 | statuses: 1.5.0 161 | unpipe: 1.0.0 162 | dev: false 163 | engines: 164 | node: '>= 0.8' 165 | resolution: 166 | integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== 167 | /follow-redirects/1.14.9: 168 | dev: false 169 | engines: 170 | node: '>=4.0' 171 | resolution: 172 | integrity: sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== 173 | /forwarded/0.2.0: 174 | dev: false 175 | engines: 176 | node: '>= 0.6' 177 | resolution: 178 | integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== 179 | /fresh/0.5.2: 180 | dev: false 181 | engines: 182 | node: '>= 0.6' 183 | resolution: 184 | integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= 185 | /http-errors/1.8.1: 186 | dependencies: 187 | depd: 1.1.2 188 | inherits: 2.0.4 189 | setprototypeof: 1.2.0 190 | statuses: 1.5.0 191 | toidentifier: 1.0.1 192 | dev: false 193 | engines: 194 | node: '>= 0.6' 195 | resolution: 196 | integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== 197 | /iconv-lite/0.4.24: 198 | dependencies: 199 | safer-buffer: 2.1.2 200 | dev: false 201 | engines: 202 | node: '>=0.10.0' 203 | resolution: 204 | integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 205 | /inherits/2.0.4: 206 | dev: false 207 | resolution: 208 | integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 209 | /ipaddr.js/1.9.1: 210 | dev: false 211 | engines: 212 | node: '>= 0.10' 213 | resolution: 214 | integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 215 | /media-typer/0.3.0: 216 | dev: false 217 | engines: 218 | node: '>= 0.6' 219 | resolution: 220 | integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= 221 | /merge-descriptors/1.0.1: 222 | dev: false 223 | resolution: 224 | integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= 225 | /methods/1.1.2: 226 | dev: false 227 | engines: 228 | node: '>= 0.6' 229 | resolution: 230 | integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= 231 | /mime-db/1.52.0: 232 | dev: false 233 | engines: 234 | node: '>= 0.6' 235 | resolution: 236 | integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== 237 | /mime-types/2.1.35: 238 | dependencies: 239 | mime-db: 1.52.0 240 | dev: false 241 | engines: 242 | node: '>= 0.6' 243 | resolution: 244 | integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== 245 | /mime/1.6.0: 246 | dev: false 247 | engines: 248 | node: '>=4' 249 | hasBin: true 250 | resolution: 251 | integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 252 | /ms/2.0.0: 253 | dev: false 254 | resolution: 255 | integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 256 | /ms/2.1.3: 257 | dev: false 258 | resolution: 259 | integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 260 | /negotiator/0.6.3: 261 | dev: false 262 | engines: 263 | node: '>= 0.6' 264 | resolution: 265 | integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== 266 | /on-finished/2.3.0: 267 | dependencies: 268 | ee-first: 1.1.1 269 | dev: false 270 | engines: 271 | node: '>= 0.8' 272 | resolution: 273 | integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= 274 | /parseurl/1.3.3: 275 | dev: false 276 | engines: 277 | node: '>= 0.8' 278 | resolution: 279 | integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 280 | /path-to-regexp/0.1.7: 281 | dev: false 282 | resolution: 283 | integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 284 | /proxy-addr/2.0.7: 285 | dependencies: 286 | forwarded: 0.2.0 287 | ipaddr.js: 1.9.1 288 | dev: false 289 | engines: 290 | node: '>= 0.10' 291 | resolution: 292 | integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== 293 | /qs/6.9.7: 294 | dev: false 295 | engines: 296 | node: '>=0.6' 297 | resolution: 298 | integrity: sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw== 299 | /range-parser/1.2.1: 300 | dev: false 301 | engines: 302 | node: '>= 0.6' 303 | resolution: 304 | integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 305 | /raw-body/2.4.3: 306 | dependencies: 307 | bytes: 3.1.2 308 | http-errors: 1.8.1 309 | iconv-lite: 0.4.24 310 | unpipe: 1.0.0 311 | dev: false 312 | engines: 313 | node: '>= 0.8' 314 | resolution: 315 | integrity: sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g== 316 | /safe-buffer/5.2.1: 317 | dev: false 318 | resolution: 319 | integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 320 | /safer-buffer/2.1.2: 321 | dev: false 322 | resolution: 323 | integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 324 | /send/0.17.2: 325 | dependencies: 326 | debug: 2.6.9 327 | depd: 1.1.2 328 | destroy: 1.0.4 329 | encodeurl: 1.0.2 330 | escape-html: 1.0.3 331 | etag: 1.8.1 332 | fresh: 0.5.2 333 | http-errors: 1.8.1 334 | mime: 1.6.0 335 | ms: 2.1.3 336 | on-finished: 2.3.0 337 | range-parser: 1.2.1 338 | statuses: 1.5.0 339 | dev: false 340 | engines: 341 | node: '>= 0.8.0' 342 | resolution: 343 | integrity: sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww== 344 | /serve-static/1.14.2: 345 | dependencies: 346 | encodeurl: 1.0.2 347 | escape-html: 1.0.3 348 | parseurl: 1.3.3 349 | send: 0.17.2 350 | dev: false 351 | engines: 352 | node: '>= 0.8.0' 353 | resolution: 354 | integrity: sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ== 355 | /setprototypeof/1.2.0: 356 | dev: false 357 | resolution: 358 | integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== 359 | /statuses/1.5.0: 360 | dev: false 361 | engines: 362 | node: '>= 0.6' 363 | resolution: 364 | integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 365 | /toidentifier/1.0.1: 366 | dev: false 367 | engines: 368 | node: '>=0.6' 369 | resolution: 370 | integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== 371 | /tweetnacl/1.0.3: 372 | dev: false 373 | resolution: 374 | integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== 375 | /type-is/1.6.18: 376 | dependencies: 377 | media-typer: 0.3.0 378 | mime-types: 2.1.35 379 | dev: false 380 | engines: 381 | node: '>= 0.6' 382 | resolution: 383 | integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 384 | /unpipe/1.0.0: 385 | dev: false 386 | engines: 387 | node: '>= 0.8' 388 | resolution: 389 | integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= 390 | /utils-merge/1.0.1: 391 | dev: false 392 | engines: 393 | node: '>= 0.4.0' 394 | resolution: 395 | integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 396 | /vary/1.1.2: 397 | dev: false 398 | engines: 399 | node: '>= 0.8' 400 | resolution: 401 | integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= 402 | registry: 'https://registry.npmjs.org/' 403 | shrinkwrapMinorVersion: 9 404 | shrinkwrapVersion: 3 405 | specifiers: 406 | axios: ^0.26.1 407 | discord-interactions: ^3.1.0 408 | express: ^4.17.3 409 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { verifyKey } from 'discord-interactions'; 3 | 4 | export function VerifyDiscordRequest(clientKey) { 5 | return function (req, res, buf, encoding) { 6 | const signature = req.get('X-Signature-Ed25519'); 7 | const timestamp = req.get('X-Signature-Timestamp'); 8 | 9 | const isValidRequest = verifyKey(buf, signature, timestamp, clientKey); 10 | if (!isValidRequest) { 11 | res.status(401).send('Bad request signature'); 12 | throw new Error('Bad request signature'); 13 | } 14 | }; 15 | } 16 | 17 | export async function DiscordRequest(endpoint, options) { 18 | // append endpoint to root API URL 19 | const url = 'https://discord.com/api/v9/' + endpoint; 20 | // Stringify payloads 21 | if (options.body) options.body = JSON.stringify(options.body); 22 | // Use node-fetch to make requests 23 | const res = await fetch(url, { 24 | headers: { 25 | Authorization: `Bot ${process.env.DISCORD_TOKEN}`, 26 | 'Content-Type': 'application/json; charset=UTF-8', 27 | }, 28 | ...options 29 | }); 30 | console.log(res.ok); 31 | // throw API errors 32 | if (!res.ok) { 33 | const data = await res.json(); 34 | console.log(res.status); 35 | throw new Error(JSON.stringify(data)); 36 | } 37 | // return original response 38 | return res; 39 | } 40 | 41 | // Simple method that returns a random emoji from list 42 | export function getRandomEmoji() { 43 | const emojiList = ['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨']; 44 | return emojiList[Math.floor(Math.random() * emojiList.length)]; 45 | } 46 | 47 | export function capitalize(str) { 48 | return str.charAt(0).toUpperCase() + str.slice(1); 49 | } 50 | -------------------------------------------------------------------------------- /watchDog.js: -------------------------------------------------------------------------------- 1 | 2 | import Web3 from 'web3'; 3 | import axios from 'axios'; 4 | import chalk from 'chalk'; 5 | import { DiscordRequest } from './utils.js'; 6 | const web3 = new Web3(`wss://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_AK}`); 7 | const contractMap = new Map(); 8 | const queryMap = new Map(); 9 | const MIN = 3; 10 | const NOTIFY_DEALY = MIN * 60 * 1000; 11 | const QUERY_LIMIT = 30; 12 | 13 | class WatchDog { 14 | constructor() { 15 | this.instance = axios.create({ 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36', 19 | 'X-API-KEY': process.env.OS_AK 20 | } 21 | }); 22 | this.queryCount = 0; 23 | } 24 | 25 | init(channelId) { 26 | this.channelId = channelId; 27 | } 28 | 29 | start() { 30 | this.sendMsg('脚本启动🚀....'); 31 | this.subscribe(); 32 | this.timer = setInterval(() => { 33 | if (contractMap.size > 0) { 34 | // 取前3进行展示,其余删除 35 | Array.from(contractMap.values()) 36 | .sort((a, b) => b.count - a.count) 37 | .slice(0, 1) 38 | .forEach(e => this.notify(e)); 39 | 40 | console.log(chalk.yellow('清理合约缓存...')); 41 | contractMap.clear(); 42 | } 43 | }, NOTIFY_DEALY); 44 | } 45 | 46 | getState() { 47 | return ` 48 | 🤖️机器人状态 49 | 合约缓存情况: ${contractMap.size} 条 50 | abi缓存情况: ${queryMap.size} 条 51 | abi请求数: ${this.queryCount} 条 52 | ` 53 | } 54 | 55 | stop() { 56 | this.sendMsg('脚本关闭❌....'); 57 | this.subscription && this.subscription.unsubscribe(function (error, success) { 58 | if (success) { 59 | console.log('Successfully unsubscribed!'); 60 | } 61 | }); 62 | clearInterval(this.timer); 63 | } 64 | 65 | async info() { 66 | const top = Array.from((contractMap?.values() || [])) 67 | .sort((a, b) => b.count - a.count) 68 | .slice(0, 1)?.[0]; 69 | if (top) { 70 | this.sendMsg(this.getState() + await this.getMessage(top)); 71 | } else { 72 | this.sendMsg(this.getState()); 73 | } 74 | } 75 | 76 | async sendMsg(msg) { 77 | const endpoint = `/channels/${this.channelId}/messages`; 78 | await DiscordRequest(endpoint, { 79 | method: 'POST', body: { 80 | content: '', 81 | tts: false, 82 | embeds: [{ 83 | title: '图狗播报', 84 | description: msg 85 | }] 86 | } 87 | }); 88 | } 89 | 90 | /** 91 | * 根据地址查询合约abi 92 | * @param {*} address 93 | * @returns abi[] 94 | */ 95 | async getAbi(address) { 96 | console.log('查询合约:', address); 97 | const old = queryMap.get(address); 98 | if (old) { 99 | console.log(chalk.yellow('使用缓存合约')); 100 | return old 101 | } 102 | const that = this; 103 | that.queryCount += 1; 104 | console.log(chalk.green('发送查询合约请求')); 105 | return await this.instance.get(`https://api.etherscan.io/api?module=contract&action=getabi&address=${address}&apikey=MT4K2JBC4VRH5JHFADE81PAN7RJCIE8HMM`).then(data => { 106 | try { 107 | const contractABI = JSON.parse(data.data.result); 108 | if (queryMap.size >= 1000) { 109 | queryMap.clear(); 110 | console.log(chalk.yellow('清理abi缓存...')); 111 | } 112 | queryMap.set(address, contractABI); 113 | return contractABI; 114 | } catch (e) { 115 | console.log(chalk.red('查询abi失败', e)); 116 | queryMap.set(address, null); 117 | return null; 118 | } 119 | }).catch(err => { 120 | that.sendMsg('查询abi接口异常'); 121 | console.log(chalk.red('查询abi接口异常', err)); 122 | return null; 123 | }).finally(() => { 124 | that.queryCount -= 1; 125 | }); 126 | } 127 | 128 | async getOpenSeaInfoByContract(address) { 129 | const that = this; 130 | console.log('OS上的信息:', address); 131 | return await this.instance.get(`https://api.opensea.io/api/v1/asset_contract/${address}`).then(data => { 132 | return data.data || {}; 133 | }).catch(err=>{ 134 | that.sendMsg('查询opensea接口异常'); 135 | return {}; 136 | }); 137 | } 138 | 139 | /** 140 | * 是否达到最大请求数 141 | */ 142 | isFullQueryCount() { 143 | return this.queryCount >= QUERY_LIMIT; 144 | } 145 | 146 | /** 147 | * 过滤出abi中的mint方法 148 | * @param {*} address 149 | * @returns string[] 150 | */ 151 | async filterMintFunc(address) { 152 | return await this.getAbi(address).then(data => { 153 | if (!data) return []; 154 | var contract = new web3.eth.Contract(data, address); 155 | const { _jsonInterface } = contract; 156 | const mintFuncs = _jsonInterface.filter(func => { 157 | return /mint/.test((func.name || '').toLowerCase()); 158 | }); 159 | return mintFuncs; 160 | }); 161 | } 162 | 163 | /** 164 | * methodId是否是调用的mint函数 165 | * @param {*} abis 166 | * @param {*} methodId 167 | * @returns bool 168 | */ 169 | isCallMint(abis, methodId) { 170 | return abis.map(e => e.signature).includes(methodId); 171 | } 172 | 173 | /** 174 | * 根据methodId返回methodName 175 | * @param {*} abis 176 | * @param {*} methodId 177 | * @returns string 178 | */ 179 | getMethodNameById(abis, methodId) { 180 | return abis.find(e => e.signature === methodId)?.name; 181 | } 182 | 183 | /** 184 | * 该交易是否免费 185 | * @param {*} txData 186 | * @returns bool 187 | */ 188 | isFree(txData) { 189 | const { value, gasPrice, input } = txData; 190 | const methodId = input.slice(0, 10); 191 | return +value === 0 && +gasPrice > 0 && methodId.length === 10; 192 | } 193 | 194 | /** 195 | * 获取methodId 196 | * @param {*} txData 197 | * @returns string 198 | */ 199 | getMethodId(txData) { 200 | const { input } = txData; 201 | return input.slice(0, 10); 202 | } 203 | 204 | /** 205 | * 展示调用函数名 206 | * @param {*} name 207 | */ 208 | showFuncName(name) { 209 | name && console.log(`调用合约函数 ${name}`); 210 | } 211 | 212 | async getMessage(data) { 213 | const osInfo = await this.getOpenSeaInfoByContract(data.to); 214 | console.log(osInfo); 215 | return `freemint项目${MIN}min继续发生mint: 216 | 名称: ${osInfo?.collection?.name || '未知项目'} 217 | 官网: ${osInfo?.collection?.external_link || '无官网信息'} 218 | 合约address: ${data.to} 219 | mint函数: Function ${data.methodName} 调用次数 ${data.count} 次 220 | 合约: https://etherscan.io/address/${data.to}#code 221 | OpenSea: ${osInfo?.collection?.slug ? `https://opensea.io/collection/${osInfo?.collection?.slug}` : '未知'} 222 | 税点: ${osInfo?.seller_fee_basis_points ? `${osInfo.seller_fee_basis_points / 100}%` : '未知'} 223 | `; 224 | } 225 | 226 | async notify(data) { 227 | this.sendMsg(await this.getMessage(data)); 228 | } 229 | 230 | subscribe() { 231 | const that = this; 232 | this.subscription = web3.eth.subscribe('newBlockHeaders', async function (error, blockHeader) { 233 | if (!error) { 234 | const { number } = blockHeader; 235 | const blockData = await web3.eth.getBlock(number); 236 | if (blockData) { 237 | const { transactions } = blockData; 238 | // 达到了最大请求数,需要等待请求数降下来 239 | if (that.isFullQueryCount()) { 240 | console.log('请求数到达限制,跳过'); 241 | return; 242 | } 243 | // 过滤异常交易 244 | if (!transactions || transactions.length === 0) return; 245 | for (let txHash of transactions) { 246 | const txData = await web3.eth.getTransaction(txHash); 247 | // 过滤非免费和无数据交易 248 | if (!txData) continue; 249 | if (!that.isFree(txData)) continue; 250 | 251 | const { to } = txData; 252 | const methodId = that.getMethodId(txData); 253 | // 获取缓存数据 254 | const old = contractMap.get(to); 255 | let methodName = ''; 256 | if (old) { 257 | // 过滤掉不是mint的事件 258 | if (!that.isCallMint(old.abis, methodId)) continue; 259 | contractMap.set(to, { 260 | count: old.count + 1, 261 | abis: old.abis, 262 | methodName: old.methodName, 263 | to: old.to, 264 | }); 265 | methodName = that.getMethodNameById(old.abis, methodId); 266 | } else { 267 | const abis = await that.filterMintFunc(to); 268 | // 过滤掉无abi的情况 269 | if (!abis || !abis.length) continue; 270 | // 过滤掉不是mint的事件 271 | if (!that.isCallMint(abis, methodId)) continue; 272 | 273 | methodName = that.getMethodNameById(abis, methodId); 274 | // 存入缓存数据 275 | contractMap.set(to, { 276 | count: 1, 277 | abis, 278 | methodName, 279 | to, 280 | }); 281 | } 282 | that.showFuncName(methodName); 283 | console.log('疑似白嫖:', txData); 284 | } 285 | } 286 | return; 287 | } 288 | 289 | that.sendMsg('脚本异常....', error); 290 | console.error(error); 291 | }); 292 | } 293 | } 294 | 295 | export default new WatchDog(); --------------------------------------------------------------------------------