Merge branch 'docker' into 'master'

Dockerized local development environment

See merge request csc/discord-bot!8
This commit is contained in:
Charles Zhang 2021-06-03 23:50:48 +00:00
commit 90c51a9e95
16 changed files with 245 additions and 199 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
node_modules

View File

@ -1,11 +1,11 @@
{ {
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser "parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"env": { "env": {
"ecmaVersion": 2020 // Allows for the parsing of modern ECMAScript features "ecmaVersion": 2020 // Allows for the parsing of modern ECMAScript features
}, },
"extends": [ "extends": [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
"prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
"plugin:prettier/recommended" "plugin:prettier/recommended"
] ]
} }

12
.gitignore vendored
View File

@ -1,5 +1,7 @@
/node_modules /node_modules
.env .env
/logs /logs/*
/dist !/logs/.gitkeep
/db /dist
/db/*
!/db/.gitkeep

View File

@ -1,6 +1,6 @@
{ {
"semi": true, "semi": true,
"trailingComma": "none", "trailingComma": "none",
"singleQuote": true, "singleQuote": true,
"printWidth": 120 "printWidth": 120
} }

14
.vscode/settings.json vendored
View File

@ -1,7 +1,7 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
} }
} }

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:16-alpine
# Create app directory
WORKDIR /usr/app
# Install app dependencies
COPY package.json .
COPY yarn.lock .
RUN npm install
# Copy app files
COPY . .
CMD [ "npm", "run", "local:run" ]

View File

@ -1,12 +1,24 @@
# Codey Bot # Codey Bot
## Required environment variables ## Required environment variables
- `BOT_TOKEN`: the token found in the bot user account. - `BOT_TOKEN`: the token found in the bot user account.
- `NOTIF_CHANNEL_ID`: the ID of the channel the bot will send system notifications to. - `NOTIF_CHANNEL_ID`: the ID of the channel the bot will send system notifications to.
## Running the bot locally ## Prerequisites
1. Run `yarn` to install dependencies. - [Yarn](https://classic.yarnpkg.com/en/docs/install)
1. Add the required environment variables in a `.env` file in the root directory. - [Docker](https://docs.docker.com/get-docker/) (tested up to v20.10.6)
1. Run `yarn dev` to start the bot locally.
## Running the bot locally
1. Build docker image: `yarn image:build`
1. Start container in detached mode: `yarn start`
1. View and follow console output: `yarn logs`
## Other usage
- Stop the container: `yarn stop`
- Stop and remove the container: `yarn clean`
- Restart the container: `yarn restart`
- Fresh build and restart: `yarn image:build && yarn clean && yarn start`

0
db/.gitkeep Normal file
View File

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
services:
codey-bot:
image: codey:latest
container_name: codey-bot
environment:
- CHOKIDAR_USEPOLLING=true
volumes:
- ./src:/usr/app/src
- ./logs:/usr/app/logs
- ./db:/usr/app/db

View File

@ -1,92 +1,3 @@
import dotenv from 'dotenv'; import { startBot } from './src/bot';
dotenv.config();
startBot();
import Discord from 'discord.js';
import _ from 'lodash';
import { openDB, testDb } from './components/db';
import logger from './logger';
const NOTIF_CHANNEL_ID: string = process.env.NOTIF_CHANNEL_ID || '.';
const BOT_TOKEN: string = process.env.BOT_TOKEN || '.';
const BOT_PREFIX = '.';
const client = new Discord.Client();
const parseCommand = (message: Discord.Message): { command: string | null; args: string[] } => {
// extract arguments by splitting by spaces and grouping strings in quotes
// e.g. .ping 1 "2 3" => ['ping', '1', '2 3']
let args = message.content.slice(BOT_PREFIX.length).match(/[^\s"']+|"([^"]*)"|'([^']*)'/g);
args = _.map(args, (arg) => {
if (arg[0].match(/'|"/g) && arg[arg.length - 1].match(/'|"/g)) {
return arg.slice(1, arg.length - 1);
}
return arg;
});
// obtain the first argument after the prefix
const firstArg = args.shift();
if (!firstArg) return { command: null, args: [] };
const command = firstArg.toLowerCase();
return { command, args };
};
const handleCommand = async (message: Discord.Message, command: string, args: string[]) => {
// log command and its author info
logger.info({
event: 'command',
messageId: message.id,
author: message.author.id,
authorName: message.author.username,
channel: message.channel.id,
command,
args
});
switch (command) {
case 'ping':
await message.channel.send('pong');
break;
}
//dev testing
if (process.env.NODE_ENV == 'dev') {
testDb(message, command, args);
}
};
const handleMessage = async (message: Discord.Message) => {
// ignore messages without bot prefix and messages from other bots
if (!message.content.startsWith(BOT_PREFIX) || message.author.bot) return;
// obtain command and args from the command message
const { command, args } = parseCommand(message);
if (!command) return;
try {
await handleCommand(message, command, args);
} catch (e) {
// log error
logger.error({
event: 'error',
messageId: message.id,
command: command,
args: args,
error: e
});
}
};
const startBot = async () => {
client.once('ready', async () => {
// log bot init event and send system notification
logger.info({
event: 'init'
});
const notif = (await client.channels.fetch(NOTIF_CHANNEL_ID)) as Discord.TextChannel;
notif.send('Codey is up!');
});
client.on('message', handleMessage);
client.login(BOT_TOKEN);
};
startBot();

0
logs/.gitkeep Normal file
View File

View File

@ -4,10 +4,16 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "concurrently \"yarn run-dev\" \"yarn watch\"", "local:run": "concurrently \"yarn node:watch\" \"yarn ts:watch\"",
"run-dev": "cross-env NODE_ENV=dev nodemon index.ts", "node:watch": "NODE_ENV=dev nodemon index.ts",
"watch": "tsc --watch", "ts:watch": "tsc --watch",
"build": "tsc", "ts:build": "tsc",
"image:build": "docker build . -t codey",
"clean": "docker compose down",
"stop": "docker compose stop",
"start": "docker compose up -d",
"restart": "docker compose restart",
"logs": "docker logs -f codey-bot",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"author": "", "author": "",

90
src/bot.ts Normal file
View File

@ -0,0 +1,90 @@
import dotenv from 'dotenv';
dotenv.config();
import Discord from 'discord.js';
import _ from 'lodash';
import { openDB, testDb } from './components/db';
import logger from './logger';
const NOTIF_CHANNEL_ID: string = process.env.NOTIF_CHANNEL_ID || '.';
const BOT_TOKEN: string = process.env.BOT_TOKEN || '.';
const BOT_PREFIX = '.';
const client = new Discord.Client();
const parseCommand = (message: Discord.Message): { command: string | null; args: string[] } => {
// extract arguments by splitting by spaces and grouping strings in quotes
// e.g. .ping 1 "2 3" => ['ping', '1', '2 3']
let args = message.content.slice(BOT_PREFIX.length).match(/[^\s"']+|"([^"]*)"|'([^']*)'/g);
args = _.map(args, (arg) => {
if (arg[0].match(/'|"/g) && arg[arg.length - 1].match(/'|"/g)) {
return arg.slice(1, arg.length - 1);
}
return arg;
});
// obtain the first argument after the prefix
const firstArg = args.shift();
if (!firstArg) return { command: null, args: [] };
const command = firstArg.toLowerCase();
return { command, args };
};
const handleCommand = async (message: Discord.Message, command: string, args: string[]) => {
// log command and its author info
logger.info({
event: 'command',
messageId: message.id,
author: message.author.id,
authorName: message.author.username,
channel: message.channel.id,
command,
args
});
switch (command) {
case 'ping':
await message.channel.send('pong');
break;
}
//dev testing
if (process.env.NODE_ENV == 'dev') {
testDb(message, command, args);
}
};
const handleMessage = async (message: Discord.Message) => {
// ignore messages without bot prefix and messages from other bots
if (!message.content.startsWith(BOT_PREFIX) || message.author.bot) return;
// obtain command and args from the command message
const { command, args } = parseCommand(message);
if (!command) return;
try {
await handleCommand(message, command, args);
} catch (e) {
// log error
logger.error({
event: 'error',
messageId: message.id,
command: command,
args: args,
error: e
});
}
};
export const startBot = async () => {
client.once('ready', async () => {
// log bot init event and send system notification
logger.info({
event: 'init'
});
const notif = (await client.channels.fetch(NOTIF_CHANNEL_ID)) as Discord.TextChannel;
notif.send('Codey is up!');
});
client.on('message', handleMessage);
client.login(BOT_TOKEN);
};

View File

@ -1,22 +1,22 @@
import sqlite3 = require('sqlite3') import sqlite3 = require('sqlite3');
import { open, Database } from 'sqlite' import { open, Database } from 'sqlite';
import Discord from 'discord.js' import Discord from 'discord.js';
let db : Database | null = null; let db: Database | null = null;
export async function openDB () { export async function openDB() {
if(db == null){ if (db == null) {
db = await open({ db = await open({
filename: './db/bot.db', filename: 'db/bot.db',
driver: sqlite3.Database driver: sqlite3.Database
}) });
await db.run('CREATE TABLE IF NOT EXISTS saved_data (msg_id INTEGER PRIMARY KEY,data TEXT NOT NULL);') await db.run('CREATE TABLE IF NOT EXISTS saved_data (msg_id INTEGER PRIMARY KEY,data TEXT NOT NULL);');
} }
return db; return db;
} }
export async function testDb(message: Discord.Message, command: string, args: string[]){ export async function testDb(message: Discord.Message, command: string, args: string[]) {
switch(command){ switch (command) {
case 'save': case 'save':
if (args.length < 1) { if (args.length < 1) {
await message.channel.send('no args'); await message.channel.send('no args');
@ -35,7 +35,7 @@ export async function testDb(message: Discord.Message, command: string, args: st
.setTitle('Database Dump') .setTitle('Database Dump')
.setURL('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); .setURL('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
const res = await db.all('SELECT * FROM saved_data'); const res = await db.all('SELECT * FROM saved_data');
for(const rows of res){ for (const rows of res) {
console.log(rows['msg_id'], rows['data']); console.log(rows['msg_id'], rows['data']);
outEmbed = outEmbed.addField(rows['msg_id'], rows['data'], true); outEmbed = outEmbed.addField(rows['msg_id'], rows['data'], true);
console.log(outEmbed); console.log(outEmbed);
@ -65,4 +65,4 @@ export async function testDb(message: Discord.Message, command: string, args: st
} }
} }
console.log('connected to db') console.log('connected to db');

View File

@ -1,40 +1,40 @@
const winston = require('winston'); const winston = require('winston');
require('winston-daily-rotate-file'); require('winston-daily-rotate-file');
const dailyRotateTransport = new winston.transports.DailyRotateFile({ const dailyRotateTransport = new winston.transports.DailyRotateFile({
filename: '%DATE%.log', filename: '%DATE%.log',
dirname: 'logs', dirname: 'logs',
zippedArchive: true zippedArchive: true
}); });
const dailyRotateErrorTransport = new winston.transports.DailyRotateFile({ const dailyRotateErrorTransport = new winston.transports.DailyRotateFile({
filename: 'error-%DATE%.log', filename: 'error-%DATE%.log',
dirname: 'logs', dirname: 'logs',
zippedArchive: true, zippedArchive: true,
level: 'error' level: 'error'
}); });
const consoleTransport = new winston.transports.Console({ const consoleTransport = new winston.transports.Console({
format: winston.format.prettyPrint() format: winston.format.prettyPrint()
}); });
const logger = winston.createLogger({ const logger = winston.createLogger({
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp(), winston.format.timestamp(),
winston.format.printf( winston.format.printf(
({ level, message, timestamp }: { level: string; message: string; timestamp: string }) => ({ level, message, timestamp }: { level: string; message: string; timestamp: string }) =>
`[${timestamp}] ${level}: ${JSON.stringify(message)}` `[${timestamp}] ${level}: ${JSON.stringify(message)}`
) )
), ),
transports: [dailyRotateTransport, dailyRotateErrorTransport] transports: [dailyRotateTransport, dailyRotateErrorTransport]
}); });
if (process.env.NODE_ENV === 'dev') { if (process.env.NODE_ENV === 'dev') {
logger.add( logger.add(
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.prettyPrint() format: winston.format.prettyPrint()
}) })
); );
} }
export default logger; export default logger;

View File

@ -1,9 +1,9 @@
{ {
"extends": "@tsconfig/node14/tsconfig.json", "extends": "@tsconfig/node14/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"preserveConstEnums": true, "preserveConstEnums": true,
"esModuleInterop": true "esModuleInterop": true
}, },
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }