diff --git a/.github/workflows/pull-test.yml b/.github/workflows/pull-test.yml index 802dd13..ac6048e 100644 --- a/.github/workflows/pull-test.yml +++ b/.github/workflows/pull-test.yml @@ -1,26 +1,34 @@ -name: PR unit test - -on: - pull_request: - paths: - - '9armbot/**' - - 'core/**' - - '__test__/**' - branches: - - main - -jobs: - build: - runs-on: windows-latest - steps: - - uses: actions/checkout@v2 - - name: Setup node - uses: actions/setup-node@v2 - with: - node-version: '14' - architecture: 'x64' - check-latest: true - - name: Install dependencies - run: npm install - - name: Test - run: npm test +name: PR unit test + +on: + push: + branches: + - main + - v2.0.0 + pull_request: + paths: + - "9armbot/**" + - "9armbot-2.0/**" + - "core/**" + - "__test__/**" + branches: + - main + - v2.0.0 + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Setup node + uses: actions/setup-node@v2 + with: + node-version: "14" + architecture: "x64" + check-latest: true + - name: Install dependencies + run: npm install + - name: Test + run: npm test + - name: Test (2.0) + run: npm run test-2.0 diff --git a/.gitignore b/.gitignore index 8812074..25ce7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules 9armbot/players.json package-lock.json yarn.lock +players-2.0.json +.node_repl_history +dist diff --git a/9armbot-2.0/.prettierrc b/9armbot-2.0/.prettierrc new file mode 100644 index 0000000..b04bde6 --- /dev/null +++ b/9armbot-2.0/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/9armbot-2.0/console.ts b/9armbot-2.0/console.ts new file mode 100644 index 0000000..fb3731e --- /dev/null +++ b/9armbot-2.0/console.ts @@ -0,0 +1,28 @@ +/** + * 9armbot Console + * Warning : Since this console toes not share the same process as the NodeJS server, + * writing to db will overwrite everything! (Singleton does not work across processes, damn!) + */ + +import repl from 'repl' +import _ from 'lodash' +import { dbService } from './services/db' + +const replServer = repl.start({ + prompt: `9armbot(${process.env.NODE_ENV || 'development'}) > `, +}) + +replServer.setupHistory('./.node_repl_history', () => {}) + +// Preload database +dbService.load() + +console.log('Database loaded, press enter to continue.') + +// Access db eg. `db.read()` +// Type `db.` then press Tab to see all available commands +replServer.context.db = dbService + +// Lodash (_ is reserved, use l or __ instead) +replServer.context.l = _ +replServer.context.__ = _ diff --git a/9armbot-2.0/index.ts b/9armbot-2.0/index.ts new file mode 100644 index 0000000..7d8af7e --- /dev/null +++ b/9armbot-2.0/index.ts @@ -0,0 +1,19 @@ +import { twitchService } from './services/twitch' +import { discordService } from './services/discord' +import { dbService } from './services/db' + +async function main() { + dbService.load() + dbService.save() + + await twitchService() + await discordService() + + console.log('9armbot 2.0 Running...') + + setInterval(() => { + console.log('db', 'save database', dbService.save()) + }, 15000) +} + +main() diff --git a/9armbot-2.0/jest.config.js b/9armbot-2.0/jest.config.js new file mode 100644 index 0000000..3a8d305 --- /dev/null +++ b/9armbot-2.0/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/test/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"], +}; diff --git a/9armbot-2.0/services/bot.ts b/9armbot-2.0/services/bot.ts new file mode 100644 index 0000000..2f9b513 --- /dev/null +++ b/9armbot-2.0/services/bot.ts @@ -0,0 +1 @@ +export class Bot {} diff --git a/9armbot-2.0/services/db.ts b/9armbot-2.0/services/db.ts new file mode 100644 index 0000000..99b27a9 --- /dev/null +++ b/9armbot-2.0/services/db.ts @@ -0,0 +1,88 @@ +import find from 'lodash/find' +import fs from 'fs' +import { nanoid } from 'nanoid' +import path from 'path' + +const DB_PATH = path.resolve(process.cwd(), './players-2.0.json') + +export interface IDb { + players: IPlayer[] +} + +export interface IPlayer { + uid: string + username: string +} + +export class Db { + private db: IDb = { + players: [], + } + + public load(): void { + try { + const playersJson = fs.readFileSync(DB_PATH, 'utf8') + this.db = JSON.parse(playersJson) + console.log(`Loaded ${this.db.players.length} players from ${DB_PATH}.`) + } catch (err) { + console.log('[ERROR] File not found, use default blank db.', err.message) + } + } + + public save(): void { + try { + const data = JSON.stringify(this.read(), null, 2) + fs.writeFileSync(DB_PATH, data, 'utf8') + console.log(`Saved ${this.db.players.length} players to ${DB_PATH}.`) + } catch (err) { + console.log('[ERROR] Cannot write file.', err.message) + } + } + + public read(): IDb { + return this.db + } + + public createPlayer(username: string): IPlayer { + // Don't re-create existing player + const player = this.getPlayerbyUsername(username) + + if (player) { + return player + } + + const newPlayer: IPlayer = { + uid: nanoid(), + username, + } + + this.db.players.push(newPlayer) + + return newPlayer + } + + public getPlayerbyUsername(username: string): IPlayer | undefined { + return find(this.db.players, (p) => { + return p.username === username + }) + } +} + +// Graceful Shutdown +function gracefulShutdown(): void { + console.log('\nPre-Close') + dbService.save() + setTimeout(() => { + console.log('Closed') + process.exit(0) + }, 1000) +} +process.on('SIGINT', () => gracefulShutdown()) +process.on('SIGTERM', () => gracefulShutdown()) +process.on('uncaughtException', (err) => { + console.error(err) + gracefulShutdown() +}) + +// Use singleton pattern to mitigate multiple readers/writers +export const dbService = new Db() diff --git a/9armbot-2.0/services/discord.ts b/9armbot-2.0/services/discord.ts new file mode 100644 index 0000000..ecbd6e3 --- /dev/null +++ b/9armbot-2.0/services/discord.ts @@ -0,0 +1,23 @@ +import dotenv from 'dotenv' +import { customAlphabet } from 'nanoid' +import { dbService } from './db' + +dotenv.config() + +export async function discordService() { + // Test db reading + setInterval(() => { + console.log('discord read db from dbservice', dbService.read()) + }, 2500) + + // Test db writing + const randomName = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8) + + setInterval(() => { + console.log( + 'discord', + 'test adding random players', + dbService.createPlayer(randomName()), + ) + }, 7000) +} diff --git a/9armbot-2.0/services/twitch.ts b/9armbot-2.0/services/twitch.ts new file mode 100644 index 0000000..fab56b7 --- /dev/null +++ b/9armbot-2.0/services/twitch.ts @@ -0,0 +1,31 @@ +import tmi from 'tmi.js' +import fs from 'fs' +import dotenv from 'dotenv' +import path from 'path' +import { dbService } from './db' + +dotenv.config() + +let oauth_token = fs.readFileSync( + path.resolve(__dirname, '../../9armbot/oauth_token'), + 'utf8', +) + +export async function twitchService() { + const client = new tmi.Client({ + options: { debug: true }, + connection: { reconnect: true }, + identity: { + username: process.env.tmi_username, + password: oauth_token, + }, + channels: [process.env.tmi_channel_name as string], + }) + + await client.connect() + + // Test db reading + setInterval(() => { + console.log('twitch read db from dbservice', dbService.read()) + }, 2000) +} diff --git a/9armbot-2.0/test/db.test.ts b/9armbot-2.0/test/db.test.ts new file mode 100644 index 0000000..bc6773a --- /dev/null +++ b/9armbot-2.0/test/db.test.ts @@ -0,0 +1,113 @@ +import fs from 'fs' +import { Db, IDb } from '../services/db' + +jest.mock('fs') + +const mockFs = fs as jest.Mocked + +let db: Db + +beforeEach(() => { + db = new Db() +}) + +describe('Database', () => { + describe('#load', () => { + it('loads data from json file and initiate players', () => { + const dbFileContent: IDb = { + players: [ + { + uid: 'uid', + username: 'foo', + }, + ], + } + + mockFs.readFileSync.mockReturnValueOnce(JSON.stringify(dbFileContent)) + + db.load() + + expect(db.read()).toEqual(dbFileContent) + expect(mockFs.readFileSync).toHaveBeenCalledTimes(1) + }) + + it('falls back to default if file does not exist', () => { + const dbFileBlank: IDb = { + players: [], + } + + mockFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory') + }) + + expect(db.read()).toEqual(dbFileBlank) + expect(mockFs.readFileSync).toHaveBeenCalledTimes(1) + }) + }) + + describe('#save', () => { + it('saves the current database state to json file', () => { + db.createPlayer('foo') + + const expectedJson = JSON.stringify(db.read(), null, 2) + + db.save() + + expect(mockFs.writeFileSync).toHaveBeenCalledTimes(1) + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expectedJson, + 'utf8', + ) + }) + }) +}) + +describe('CRUD players', () => { + describe('#createPlayer', () => { + it('can create new player by username (twitch for now)', () => { + db.createPlayer('foo') + + expect(db.read()).toEqual({ + players: [ + { + uid: expect.any(String), + username: 'foo', + }, + ], + }) + }) + + it('does not create new player if username is existed', () => { + db.createPlayer('foo') + db.createPlayer('foo') + db.createPlayer('foo') + + expect(db.read()).toEqual({ + players: [ + { + uid: expect.any(String), + username: 'foo', + }, + ], + }) + }) + }) + + describe('#getPlayerbyUsername', () => { + it('returns undefined if player is not found', () => { + expect(db.getPlayerbyUsername('foo')).toBeUndefined + }) + + it('returns player if found by username', () => { + db.createPlayer('foo') + + expect(db.getPlayerbyUsername('foo')).toEqual({ + uid: expect.any(String), + username: 'foo', + }) + + expect(db.getPlayerbyUsername('bar')).toBeUndefined + }) + }) +}) diff --git a/README.md b/README.md index b8792fb..fa3a0a6 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,9 @@ $ npm install $ npm start ``` > More information about `oauth_token` [here](https://dev.twitch.tv/docs/irc). + +### Debug Console + +```bash +$ npm run console +``` \ No newline at end of file diff --git a/package.json b/package.json index d300646..331b7c8 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,15 @@ "description": "Repo to keep my community's twitch gadgeties.", "main": "index.js", "scripts": { - "test": "jest --verbose", + "test": "jest --verbose ./__test__", "start": "cd 9armbot && node 9armbot.js", - "dev": "cd 9armbot && nodemon 9armbot.js --ignore '*.json'" + "dev": "cd 9armbot && nodemon 9armbot.js --ignore '*.json'", + "dev-2.0": "ts-node-dev --respawn 9armbot-2.0", + "build-2.0": "tsc", + "start-2.0": "node dist", + "test-2.0": "jest --config 9armbot-2.0/jest.config.js", + "test-2.0:watch": "jest --config 9armbot-2.0/jest.config.js --watch", + "console": "ts-node 9armbot-2.0/console" }, "repository": { "type": "git", @@ -20,15 +26,24 @@ "homepage": "https://github.com/thananon/twitch_tools#readme", "dependencies": { "axios": "^0.21.1", + "discord.js": "^12.5.2", "dotenv": "^8.2.0", "ejs": "^3.1.6", "express": "^4.17.1", + "lodash": "^4.17.21", "multer": "^1.4.2", + "nanoid": "^3.1.22", "socket.io": "^3.1.1", "tmi.js": "^1.7.1" }, "devDependencies": { + "@types/jest": "^26.0.22", + "@types/lodash": "^4.14.168", + "@types/tmi.js": "^1.7.1", + "jest": "^26.6.3", "nodemon": "^2.0.7", - "jest": "^26.6.3" + "ts-jest": "^26.5.4", + "ts-node-dev": "^1.1.6", + "typescript": "^4.2.3" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e611389 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], + "sourceMap": true, + "outDir": "./dist", + "moduleResolution": "node", + "removeComments": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "baseUrl": ".", + "allowJs": true + }, + "exclude": ["node_modules"], + "include": ["./9armbot-2.0/**/*.ts"] +}