Compare commits

...

24 Commits

Author SHA1 Message Date
76101943de Move redis connection url to env var 2023-05-08 18:23:41 -07:00
852b9cfc76 rm pull_policy: build 2023-05-03 14:10:38 -07:00
1ae3ef7a03 pass env vars through docker-compose.yml 2023-04-23 11:32:44 -07:00
3a9c68f21c always build new bot image 2023-04-23 11:24:56 -07:00
31d5a250b5 Don't import .env explicitly 2023-04-23 11:14:02 -07:00
20185635fb retry forever? 2023-04-22 20:48:28 -07:00
09e409c62d retry more times 2023-04-22 20:38:29 -07:00
d9de65911b don't prefetch txns 2023-04-22 19:37:10 -07:00
f78674578b Refactor to compute GasPrices differently 2023-04-22 15:58:38 -07:00
ef228254b3 handle observable errors 2023-04-22 11:46:24 -07:00
34f7f23ae8 remove unnecessary await 2023-04-21 22:26:48 -07:00
b16ef7c368 auto reply ignore roles and @everyone/@here 2023-04-21 20:37:52 -07:00
8b06d7dbdf fix some formatting 2023-04-21 17:24:10 -07:00
8b2ae82cfd Need to sort gas price list to find correct percentiles 2023-04-21 17:09:00 -07:00
f64fbfb71d reduce once instead of thrice 2023-04-21 16:44:51 -07:00
d28cc9b3f7 fix the formulas for fast/slow 2023-04-21 16:19:16 -07:00
deee0f205a Use rxjs observables for blockchain data 2023-04-21 15:51:09 -07:00
4f5812c2b8 set status every block, discord doesn't rate limit until 50 req/sec 2023-04-18 21:30:23 -07:00
5c3e1fdd6a save to disk every 5 minutes 2023-04-18 17:02:29 -07:00
08c71e3a60 Fix responding to bot replies 2023-04-18 17:00:28 -07:00
9fcddcaa28 fix build for ARM 2023-04-18 15:52:09 -07:00
bfaa6ebd25 respond with command list when @mentioned 2023-04-18 15:25:12 -07:00
6036aea919 update README.md and quiet down logs 2023-04-18 14:21:24 -07:00
5b2975a0cc works and runs, need readme 2023-04-18 13:35:09 -07:00
16 changed files with 394 additions and 3551 deletions

View File

@ -2,8 +2,8 @@ FROM node:18-slim as ts-compiler
WORKDIR /usr/app
COPY package*.json ./
COPY tsconfig*.json ./
# RUN apt update \
# && apt install build-essential python3 libsqlite3-dev -y
RUN apt update \
&& apt install build-essential python3 -y
RUN npm install
COPY . ./
RUN npm run build
@ -12,15 +12,11 @@ FROM node:18-slim as ts-remover
WORKDIR /usr/app
COPY --from=ts-compiler /usr/app/package*.json ./
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 apt update \
&& apt install build-essential python3 -y
RUN npm install --omit=dev
FROM node:18-slim
WORKDIR /usr/app
COPY --from=ts-remover /usr/app ./
COPY .env ./
# RUN apt update \
# && apt install libsqlite3-dev sqlite3 -y
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

@ -7,11 +7,11 @@ services:
restart: always
volumes:
- redisdb:/data
command: redis-server --save 900 1 --appendonly yes
command: redis-server --save 300 1 --appendonly yes
environment:
- REDIS_REPLICATION_MODE=master
- REDIS_APPENDONLY=yes
- REDIS_SAVE=900 1
- REDIS_SAVE=300 1
gwei-alert-bot:
depends_on:
- gwei-bot-redis
@ -22,6 +22,10 @@ services:
- 1.1.1.1
environment:
- NODE_ENV=production
- REDIS_URL=redis://gwei-bot-redis:6379
- DISCORD_BOT_TOKEN
- DISCORD_CLIENT
- RPC_URL
restart: unless-stopped
volumes:

3497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,13 @@
"dependencies": {
"discord.js": "^14.9.0",
"dotenv": "^16.0.3",
"ethers": "^6.3.0",
"lodash": "^4.17.21",
"redis": "^4.6.5",
"web3": "^1.9.0"
"rxjs": "^7.8.0"
},
"devDependencies": {
"@types/dotenv": "^8.2.0",
"@types/redis": "^4.0.11",
"@types/lodash": "^4.14.194",
"typescript": "^4.9.5"
}
}

View File

@ -1,47 +1,59 @@
import 'dotenv/config.js';
import { GasPrices } from '../types/gasPrices'
import Web3 from 'web3';
import { WebSocketProvider, formatUnits } from 'ethers';
import { Observable, throwError } from 'rxjs';
import { catchError, map, scan, retry } from 'rxjs/operators';
const rpcUrl = process.env.RPC_URL || 'ws://localhost:8545';
import { GasPrices } from '../types/gasPrices';
// Create a new web3 instance
const web3 = new Web3(new Web3.providers.WebsocketProvider(rpcUrl, {
reconnect: {
auto: true,
delay: 5000,
maxAttempts: 5,
onTimeout: false
}
}));
const rpcUrl = process.env.RPC_URL || "wss://ropsten.infura.io/ws/v3/YOUR_INFURA_PROJECT_ID";
// Get the current gas price in gwei
async function getGasPrice() {
const gasPrice = await web3.eth.getGasPrice();
return Number(gasPrice);
}
const getGasPricesInGwei = async (): Promise<GasPrices> => {
const gweiFromWei = (priceInWei: number): number =>
Number(web3.utils.fromWei(`${Math.round(priceInWei)}`, 'gwei'));
const provider = new WebSocketProvider(rpcUrl);
const blockBaseFeePerGasObservable$ = new Observable<number>((observer) => {
provider.on('block', async (blockNumber) => {
try {
const gasPrice = await getGasPrice()
const fastPrice = gasPrice * 1.2;
const slowPrice = gasPrice * 0.8;
const { baseFeePerGas } = await provider.getBlock(blockNumber) || {};
const gasPrices = {
fast: gweiFromWei(fastPrice),
average: gweiFromWei(gasPrice),
slow: gweiFromWei(slowPrice),
};
if (!baseFeePerGas) throw new Error(`Error fetching block! ${blockNumber}`);
return Promise.resolve(gasPrices);
// await redisClient.set('gas-prices', JSON.stringify(gasPrices));
// Log averages every 10 blocks
if (blockNumber % 10 == 0) console.log(
`Found new block data for ${blockNumber}! Gas price: 🐢 ${Number(formatUnits(baseFeePerGas, "gwei")).toFixed(2)} Gwei`
)
observer.next(Number(formatUnits(baseFeePerGas, "gwei")));
} catch (error) {
console.log(error);
return Promise.reject(error);
observer.error(`Error fetching block! ${error}`);
}
};
});
});
// Export the getCurrentGasPrice function
export { getGasPricesInGwei };
const baseGasPricesObservable$ = blockBaseFeePerGasObservable$.pipe(
scan<number, GasPrices>(
(acc: GasPrices, curr: number): GasPrices => {
// Keep only the 20 latest values
const values: number[] = acc.values ? [...acc.values.slice(-19), curr] : [curr];
const fast: number = Math.max(...values);
const slow: number = Math.min(...values);
const sum: number = values.reduce((a, b) => a + b, 0);
const average: number = sum / values.length;
return { values, fast, slow, average };
},
{ values: [], fast: -Infinity, slow: Infinity, average: NaN } // Initial value
),
// Only emit the computed prices
map<GasPrices, GasPrices>((computedGasPrices: GasPrices): GasPrices => {
const { fast, average, slow } = computedGasPrices;
return {
fast: Math.round(fast),
average: Math.round(average),
slow: Math.round(slow)
};
}),
catchError(err => throwError(() => new Error(err))),
retry(-1) // retry forever
);
export { baseGasPricesObservable$ };

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!
export const deployCommands = async () => {
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
const data = await rest.put(
Routes.applicationCommands(clientId),

View File

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

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

@ -0,0 +1,34 @@
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) {
// Don't respond to bots or replies
if (message.author.bot) return;
if (message.mentions.has(client.user as User, {
ignoreRoles: true,
ignoreRepliedUser: true,
ignoreEveryone: true
})) {
const embeds = [
new EmbedBuilder()
.setColor('#008000')
.setTitle('Available slash commands')
.setDescription(
`/gas
/alert [GWEI]
/alert-delete
/pending-alert`
)
]
message.reply({embeds});
}
}
};

42
src/gasAlertChecker.ts Normal file
View File

@ -0,0 +1,42 @@
import { EmbedBuilder, TextChannel } from 'discord.js';
import { DiscordClient } from './discordClient';
import redisClient from './redis';
import { GasAlert } from '../types/gasAlert';
import { GasPrices } from '../types/gasPrices';
const createGasAlertChecker = (client: DiscordClient) =>
async (gasPrices: GasPrices) => {
try {
const gasAlerts: GasAlert[] = await redisClient
.hVals('gas-alerts')
.then((values) => values.map(val => JSON.parse(val)));
gasAlerts.forEach(async (gasAlert) => {
if (gasPrices.fast <= gasAlert.threshold) {
const channel = await client.channels.fetch(gasAlert.channelId) as TextChannel;
const user = await client.users.fetch(gasAlert.userId);
channel.send({
embeds: [
new EmbedBuilder()
.setTitle('Gas price alert!')
.setDescription(`<@${gasAlert.userId}>!
Gas prices have fallen below your alert threshold of ${gasAlert.threshold} Gwei:
${gasPrices.fast} ⦚⦚🚶 ${gasPrices.average} ⦚⦚ 🐢 ${gasPrices.slow}`)
.setColor('#FF8C00')
],
target: user
})
console.log(`Alerted ${user} under threshold ${gasAlert.threshold}: Removing alert...`)
await redisClient.hDel('gas-alerts', `${gasAlert.userId}`)
}
});
} catch (error) {
console.log(`Error checking gas prices:\n`, error);
}
};
export { createGasAlertChecker };

View File

@ -1,39 +0,0 @@
import { EmbedBuilder, TextChannel } from 'discord.js';
import { getGasPricesInGwei } from './blockchain';
import redisClient from './redis';
import { DiscordClient } from './discordClient';
import { GasAlert } from '../types/gasAlert';
const createGasPriceChecker = (client: DiscordClient) => {
setInterval(async () => {
try {
const gasPrices = await getGasPricesInGwei();
const gasAlerts: GasAlert[] = await redisClient
.get('gas-alerts')
.then((value) => (value ? JSON.parse(value) : []));
gasAlerts.forEach(async (gasAlert) => {
if (gasPrices.average <= gasAlert.threshold) {
const channel = await client.channels.fetch(gasAlert.channelId) as TextChannel;
const user = await client.users.fetch(gasAlert.userId);
channel.send({
embeds: [
new EmbedBuilder()
.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`)
.setColor('#FF8C00')
],
target: user
})
}
});
} catch (error) {
console.error(`Error checking gas prices: ${error}`);
}
}, 15000);
};
export { createGasPriceChecker };

View File

@ -1,21 +1,27 @@
import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import redisClient from './redis';
import { getGasPricesInGwei } from './blockchain';
import { GasAlert } from '../types/gasAlert';
import { GasPrices } from '../types/gasPrices';
// Respond to the "/gas" command
const handleGasCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const gasPrices = await getGasPricesInGwei();
const gasPricesStr = await redisClient.get('current-prices');
console.log(`Replying to command "/gas": \n${gasPrices}`);
if (gasPricesStr) {
const gasPrices = JSON.parse(gasPricesStr) as GasPrices;
const embed = new EmbedBuilder()
console.log(`Replying to command "/gas": \n`, gasPrices);
const embed = new EmbedBuilder()
.setColor('#0099ff')
.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`)
await interaction.reply({ embeds: [embed] });
.setDescription(` ${gasPrices.fast} ⦚⦚🚶 ${gasPrices.average} ⦚⦚ 🐢 ${gasPrices.slow}`);
await interaction.reply({ embeds: [embed] });
} else {
console.log(`Error fetching gas prices!`);
await interaction.reply('ERROR FETCHING GAS!!!1!')
}
};
// Respond to the "/gas alert ${gwei}" command
@ -28,9 +34,7 @@ const handleGasAlertCommand = async (interaction: ChatInputCommandInteraction):
console.log(`Replying to command "/gas gwei ${threshold}"`);
await redisClient.connect();
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.`);
};
@ -39,17 +43,28 @@ const handleGasAlertCommand = async (interaction: ChatInputCommandInteraction):
const handleGasPendingCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const userId = interaction.user.id;
await redisClient.connect();
const alertThreshold = await redisClient.get(`${userId}`);
await redisClient.disconnect();
const alertStr = await redisClient.hGet('gas-alerts', `${userId}`);
const { threshold = null } = alertStr ? JSON.parse(alertStr) : {};
console.log(`Replying to command "/gas pending"`);
if (alertThreshold === null) {
if (threshold === null) {
await interaction.reply('No gas price alerts set');
} 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,16 +1,38 @@
/* 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 fs from 'node:fs';
import path from 'node:path';
import { GatewayIntentBits } from 'discord.js';
import { ActivityType, GatewayIntentBits } from 'discord.js';
import { baseGasPricesObservable$ } from './blockchain';
import { deployCommands } from './deploy';
import { DiscordClient } from './discordClient';
import { createGasPriceChecker } from './gasPriceChecker';
import { createGasAlertChecker } from './gasAlertChecker';
import redisClient from './redis';
import { GasPrices } from '../types/gasPrices';
const token = process.env.DISCORD_BOT_TOKEN || "";
// Create a new Discord client
const client = new DiscordClient({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] });
const doAlerts = createGasAlertChecker(client);
const setDiscordStatus = ({ average, fast, slow }: GasPrices) => {
if (client.user) {
client.user.setActivity(
`${fast} ⦚🚶 ${average} ⦚ 🐢 ${slow}`
, { type: ActivityType.Watching });
}
}
// Load bot commands
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
@ -25,6 +47,7 @@ for (const file of commandFiles) {
}
}
// Load bot events
const eventsPath = path.join(__dirname, 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
@ -33,17 +56,37 @@ for (const file of eventFiles) {
const event = require(filePath);
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else if (event.name == 'messageCreate') {
client.on(event.name, (...args) => event.execute(client, ...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}
deployCommands();
console.log('Booting up discord bot')
// Log in to Discord
client.login(token)
.then(deployCommands)
.then(async () => {
// Connect to redis
await redisClient.connect()
.catch((reason) => console.log("Error connecting to redis!\n", reason))
})
.then(() => {
// Start the gas price checker
createGasPriceChecker(client);
});
// Start listening to blockchain
baseGasPricesObservable$.subscribe({
next: async (averageGasPrices) => {
setDiscordStatus(averageGasPrices);
await redisClient.set('current-prices', JSON.stringify(averageGasPrices))
await doAlerts(averageGasPrices);
},
error: (err) => console.error(err),
complete: () => console.log("Blockchain data stream closed")
})
})
.catch(
(err) => console.log(`Bot runtime error!
${err}`)
);

View File

@ -1,9 +1,9 @@
import { createClient } from 'redis';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
// Create a new Redis client
const client = createClient({
url: 'redis://gwei-bot-redis:6379'
});
const client = createClient({ url: redisUrl });
// Log any Redis errors to the console
client.on('error', (error) => {

View File

@ -2,4 +2,5 @@ export interface GasPrices {
fast: number;
average: number;
slow: number;
values?: number[];
}