Compare commits

..

21 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
12 changed files with 210 additions and 3517 deletions

View File

@ -2,6 +2,8 @@ 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 -y
RUN npm install RUN npm install
COPY . ./ COPY . ./
RUN npm run build RUN npm run build
@ -10,10 +12,11 @@ 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 -y
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 ./
CMD node index.js CMD node index.js

View File

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

3497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,61 +1,59 @@
import 'dotenv/config.js'; import 'dotenv/config.js';
import Web3 from 'web3'; import { WebSocketProvider, formatUnits } from 'ethers';
import { Observable, throwError } from 'rxjs';
import { catchError, map, scan, retry } from 'rxjs/operators';
import { GasPrices } from '../types/gasPrices' import { GasPrices } from '../types/gasPrices';
import redisClient from './redis';
const rpcUrl = process.env.RPC_URL || 'ws://localhost:8545'; const rpcUrl = process.env.RPC_URL || "wss://ropsten.infura.io/ws/v3/YOUR_INFURA_PROJECT_ID";
// Create a new web3 instance const provider = new WebSocketProvider(rpcUrl);
const web3 = new Web3(new Web3.providers.WebsocketProvider(rpcUrl, {
reconnect: { const blockBaseFeePerGasObservable$ = new Observable<number>((observer) => {
auto: true, provider.on('block', async (blockNumber) => {
delay: 5000, try {
maxAttempts: 5, const { baseFeePerGas } = await provider.getBlock(blockNumber) || {};
onTimeout: false
if (!baseFeePerGas) throw new Error(`Error fetching block! ${blockNumber}`);
// 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) {
observer.error(`Error fetching block! ${error}`);
} }
})); });
export const subToBlockHeaders = (setDiscordStatus: () => Promise<void>) => {
web3.eth.subscribe('newBlockHeaders', (error, blockHeader) => {
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 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 gweiFromWei = (priceInWei: number): number => const fast: number = Math.max(...values);
Math.round(Number(web3.utils.fromWei(`${Math.round(priceInWei)}`, 'gwei'))); const slow: number = Math.min(...values);
const sum: number = values.reduce((a, b) => a + b, 0);
const average: number = sum / values.length;
const getGasPrice = async (): Promise<number> => { return { values, fast, slow, average };
const gasPrice = await redisClient.get('gas-price'); },
return Number(gasPrice); { values: [], fast: -Infinity, slow: Infinity, average: NaN } // Initial value
} ),
// Only emit the computed prices
const getGasPricesInGwei = async (): Promise<GasPrices> => { map<GasPrices, GasPrices>((computedGasPrices: GasPrices): GasPrices => {
const gasPrice = await getGasPrice() const { fast, average, slow } = computedGasPrices;
const fastPrice = gasPrice * 1.1; return {
const slowPrice = gasPrice * 0.9; fast: Math.round(fast),
average: Math.round(average),
const gasPrices = { slow: Math.round(slow)
fast: gweiFromWei(fastPrice),
average: gweiFromWei(gasPrice),
slow: gweiFromWei(slowPrice),
}; };
}),
catchError(err => throwError(() => new Error(err))),
retry(-1) // retry forever
);
return gasPrices; export { baseGasPricesObservable$ };
};
export { getGasPricesInGwei };

View File

@ -1,12 +1,22 @@
import 'dotenv/config.js'; import 'dotenv/config.js';
import { Events, Message, EmbedBuilder, User } from "discord.js"; import {
Events,
Message,
EmbedBuilder,
User
} from "discord.js";
import { DiscordClient } from '../discordClient'; import { DiscordClient } from '../discordClient';
module.exports = { module.exports = {
name: Events.MessageCreate, name: Events.MessageCreate,
async execute(client: DiscordClient, message: Message) { async execute(client: DiscordClient, message: Message) {
// Don't respond to bots or replies
if (message.author.bot) return; if (message.author.bot) return;
if (message.mentions.has(client.user as User)) { if (message.mentions.has(client.user as User, {
ignoreRoles: true,
ignoreRepliedUser: true,
ignoreEveryone: true
})) {
const embeds = [ const embeds = [
new EmbedBuilder() new EmbedBuilder()
.setColor('#008000') .setColor('#008000')

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.id}: ${client.user.tag}`); if (client.user) return console.log(`Ready! Logged in as ${client.user.tag}`);
} }
}; };

View File

@ -1,22 +1,20 @@
import { EmbedBuilder, TextChannel } from 'discord.js'; import { EmbedBuilder, TextChannel } from 'discord.js';
import { getGasPricesInGwei } from './blockchain';
import { DiscordClient } from './discordClient'; import { DiscordClient } from './discordClient';
import redisClient from './redis'; import redisClient from './redis';
import { GasAlert } from '../types/gasAlert'; import { GasAlert } from '../types/gasAlert';
import { GasPrices } from '../types/gasPrices';
const createGasPriceChecker = (client: DiscordClient) => { const createGasAlertChecker = (client: DiscordClient) =>
setInterval(async () => { async (gasPrices: GasPrices) => {
try { try {
const gasPrices = await getGasPricesInGwei();
const gasAlerts: GasAlert[] = await redisClient const gasAlerts: GasAlert[] = await redisClient
.hVals('gas-alerts') .hVals('gas-alerts')
.then((values) => values.map(val => JSON.parse(val))); .then((values) => values.map(val => JSON.parse(val)));
gasAlerts.forEach(async (gasAlert) => { gasAlerts.forEach(async (gasAlert) => {
if (gasPrices.average <= gasAlert.threshold) { if (gasPrices.fast <= gasAlert.threshold) {
const channel = await client.channels.fetch(gasAlert.channelId) as TextChannel; const channel = await client.channels.fetch(gasAlert.channelId) as TextChannel;
const user = await client.users.fetch(gasAlert.userId); const user = await client.users.fetch(gasAlert.userId);
@ -39,7 +37,6 @@ Gas prices have fallen below your alert threshold of ${gasAlert.threshold} Gwei:
} catch (error) { } catch (error) {
console.log(`Error checking gas prices:\n`, error); console.log(`Error checking gas prices:\n`, error);
} }
}, 15000);
}; };
export { createGasPriceChecker }; export { createGasAlertChecker };

View File

@ -1,21 +1,27 @@
import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import redisClient from './redis'; import redisClient from './redis';
import { getGasPricesInGwei } from './blockchain';
import { GasAlert } from '../types/gasAlert'; import { GasAlert } from '../types/gasAlert';
import { GasPrices } from '../types/gasPrices';
// Respond to the "/gas" command // Respond to the "/gas" command
const handleGasCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => { const handleGasCommand = async (interaction: ChatInputCommandInteraction): Promise<void> => {
const gasPrices = await getGasPricesInGwei(); const gasPricesStr = await redisClient.get('current-prices');
if (gasPricesStr) {
const gasPrices = JSON.parse(gasPricesStr) as GasPrices;
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(`${gasPrices.fast} ⦚⦚ 🚶${gasPrices.average} ⦚⦚ 🐢${gasPrices.slow}`) .setDescription(` ${gasPrices.fast} ⦚⦚🚶 ${gasPrices.average} ⦚⦚ 🐢 ${gasPrices.slow}`);
await interaction.reply({ embeds: [embed] }); 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 // Respond to the "/gas alert ${gwei}" command

View File

@ -9,19 +9,30 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { ActivityType, GatewayIntentBits } from 'discord.js'; import { ActivityType, GatewayIntentBits } from 'discord.js';
import { getGasPricesInGwei, subToBlockHeaders } from './blockchain'; import { baseGasPricesObservable$ } from './blockchain';
import { deployCommands } from './deploy'; import { deployCommands } from './deploy';
import { DiscordClient } from './discordClient'; import { DiscordClient } from './discordClient';
import { createGasPriceChecker } from './gasPriceChecker'; import { createGasAlertChecker } from './gasAlertChecker';
import redisClient from './redis'; import redisClient from './redis';
import { GasAlert } from '../types/gasAlert'; import { GasPrices } from '../types/gasPrices';
const token = process.env.DISCORD_BOT_TOKEN || ""; const token = process.env.DISCORD_BOT_TOKEN || "";
// Create a new Discord client // Create a new Discord client
const client = new DiscordClient({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] }); 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 commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
@ -36,6 +47,7 @@ for (const file of commandFiles) {
} }
} }
// Load bot events
const eventsPath = path.join(__dirname, 'events'); const eventsPath = path.join(__dirname, 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
@ -62,19 +74,19 @@ client.login(token)
await redisClient.connect() await redisClient.connect()
.catch((reason) => console.log("Error connecting to redis!\n", reason)) .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 listening to blockchain
createGasPriceChecker(client); 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'; import { createClient } from 'redis';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
// Create a new Redis client // Create a new Redis client
const client = createClient({ const client = createClient({ url: redisUrl });
url: 'redis://gwei-bot-redis:6379'
});
// Log any Redis errors to the console // Log any Redis errors to the console
client.on('error', (error) => { client.on('error', (error) => {

View File

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