Compare commits

..

3 Commits

Author SHA1 Message Date
GooeyTuxedo
bfaa6ebd25 respond with command list when @mentioned 2023-04-18 15:25:12 -07:00
GooeyTuxedo
6036aea919 update README.md and quiet down logs 2023-04-18 14:21:24 -07:00
GooeyTuxedo
5b2975a0cc works and runs, need readme 2023-04-18 13:35:09 -07:00
11 changed files with 218 additions and 68 deletions

View File

@ -2,8 +2,6 @@ FROM node:18-slim as ts-compiler
WORKDIR /usr/app WORKDIR /usr/app
COPY package*.json ./ COPY package*.json ./
COPY tsconfig*.json ./ COPY tsconfig*.json ./
# RUN apt update \
# && apt install build-essential python3 libsqlite3-dev -y
RUN npm install RUN npm install
COPY . ./ COPY . ./
RUN npm run build RUN npm run build
@ -12,15 +10,10 @@ FROM node:18-slim as ts-remover
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=ts-compiler /usr/app/package*.json ./ COPY --from=ts-compiler /usr/app/package*.json ./
COPY --from=ts-compiler /usr/app/dist ./ COPY --from=ts-compiler /usr/app/dist ./
# RUN apt update \
# && apt install build-essential python3 libsqlite3-dev sqlite3 -y
# RUN npm install --build-from-source --sqlite=/usr/bin --omit=dev
RUN npm install --omit=dev RUN npm install --omit=dev
FROM node:18-slim FROM node:18-slim
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=ts-remover /usr/app ./ COPY --from=ts-remover /usr/app ./
COPY .env ./ COPY .env ./
# RUN apt update \
# && apt install libsqlite3-dev sqlite3 -y
CMD node index.js CMD node index.js

View File

@ -1,2 +1,65 @@
# gwei-alert-bot # Gwei Alert Bot
## Introduction
This repo contains Docker build files for a Discord bot built with Node.js and utilizes a composed Redis container for storage.
## Requirements
- Docker + Docker Compose
- A Discord bot token
- A EVM RPC endpoint URL (websocket enabled) OR a local node running at `localhost:8545` (using docker host network mode)
## Usage
1. Clone the repository to your local machine
```bash
git clone https://git.ssh.surf/MrTuxedo/gwei-alert-bot.git
```
2. Create a .env file in the root of the project and add the following environment variables:
```makefile
RPC_URL=<your rpc url>
DISCORD_BOT_TOKEN=<your discord bot token>
DISCORD_CLIENT=<your discord client ID>
```
3. Run the Docker container
```bash
docker compose up -d
```
## Rebuilding with new changes
1. Stop the running bot and remove container by same name
```bash
docker compose down
```
2. Pull the new work
```bash
git pull
```
3. Build and Run
```bash
docker compose up -d --build
```
## Environment Variables
The following environment variables must be set in either `.env` or `docker-compose.yml` in order for the bot to function properly:
- RPC_URL: Your RPC url
- DISCORD_BOT_TOKEN: the Oauth2 token for your Discord bot.
- DISCORD_CLIENT: the client ID for your Discord bot. (app ID)
### License
[WTFPL](./LICENSE)

View File

@ -1,7 +1,9 @@
import 'dotenv/config.js'; import 'dotenv/config.js';
import { GasPrices } from '../types/gasPrices'
import Web3 from 'web3'; import Web3 from 'web3';
import { GasPrices } from '../types/gasPrices'
import redisClient from './redis';
const rpcUrl = process.env.RPC_URL || 'ws://localhost:8545'; const rpcUrl = process.env.RPC_URL || 'ws://localhost:8545';
// Create a new web3 instance // Create a new web3 instance
@ -14,34 +16,46 @@ const web3 = new Web3(new Web3.providers.WebsocketProvider(rpcUrl, {
} }
})); }));
// Get the current gas price in gwei export const subToBlockHeaders = (setDiscordStatus: () => Promise<void>) => {
async function getGasPrice() { web3.eth.subscribe('newBlockHeaders', (error, blockHeader) => {
const gasPrice = await web3.eth.getGasPrice(); if (error) console.error(error);
// Set status every 10 blocks
const shouldSetStatus = blockHeader.number % 10 === 0;
// Get the gas price for this block
web3.eth.getGasPrice((error, gasPrice) => {
if (error) console.error(error);
if (shouldSetStatus) console.log('Gas price in wei:', gasPrice);
redisClient.set('gas-price', Math.round(Number(gasPrice)))
});
if (shouldSetStatus) setDiscordStatus()
});
}
const gweiFromWei = (priceInWei: number): number =>
Math.round(Number(web3.utils.fromWei(`${Math.round(priceInWei)}`, 'gwei')));
const getGasPrice = async (): Promise<number> => {
const gasPrice = await redisClient.get('gas-price');
return Number(gasPrice); return Number(gasPrice);
} }
const getGasPricesInGwei = async (): Promise<GasPrices> => { const getGasPricesInGwei = async (): Promise<GasPrices> => {
const gweiFromWei = (priceInWei: number): number => const gasPrice = await getGasPrice()
Number(web3.utils.fromWei(`${Math.round(priceInWei)}`, 'gwei')); const fastPrice = gasPrice * 1.1;
const slowPrice = gasPrice * 0.9;
try { const gasPrices = {
const gasPrice = await getGasPrice() fast: gweiFromWei(fastPrice),
const fastPrice = gasPrice * 1.2; average: gweiFromWei(gasPrice),
const slowPrice = gasPrice * 0.8; slow: gweiFromWei(slowPrice),
};
const gasPrices = { return gasPrices;
fast: gweiFromWei(fastPrice),
average: gweiFromWei(gasPrice),
slow: gweiFromWei(slowPrice),
};
return Promise.resolve(gasPrices);
// await redisClient.set('gas-prices', JSON.stringify(gasPrices));
} catch (error) {
console.log(error);
return Promise.reject(error);
}
}; };
// Export the getCurrentGasPrice function
export { getGasPricesInGwei }; export { getGasPricesInGwei };

View File

@ -0,0 +1,12 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { handleAlertDeleteCommand } from '../handlers';
module.exports = {
data: new SlashCommandBuilder()
.setName('alert-delete')
.setDescription('Remove your gwei alert threshold'),
async execute(interaction: ChatInputCommandInteraction) {
return await handleAlertDeleteCommand(interaction);
}
}

View File

@ -23,8 +23,6 @@ const rest = new REST({ version: '10' }).setToken(token);
// and deploy your commands! // and deploy your commands!
export const deployCommands = async () => { export const deployCommands = async () => {
try { try {
console.log(`Started refreshing ${commands.length} global application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set // The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put( const data = await rest.put(
Routes.applicationCommands(clientId), Routes.applicationCommands(clientId),

View File

@ -2,23 +2,23 @@ import { Events, Interaction } from 'discord.js';
import { DiscordClient } from '../discordClient'; import { DiscordClient } from '../discordClient';
module.exports = { module.exports = {
name: Events.InteractionCreate, name: Events.InteractionCreate,
async execute(interaction: Interaction) { async execute(interaction: Interaction) {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const client = interaction.client as DiscordClient; const client = interaction.client as DiscordClient;
const command = client.commands.get(interaction.commandName); const command = client.commands.get(interaction.commandName);
if (!command) { if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`); console.error(`No command matching ${interaction.commandName} was found.`);
return; return;
} }
try { try {
await command.execute(interaction); await command.execute(interaction);
} catch (error) { } catch (error) {
console.error(`Error executing ${interaction.commandName}`); console.error(`Error executing ${interaction.commandName}`);
console.error(error); console.error(error);
} }
}, }
}; };

24
src/events/message.ts Normal file
View File

@ -0,0 +1,24 @@
import 'dotenv/config.js';
import { Events, Message, EmbedBuilder, User } from "discord.js";
import { DiscordClient } from '../discordClient';
module.exports = {
name: Events.MessageCreate,
async execute(client: DiscordClient, message: Message) {
if (message.author.bot) return;
if (message.mentions.has(client.user as User)) {
const embeds = [
new EmbedBuilder()
.setColor('#008000')
.setTitle('Available slash commands')
.setDescription(
`/gas
/alert [GWEI]
/alert-delete
/pending-alert`
)
]
message.reply({embeds});
}
}
};

View File

@ -5,6 +5,6 @@ module.exports = {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
execute(client: DiscordClient) { execute(client: DiscordClient) {
if (client.user) return console.log(`Ready! Logged in as ${client.user.tag}`); if (client.user) return console.log(`Ready! Logged in as ${client.user.id}: ${client.user.tag}`);
} }
}; };

View File

@ -1,7 +1,8 @@
import { EmbedBuilder, TextChannel } from 'discord.js'; import { EmbedBuilder, TextChannel } from 'discord.js';
import { getGasPricesInGwei } from './blockchain'; import { getGasPricesInGwei } from './blockchain';
import redisClient from './redis';
import { DiscordClient } from './discordClient'; import { DiscordClient } from './discordClient';
import redisClient from './redis';
import { GasAlert } from '../types/gasAlert'; import { GasAlert } from '../types/gasAlert';
@ -11,8 +12,8 @@ const createGasPriceChecker = (client: DiscordClient) => {
const gasPrices = await getGasPricesInGwei(); const gasPrices = await getGasPricesInGwei();
const gasAlerts: GasAlert[] = await redisClient const gasAlerts: GasAlert[] = await redisClient
.get('gas-alerts') .hVals('gas-alerts')
.then((value) => (value ? JSON.parse(value) : [])); .then((values) => values.map(val => JSON.parse(val)));
gasAlerts.forEach(async (gasAlert) => { gasAlerts.forEach(async (gasAlert) => {
if (gasPrices.average <= gasAlert.threshold) { if (gasPrices.average <= gasAlert.threshold) {
@ -23,15 +24,20 @@ const createGasPriceChecker = (client: DiscordClient) => {
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setTitle('Gas price alert!') .setTitle('Gas price alert!')
.setDescription(`<@${gasAlert.userId}>!\n\nThe current gas prices have fallen below your alert threshold of ${gasAlert.threshold} Gwei. The current gas prices are: \n\n Fast: ${gasPrices.fast} Gwei \n\n Average: ${gasPrices.average} Gwei \n\n Slow: ${gasPrices.slow} Gwei`) .setDescription(`<@${gasAlert.userId}>!
Gas prices have fallen below your alert threshold of ${gasAlert.threshold} Gwei:
${gasPrices.fast} 🚶${gasPrices.average} 🐢${gasPrices.slow}`)
.setColor('#FF8C00') .setColor('#FF8C00')
], ],
target: user target: user
}) })
console.log(`Alerted ${user} under threshold ${gasAlert.threshold}: Removing alert...`)
await redisClient.hDel('gas-alerts', `${gasAlert.userId}`)
} }
}); });
} catch (error) { } catch (error) {
console.error(`Error checking gas prices: ${error}`); console.log(`Error checking gas prices:\n`, error);
} }
}, 15000); }, 15000);
}; };

View File

@ -8,12 +8,12 @@ import { GasAlert } from '../types/gasAlert';
const handleGasCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => { const handleGasCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const gasPrices = await getGasPricesInGwei(); const gasPrices = await getGasPricesInGwei();
console.log(`Replying to command "/gas": \n${gasPrices}`); console.log(`Replying to command "/gas": \n`, gasPrices);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor('#0099ff') .setColor('#0099ff')
.setTitle('Current Gas Prices') .setTitle('Current Gas Prices')
.setDescription(`The current gas prices are: \n\n Fast: ${gasPrices.fast} Gwei \n\n Average: ${gasPrices.average} Gwei \n\n Slow: ${gasPrices.slow} Gwei`) .setDescription(`${gasPrices.fast} ⦚⦚ 🚶${gasPrices.average} ⦚⦚ 🐢${gasPrices.slow}`)
await interaction.reply({ embeds: [embed] }); await interaction.reply({ embeds: [embed] });
}; };
@ -28,9 +28,7 @@ const handleGasAlertCommand = async (interaction: ChatInputCommandInteraction):
console.log(`Replying to command "/gas gwei ${threshold}"`); console.log(`Replying to command "/gas gwei ${threshold}"`);
await redisClient.connect();
await redisClient.hSet('gas-alerts', `${userId}`, JSON.stringify(gasAlert)); await redisClient.hSet('gas-alerts', `${userId}`, JSON.stringify(gasAlert));
await redisClient.disconnect();
await interaction.reply(`Your gas alert threshold of ${threshold} Gwei has been set.`); await interaction.reply(`Your gas alert threshold of ${threshold} Gwei has been set.`);
}; };
@ -39,17 +37,28 @@ const handleGasAlertCommand = async (interaction: ChatInputCommandInteraction):
const handleGasPendingCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => { const handleGasPendingCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const userId = interaction.user.id; const userId = interaction.user.id;
await redisClient.connect(); const alertStr = await redisClient.hGet('gas-alerts', `${userId}`);
const alertThreshold = await redisClient.get(`${userId}`); const { threshold = null } = alertStr ? JSON.parse(alertStr) : {};
await redisClient.disconnect();
console.log(`Replying to command "/gas pending"`); console.log(`Replying to command "/gas pending"`);
if (alertThreshold === null) { if (threshold === null) {
await interaction.reply('No gas price alerts set'); await interaction.reply('No gas price alerts set');
} else { } else {
await interaction.reply(`Current gas price alert threshold: ${alertThreshold} Gwei`); await interaction.reply(`Current gas price alert threshold: ${threshold} Gwei`);
} }
}; };
export { handleGasCommand, handleGasAlertCommand, handleGasPendingCommand }; // Respond to the "/alert-delete" command
const handleAlertDeleteCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const userId = interaction.user.id;
const user = interaction.user.username;
console.log(`Replying to command "/alert-delete"`);
await redisClient.hDel('gas-alerts', `${userId}`);
await interaction.reply(`Removed your gas price alert, ${user}`);
};
export { handleGasCommand, handleGasAlertCommand, handleGasPendingCommand, handleAlertDeleteCommand };

View File

@ -1,10 +1,21 @@
/* This program is free software. It comes without any warranty, to
* the extent permitted by applicable law. You can redistribute it
* and/or modify it under the terms of the Do What The Fuck You Want
* To Public License, Version 2, as published by Sam Hocevar. See
* http://www.wtfpl.net/ for more details. */
import 'dotenv/config.js'; import 'dotenv/config.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { GatewayIntentBits } from 'discord.js'; import { ActivityType, GatewayIntentBits } from 'discord.js';
import { getGasPricesInGwei, subToBlockHeaders } from './blockchain';
import { deployCommands } from './deploy'; import { deployCommands } from './deploy';
import { DiscordClient } from './discordClient'; import { DiscordClient } from './discordClient';
import { createGasPriceChecker } from './gasPriceChecker'; import { createGasPriceChecker } from './gasPriceChecker';
import redisClient from './redis';
import { GasAlert } from '../types/gasAlert';
const token = process.env.DISCORD_BOT_TOKEN || ""; const token = process.env.DISCORD_BOT_TOKEN || "";
@ -33,16 +44,36 @@ for (const file of eventFiles) {
const event = require(filePath); const event = require(filePath);
if (event.once) { if (event.once) {
client.once(event.name, (...args) => event.execute(...args)); client.once(event.name, (...args) => event.execute(...args));
} else if (event.name == 'messageCreate') {
client.on(event.name, (...args) => event.execute(client, ...args));
} else { } else {
client.on(event.name, (...args) => event.execute(...args)); client.on(event.name, (...args) => event.execute(...args));
} }
} }
deployCommands();
console.log('Booting up discord bot') console.log('Booting up discord bot')
// Log in to Discord // Log in to Discord
client.login(token) client.login(token)
.then(deployCommands) .then(async () => {
// Connect to redis
await redisClient.connect()
.catch((reason) => console.log("Error connecting to redis!\n", reason))
})
.then(async () => {
const setDiscordStatus = async () => {
if (client.user) {
const { average, fast, slow } = await getGasPricesInGwei();
client.user.setActivity(
`${fast} ⦚ 🚶${average} ⦚ 🐢${slow}`
, { type: ActivityType.Watching });
}
}
// Start listening to blockchain
subToBlockHeaders(setDiscordStatus);
})
.then(() => { .then(() => {
// Start the gas price checker // Start the gas price checker
createGasPriceChecker(client); createGasPriceChecker(client);