From cf38d5b181c00337e3c23780e9cd11c6f1512dba Mon Sep 17 00:00:00 2001 From: Charles Zhang Date: Sat, 29 May 2021 02:40:40 -0400 Subject: [PATCH 1/2] dockerized local dev env dockerize init using docker compose to manage container removed debug using npm instead of yarn when building image change container workdir persist logs and db using volumes --- .dockerignore | 1 + .eslintrc | 22 +++---- .gitignore | 10 +-- .prettierrc | 12 ++-- .vscode/settings.json | 14 ++-- Dockerfile | 14 ++++ README.md | 36 +++++++---- docker-compose.yml | 10 +++ index.ts | 95 +--------------------------- package.json | 14 ++-- src/bot.ts | 90 ++++++++++++++++++++++++++ {components => src/components}/db.ts | 26 ++++---- logger.ts => src/logger.ts | 80 +++++++++++------------ tsconfig.json | 18 +++--- 14 files changed, 243 insertions(+), 199 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/bot.ts rename {components => src/components}/db.ts (80%) rename logger.ts => src/logger.ts (96%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 56dcc08..97e36c8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,11 +1,11 @@ -{ - "parser": "@typescript-eslint/parser", // Specifies the ESLint parser - "env": { - "ecmaVersion": 2020 // Allows for the parsing of modern ECMAScript features - }, - "extends": [ - "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 - "plugin:prettier/recommended" - ] -} +{ + "parser": "@typescript-eslint/parser", // Specifies the ESLint parser + "env": { + "ecmaVersion": 2020 // Allows for the parsing of modern ECMAScript features + }, + "extends": [ + "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 + "plugin:prettier/recommended" + ] +} diff --git a/.gitignore b/.gitignore index facbc7d..702d497 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -/node_modules -.env -/logs -/dist -/db \ No newline at end of file +/node_modules +.env +/logs/* +/dist +/db/* diff --git a/.prettierrc b/.prettierrc index 98b3f77..0706fd1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ -{ - "semi": true, - "trailingComma": "none", - "singleQuote": true, - "printWidth": 120 -} +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 120 +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c04db92..9b9a67f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ -{ - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } -} +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bdcd43a --- /dev/null +++ b/Dockerfile @@ -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" ] diff --git a/README.md b/README.md index 30d7430..b5912bb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,24 @@ -# Codey Bot - -## Required environment variables - -- `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. - -## Running the bot locally - -1. Run `yarn` to install dependencies. -1. Add the required environment variables in a `.env` file in the root directory. -1. Run `yarn dev` to start the bot locally. +# Codey Bot + +## Required environment variables + +- `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. + +## Prerequisites + +- [Yarn](https://classic.yarnpkg.com/en/docs/install) +- [Docker](https://docs.docker.com/get-docker/) (tested up to v20.10.6) + +## 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` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4cbf2c9 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/index.ts b/index.ts index a4a0e5b..1288c74 100644 --- a/index.ts +++ b/index.ts @@ -1,92 +1,3 @@ -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 - }); - } -}; - -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(); +import { startBot } from './src/bot'; + +startBot(); diff --git a/package.json b/package.json index fd7788c..5e94be7 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,16 @@ "description": "", "main": "index.js", "scripts": { - "dev": "concurrently \"yarn run-dev\" \"yarn watch\"", - "run-dev": "cross-env NODE_ENV=dev nodemon index.ts", - "watch": "tsc --watch", - "build": "tsc", + "local:run": "concurrently \"yarn node:watch\" \"yarn ts:watch\"", + "node:watch": "NODE_ENV=dev nodemon index.ts", + "ts:watch": "tsc --watch", + "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" }, "author": "", diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..3217ec9 --- /dev/null +++ b/src/bot.ts @@ -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); +}; diff --git a/components/db.ts b/src/components/db.ts similarity index 80% rename from components/db.ts rename to src/components/db.ts index f6c9568..ba25027 100644 --- a/components/db.ts +++ b/src/components/db.ts @@ -1,22 +1,22 @@ -import sqlite3 = require('sqlite3') -import { open, Database } from 'sqlite' -import Discord from 'discord.js' +import sqlite3 = require('sqlite3'); +import { open, Database } from 'sqlite'; +import Discord from 'discord.js'; -let db : Database | null = null; +let db: Database | null = null; -export async function openDB () { - if(db == null){ +export async function openDB() { + if (db == null) { db = await open({ - filename: './db/bot.db', + filename: 'db/bot.db', 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; } -export async function testDb(message: Discord.Message, command: string, args: string[]){ - switch(command){ +export async function testDb(message: Discord.Message, command: string, args: string[]) { + switch (command) { case 'save': if (args.length < 1) { await message.channel.send('no args'); @@ -35,7 +35,7 @@ export async function testDb(message: Discord.Message, command: string, args: st .setTitle('Database Dump') .setURL('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); 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']); outEmbed = outEmbed.addField(rows['msg_id'], rows['data'], true); 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'); diff --git a/logger.ts b/src/logger.ts similarity index 96% rename from logger.ts rename to src/logger.ts index 3ea5c09..4fe8398 100644 --- a/logger.ts +++ b/src/logger.ts @@ -1,40 +1,40 @@ -const winston = require('winston'); -require('winston-daily-rotate-file'); - -const dailyRotateTransport = new winston.transports.DailyRotateFile({ - filename: '%DATE%.log', - dirname: 'logs', - zippedArchive: true -}); - -const dailyRotateErrorTransport = new winston.transports.DailyRotateFile({ - filename: 'error-%DATE%.log', - dirname: 'logs', - zippedArchive: true, - level: 'error' -}); - -const consoleTransport = new winston.transports.Console({ - format: winston.format.prettyPrint() -}); - -const logger = winston.createLogger({ - format: winston.format.combine( - winston.format.timestamp(), - winston.format.printf( - ({ level, message, timestamp }: { level: string; message: string; timestamp: string }) => - `[${timestamp}] ${level}: ${JSON.stringify(message)}` - ) - ), - transports: [dailyRotateTransport, dailyRotateErrorTransport] -}); - -if (process.env.NODE_ENV === 'dev') { - logger.add( - new winston.transports.Console({ - format: winston.format.prettyPrint() - }) - ); -} - -export default logger; +const winston = require('winston'); +require('winston-daily-rotate-file'); + +const dailyRotateTransport = new winston.transports.DailyRotateFile({ + filename: '%DATE%.log', + dirname: 'logs', + zippedArchive: true +}); + +const dailyRotateErrorTransport = new winston.transports.DailyRotateFile({ + filename: 'error-%DATE%.log', + dirname: 'logs', + zippedArchive: true, + level: 'error' +}); + +const consoleTransport = new winston.transports.Console({ + format: winston.format.prettyPrint() +}); + +const logger = winston.createLogger({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf( + ({ level, message, timestamp }: { level: string; message: string; timestamp: string }) => + `[${timestamp}] ${level}: ${JSON.stringify(message)}` + ) + ), + transports: [dailyRotateTransport, dailyRotateErrorTransport] +}); + +if (process.env.NODE_ENV === 'dev') { + logger.add( + new winston.transports.Console({ + format: winston.format.prettyPrint() + }) + ); +} + +export default logger; diff --git a/tsconfig.json b/tsconfig.json index c7a4974..c3a239e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ -{ - "extends": "@tsconfig/node14/tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "preserveConstEnums": true, - "esModuleInterop": true - }, - "include": ["**/*.ts"] -} +{ + "extends": "@tsconfig/node14/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "preserveConstEnums": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} From 1edc459b8d17b5d17b0abce9ce49bbb9ec912001 Mon Sep 17 00:00:00 2001 From: Charles Zhang Date: Sun, 30 May 2021 13:00:52 -0400 Subject: [PATCH 2/2] keep db and logs dirs for volume mounting --- .gitignore | 2 ++ db/.gitkeep | 0 logs/.gitkeep | 0 3 files changed, 2 insertions(+) create mode 100644 db/.gitkeep create mode 100644 logs/.gitkeep diff --git a/.gitignore b/.gitignore index 702d497..34364b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /node_modules .env /logs/* +!/logs/.gitkeep /dist /db/* +!/db/.gitkeep diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29