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"] +}