├── .all-contributorsrc ├── .babelrc ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── main.yml │ └── test.yml ├── .gitignore ├── .mocharc.js ├── .yarn └── install-state.gz ├── .yarnrc.yml ├── LICENSE.md ├── README.md ├── ecosystem.config.js ├── jsconfig.json ├── package.json ├── src ├── data-mocks │ ├── bits.js │ ├── emoteonly.js │ ├── extendsub.js │ ├── giftpaidupgrade.js │ ├── primepaidupgrade.js │ ├── raid.js │ ├── resubscription.js │ ├── slowmode.js │ ├── subgift.js │ ├── submysterygift.js │ ├── subscription.js │ └── subsonly.js ├── data │ ├── CAPABILITIES.js │ ├── CHEERMOTE_PREFIXES.js │ └── DOLLARBUCK_CORRELATIONS.js ├── helpers │ ├── apps.js │ ├── bodyBuilder.js │ ├── dedupeArray.js │ ├── firebase.js │ ├── getMock.js │ ├── handleCAPMessage.js │ ├── handleJOINMessage.js │ ├── handleNICKMessage.js │ ├── handlePARTMessage.js │ ├── handlePASSMessage.js │ ├── handlePINGMessage.js │ ├── handlePONGMessage.js │ ├── handlePRIVMSGMessage.js │ ├── handleQUITMessage.js │ ├── handleUSERMessage.js │ ├── jsdocHelpers │ │ └── firstLine.js │ ├── jsdocPartials │ │ ├── docs.hbs │ │ ├── examples.hbs │ │ └── params.hbs │ ├── log.js │ ├── renderCommandResponse.js │ ├── renderMessage.js │ ├── renderTemplate.js │ ├── serializeTwitchObject.js │ ├── statusCodeGenerator.js │ └── updateStat.js ├── index.js ├── routes │ ├── fdgt │ │ └── v1 │ │ │ ├── commands.js │ │ │ ├── commands │ │ │ ├── [command].js │ │ │ └── [command] │ │ │ │ ├── docs.js │ │ │ │ └── params.js │ │ │ ├── contributors.js │ │ │ └── sponsors.js │ └── index.js └── structures │ ├── API.js │ ├── Channel.js │ ├── ChannelList.js │ ├── Collection.js │ ├── Connection.js │ ├── Route.js │ ├── Router.js │ ├── User.js │ └── UserList.js ├── test ├── commands │ ├── bits.test.js │ ├── reconnect.test.js │ └── subgift.test.js ├── routes │ └── fdgt │ │ └── v1 │ │ ├── commands.test.js │ │ ├── commands │ │ └── [command].test.js │ │ ├── contributors.test.js │ │ └── sponsors.test.js ├── setup.js ├── structures │ ├── API.test.js │ ├── Channel.test.js │ ├── ChannelList.test.js │ ├── Collection.test.js │ ├── Connection.test.js │ ├── Route.test.js │ ├── Router.test.js │ ├── User.test.js │ └── UserList.test.js └── test-helpers │ ├── createConnection.js │ ├── createFDGTUser.js │ ├── createIRCSocket.js │ └── createWSSocket.js └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "badgeTemplate": "\"All-orange.svg?style=flat-square\">", 7 | "commit": false, 8 | "contributors": [ 9 | { 10 | "login": "trezy", 11 | "name": "Trezy", 12 | "avatar_url": "https://avatars2.githubusercontent.com/u/442980?v=4", 13 | "profile": "http://trezy.com", 14 | "contributions": [ 15 | "code", 16 | "business", 17 | "doc", 18 | "example", 19 | "infra", 20 | "maintenance" 21 | ] 22 | }, 23 | { 24 | "login": "d-fischer", 25 | "name": "Daniel Fischer", 26 | "avatar_url": "https://avatars3.githubusercontent.com/u/5854687?v=4", 27 | "profile": "https://github.com/d-fischer", 28 | "contributions": [ 29 | "doc", 30 | "review", 31 | "code", 32 | "test", 33 | "maintenance" 34 | ] 35 | }, 36 | { 37 | "login": "Trico-Everfire", 38 | "name": "Trico Everfire", 39 | "avatar_url": "https://avatars3.githubusercontent.com/u/55441008?v=4", 40 | "profile": "https://github.com/Trico-Everfire", 41 | "contributions": [ 42 | "bug" 43 | ] 44 | }, 45 | { 46 | "login": "iProdigy", 47 | "name": "Sidd", 48 | "avatar_url": "https://avatars0.githubusercontent.com/u/8106344?v=4", 49 | "profile": "https://github.com/iProdigy", 50 | "contributions": [ 51 | "bug", 52 | "review", 53 | "doc", 54 | "example", 55 | "code", 56 | "test" 57 | ] 58 | }, 59 | { 60 | "login": "PhilippHeuer", 61 | "name": "Philipp Heuer", 62 | "avatar_url": "https://avatars0.githubusercontent.com/u/10275049?v=4", 63 | "profile": "https://www.philippheuer.me/", 64 | "contributions": [ 65 | "bug" 66 | ] 67 | }, 68 | { 69 | "login": "bdashore3", 70 | "name": "Brian Dashore", 71 | "avatar_url": "https://avatars2.githubusercontent.com/u/8082010?v=4", 72 | "profile": "https://github.com/bdashore3", 73 | "contributions": [ 74 | "doc", 75 | "example", 76 | "ideas", 77 | "question" 78 | ] 79 | }, 80 | { 81 | "login": "pajlada", 82 | "name": "pajlada", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/962989?v=4", 84 | "profile": "https://pajlada.se/", 85 | "contributions": [ 86 | "code", 87 | "doc" 88 | ] 89 | }, 90 | { 91 | "login": "itssimple", 92 | "name": "Chris Gårdenberg", 93 | "avatar_url": "https://avatars.githubusercontent.com/u/11502257?v=4", 94 | "profile": "https://itssimple.se", 95 | "contributions": [ 96 | "doc" 97 | ] 98 | } 99 | ], 100 | "contributorsPerLine": 7, 101 | "projectName": "api", 102 | "projectOwner": "fdgt-apis", 103 | "repoType": "github", 104 | "repoHost": "https://github.com", 105 | "skipCi": true 106 | } 107 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "node": true 6 | } 7 | }] 8 | ], 9 | "plugins": [ 10 | ["module-resolver", { 11 | "root": ["./src"] 12 | }], 13 | "@babel/plugin-proposal-class-properties", 14 | "@babel/plugin-proposal-private-methods" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [trezy] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: trezy 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'type: feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - trezy 10 | ignore: 11 | - dependency-name: "@babel/core" 12 | versions: 13 | - 7.12.10 14 | - 7.12.16 15 | - 7.12.17 16 | - 7.13.10 17 | - 7.13.13 18 | - 7.13.14 19 | - 7.13.15 20 | - 7.13.8 21 | - dependency-name: "@babel/cli" 22 | versions: 23 | - 7.12.10 24 | - 7.12.16 25 | - 7.12.17 26 | - 7.13.0 27 | - 7.13.10 28 | - 7.13.14 29 | - dependency-name: faker 30 | versions: 31 | - 5.4.0 32 | - 5.5.1 33 | - 5.5.2 34 | - dependency-name: "@babel/preset-env" 35 | versions: 36 | - 7.12.11 37 | - 7.12.16 38 | - 7.12.17 39 | - 7.13.10 40 | - 7.13.12 41 | - 7.13.8 42 | - 7.13.9 43 | - dependency-name: "@babel/node" 44 | versions: 45 | - 7.12.10 46 | - 7.12.16 47 | - 7.12.17 48 | - 7.13.0 49 | - 7.13.10 50 | - dependency-name: chai 51 | versions: 52 | - 4.3.0 53 | - 4.3.3 54 | - dependency-name: mocha 55 | versions: 56 | - 8.2.1 57 | - 8.3.0 58 | - 8.3.1 59 | - dependency-name: jsdoc-to-markdown 60 | versions: 61 | - 7.0.0 62 | - dependency-name: firebase-admin 63 | versions: 64 | - 9.4.2 65 | - 9.5.0 66 | - dependency-name: systeminformation 67 | versions: 68 | - 4.34.5 69 | - 4.34.9 70 | - dependency-name: nodemon 71 | versions: 72 | - 2.0.7 73 | - dependency-name: date-and-time 74 | versions: 75 | - 0.14.2 76 | - dependency-name: "@babel/register" 77 | versions: 78 | - 7.12.10 79 | - dependency-name: ini 80 | versions: 81 | - 1.3.7 82 | - dependency-name: "@babel/plugin-proposal-private-methods" 83 | versions: 84 | - 7.12.1 85 | - dependency-name: "@babel/plugin-proposal-class-properties" 86 | versions: 87 | - 7.12.1 88 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '14' 17 | 18 | - name: Get yarn cache directory path 19 | id: yarn-cache-dir-path 20 | run: echo "::set-output name=dir::$(yarn cache dir)" 21 | 22 | - uses: actions/cache@v2 23 | id: yarn-cache 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Install dependencies 31 | run: yarn install 32 | 33 | - name: Build 34 | run: yarn build 35 | 36 | - name: Install SSH key 37 | uses: shimataro/ssh-key-action@v2 38 | with: 39 | key: ${{ secrets.DEPLOY_KEY }} 40 | known_hosts: ${{ secrets.DEPLOY_KNOWN_HOSTS }} 41 | 42 | - name: Deploy 43 | run: npx pm2 deploy production 44 | env: 45 | DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [pull_request, push] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Setup Node.js 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '14' 14 | 15 | - name: Get yarn cache directory path 16 | id: yarn-cache-dir-path 17 | run: echo "::set-output name=dir::$(yarn cache dir)" 18 | 19 | - uses: actions/cache@v2 20 | id: yarn-cache 21 | with: 22 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 23 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn- 26 | 27 | - name: Install dependencies 28 | run: yarn install 29 | 30 | - name: Test 31 | run: yarn test 32 | 33 | - name: Send coverage to Coveralls 34 | uses: coverallsapp/github-action@master 35 | with: 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Send coverage to Code Climate 39 | uses: paambaati/codeclimate-action@v2.6.0 40 | env: 41 | CC_TEST_REPORTER_ID: ${{ secrets.CODE_CLIMATE_TEST_REPORTER_ID }} 42 | with: 43 | coverageCommand: echo '' 44 | coverageLocations: ${{ github.workspace }}/coverage/lcov.info:lcov 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | yarn-error.log 4 | 5 | .now/ 6 | .nyc_output/ 7 | coverage/ 8 | dist/ 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parallel: true, 3 | recursive: true, 4 | require: [ 5 | 'dotenv/config', 6 | '@babel/register', 7 | 'test/setup.js', 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fdgt-apis/api/a74e56eecb000d2602457291b1880567594e5d7e/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Trezy Studios, LLC 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | fdgt 3 |

4 | 5 |

6 | Test Workflow 7 | Coverage 8 | Dependencies 9 |
10 | Maintainability 11 | Code Smells 12 | Tech Debt 13 |
14 | Open Github Issues 15 | 16 | All Contributors 17 | 18 |

19 | 20 |
21 | 22 | `fdgt` is a mock API for the Twitch Messaging Interface. Via `fdgt`, you can connect and test your Twitch bots and other tools with simulated events *without* having to connect to the real API! 23 | 24 | ## Documentation 25 | 26 | You can learn all about how to use `fdgt` over at https://fdgt.dev. 27 | 28 | ## Support 29 | 30 | Want to help support the development and maintenance of `fdgt`? [Sponsor Trezy right here on Github][Sponsor Trezy on Github]! 31 | 32 | ## Community 33 | 34 | * Follow [@TrezyCodes on Twitter][Trezy on Twitter]. 35 | * Found a bug? [Submit an issue][Submit an issue]. 36 | * Want to support the project? [Sponsor Trezy on Github][Sponsor Trezy on Github]. 37 | * Discussion and help with `fdgt`: [Twitch API Discord Server][Twitch API Discord Server]. 38 | * Everything else: [Official TwitchDev Discord Server][Official TwitchDev Discord Server]. 39 | 40 | 41 | 42 | 43 | 44 | [Official TwitchDev Discord Server]: https://link.twitch.tv/devchat "Official TwitchDev Discord Server" 45 | [Sponsor Trezy on Github]: https://github.com/sponsors/trezy "Sponsor Trezy on Github" 46 | [Submit an issue]: https://github.com/fdgt-apis/api/issues/new/choose "Submit an issue" 47 | [Twitch API Discord Server]: https://discord.gg/zUzY78n "Twitch API Discord Server" 48 | [Trezy Studios Discord Server]: https://discord.gg/k3bth3f "Trezy Studios Discord Server" 49 | [Trezy on Twitter]: https://twitter.com/TrezyCodes "Trezy on Twitter" 50 | 51 | ## Contributors ✨ 52 | 53 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |

Trezy

💻 💼 📖 💡 🚇 🚧

Daniel Fischer

📖 👀 💻 ⚠️ 🚧

Trico Everfire

🐛

Sidd

🐛 👀 📖 💡 💻 ⚠️

Philipp Heuer

🐛

Brian Dashore

📖 💡 🤔 💬

pajlada

💻 📖

Chris Gårdenberg

📖
72 | 73 | 74 | 75 | 76 | 77 | 78 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 79 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | const packageData = require('./package.json') 2 | 3 | const repo = packageData.repository.url 4 | 5 | module.exports = { 6 | deploy: { 7 | production: { 8 | host: [process.env.DEPLOY_HOST], 9 | path: '/var/www/fdgt', 10 | 'post-deploy': 'source ~/.zshrc; yarn install; yarn build;', 11 | ref: 'origin/main', 12 | repo, 13 | user: 'deploy', 14 | }, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fdgt/api", 3 | "main": "dist/index.js", 4 | "version": "1.1.0", 5 | "license": "bsd-3-clause", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:fdgt-apis/api.git" 9 | }, 10 | "bin": { 11 | "fdgt": "./dist/index.js" 12 | }, 13 | "scripts": { 14 | "build": "babel src --out-dir dist --copy-files", 15 | "test": "NODE_ENV=test nyc --reporter=lcovonly --reporter=text-summary mocha", 16 | "dev": "NODE_ENV=development DEBUG=true nodemon --exec babel-node src/index.js", 17 | "start": "node dist/index.js" 18 | }, 19 | "dependencies": { 20 | "@koa/cors": "3.1.0", 21 | "dotenv": "10.0.0", 22 | "faker": "5.1.0", 23 | "firebase-admin": "9.2.0", 24 | "fs-extra": "10.0.0", 25 | "ians-logger": "0.1.1", 26 | "irc-message": "3.0.2", 27 | "jsdoc-api": "9.3.4", 28 | "jsdoc-to-markdown": "9.1.1", 29 | "koa": "2.13.3", 30 | "koa-body": "4.2.0", 31 | "koa-compress": "5.1.0", 32 | "koa-no-trailing-slash": "2.1.0", 33 | "koa-router": "9.4.0", 34 | "moment": "2.29.1", 35 | "mri": "1.2.0", 36 | "node-fetch": "2.6.1", 37 | "tinycolor2": "1.4.2", 38 | "uuid": "8.3.1", 39 | "ws": "7.3.1" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "7.16.0", 43 | "@babel/core": "7.15.5", 44 | "@babel/node": "7.16.0", 45 | "@babel/plugin-proposal-class-properties": "7.10.4", 46 | "@babel/plugin-proposal-private-methods": "7.10.4", 47 | "@babel/preset-env": "7.15.6", 48 | "@babel/register": "7.15.3", 49 | "babel-plugin-module-resolver": "4.0.0", 50 | "chai": "4.3.4", 51 | "chai-http": "4.3.0", 52 | "coveralls": "3.1.0", 53 | "frontmatter": "0.0.3", 54 | "mocha": "9.1.2", 55 | "nock": "13.1.3", 56 | "nodemon": "2.0.4", 57 | "nyc": "15.1.0", 58 | "pm2": "4.5.0", 59 | "sinon": "11.1.2", 60 | "test-listen": "1.1.0", 61 | "tmi.js": "1.8.5", 62 | "uuid-validate": "0.0.3" 63 | }, 64 | "packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538" 65 | } 66 | -------------------------------------------------------------------------------- /src/data-mocks/bits.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { CHEERMOTE_PREFIXES } from 'data/CHEERMOTE_PREFIXES' 3 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 4 | import { incrementStat } from 'helpers/updateStat' 5 | 6 | 7 | 8 | 9 | 10 | // Local constants 11 | const { HOST } = process.env 12 | 13 | 14 | 15 | 16 | 17 | export const defaults = { 18 | bitscount: 100, 19 | } 20 | 21 | /** 22 | * `bits` events are fired when a user sends a message to a Twitch channel that contains [`bits`](https://help.twitch.tv/s/article/guide-to-cheering-with-bits). 23 | * 24 | * @alias `bits` 25 | * 26 | * @param {number} bitscount=100 The number of bits to attach to the message. 27 | * @param {string} color - The color of the user's name in chat. 28 | * @param {string} message The body of the message. 29 | * @param {string} messageid - The ID of the message. 30 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 31 | * @param {string} userid - The ID of the user sending the message. 32 | * @param {string} username - The username of the user sending the message. 33 | * 34 | * @example @lang off Fires a `bits` event with no message 35 | * bits 36 | * 37 | * @example @lang off Fires a `bits` event with a custom amount of bits and the message "Woohoo!" 38 | * bits --bitscount 999999 Woohoo! 39 | */ 40 | export const render = (args = {}) => { 41 | const { 42 | bitscount, 43 | color, 44 | channel, 45 | channelid, 46 | message, 47 | messageid, 48 | timestamp, 49 | userid, 50 | username, 51 | } = { 52 | ...defaults, 53 | ...args, 54 | } 55 | 56 | const emotes = [] 57 | 58 | let processedMessage = message 59 | let totalBits = 0 60 | 61 | if (message) { 62 | const cheermotes = [...message.matchAll(new RegExp(`(?:${CHEERMOTE_PREFIXES.join('|')})(\\d+)`, 'giu'))] 63 | 64 | totalBits = cheermotes.reduce((accumulator, cheermote) => { 65 | return accumulator + parseInt(cheermote[1], 10) 66 | }, 0) 67 | } 68 | 69 | if (totalBits === 0) { 70 | totalBits = bitscount 71 | 72 | if (processedMessage) { 73 | processedMessage += ' ' 74 | } 75 | 76 | processedMessage += `cheer${totalBits}` 77 | } 78 | 79 | incrementStat('events/bits') 80 | incrementStat('totalBits', totalBits) 81 | incrementStat('dollarbucksSaved', totalBits * DOLLARBUCK_CORRELATIONS['bit']) 82 | 83 | return { 84 | 'badge-info': [], 85 | badges: [], 86 | bits: totalBits, 87 | color, 88 | 'display-name': username, 89 | emotes: null, 90 | flags: null, 91 | id: messageid, 92 | mod: 0, 93 | 'room-id': channelid, 94 | subscriber: 0, 95 | 'tmi-sent-ts': timestamp, 96 | turbo: 0, 97 | 'user-id': userid, 98 | 'user-type': null, 99 | message: `${username}!${username}@${username}.${HOST} PRIVMSG #${channel} :${processedMessage}`, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/data-mocks/emoteonly.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { incrementStat } from 'helpers/updateStat' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { HOST } = process.env 10 | 11 | 12 | 13 | 14 | 15 | export const defaults = { 16 | off: false, 17 | } 18 | 19 | /** 20 | * `emoteonly` events are fired when a Twitch channel is switched into `emote-only mode`. 21 | * 22 | * **NOTE:** This will not prevent non-emote only messages from being sent on `fdgt`. It only simulates the event of changing a channel's emote-only status. 23 | * 24 | * **NOTE:** This event **does not support all global parameters**. The table below is an exhaustive list of the supported parameters for this event. 25 | * 26 | * @alias `emoteonly` 27 | * 28 | * @param {boolean} off=false - Whether emote-only mode is being enabled or disabled. 29 | * 30 | * @example @lang off Fires an `emoteonly` event, enabling emote-only mode on the channel. 31 | * emoteonly 32 | * 33 | * @example @lang off Fires an `emoteonly` event, disabling emote-only mode on the channel. 34 | * emoteonly --off 35 | */ 36 | export const render = (args = {}) => { 37 | const { 38 | channel: channelName, 39 | connection, 40 | off, 41 | } = { 42 | ...defaults, 43 | ...args, 44 | } 45 | 46 | const channel = connection.channels.findByName(channelName) 47 | 48 | channel.emoteOnly = !off 49 | 50 | incrementStat('events/emoteonly') 51 | 52 | return { 53 | message: `${HOST} NOTICE #${channelName} :This room is ${off ? 'no longer' : 'now'} in emote-only mode.`, 54 | 'msg-id': `emote_only_${off ? 'off' : 'on'}`, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/data-mocks/extendsub.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import moment from 'moment' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 10 | import { incrementStat } from 'helpers/updateStat' 11 | 12 | 13 | 14 | 15 | 16 | // Local constants 17 | const { HOST } = process.env 18 | 19 | 20 | 21 | 22 | 23 | export const defaults = { 24 | months: 3, 25 | tier: 1, 26 | } 27 | 28 | /** 29 | * `extendsub` events are fired when a user extends their existing non-gifted subscription to a Twitch channel. 30 | * 31 | * @alias `extendsub` 32 | * 33 | * @param {string} color - The color of the user's name in chat. 34 | * @param {string} messageid - The ID of the message. 35 | * @param {number} months=3 - The number of months the subscription is being extended. 36 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 37 | * @param {string} userid - The ID of the user sending the message. 38 | * @param {string} username - The username of the user sending the message. 39 | * @param {number} tier=1 - The tier of the subscription being extended. 40 | * 41 | * @example @lang off Fires an `extendsub` event 42 | * 'extendsub' 43 | * 44 | * @example @lang off Fires an `extendsub` event to extend the user's subscription by 6 months 45 | * extendsub --months 6 46 | * 47 | * @example @lang off Fires an `extendsub` event for a Tier 3 subscription 48 | * extendsub --tier 3 49 | */ 50 | export const render = (args = {}) => { 51 | const { 52 | channel, 53 | channelid, 54 | color, 55 | messageid, 56 | months, 57 | tier, 58 | timestamp, 59 | userid, 60 | username, 61 | } = { 62 | ...defaults, 63 | ...args, 64 | } 65 | const timeAsMoment = moment(timestamp) 66 | 67 | const endmonth = timeAsMoment.add(months, 'months').month() 68 | const endmonthname = timeAsMoment.format('MMMM') 69 | 70 | incrementStat('events/extendsub') 71 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 72 | 73 | return { 74 | 'badge-info': [`subscriber/${months}`], 75 | badges: [`subscriber/${months}`], 76 | color: color, 77 | 'display-name': username, 78 | emotes: null, 79 | flags: null, 80 | id: messageid, 81 | login: username, 82 | mod: 0, 83 | 'msg-id': 'extendsub', 84 | 'msg-param-cumulative-months': months, 85 | 'msg-param-sub-benefit-end-month': endmonth, 86 | 'msg-param-sub-plan-name': `Tier ${tier}`, 87 | 'msg-param-sub-plan': 1000 * tier, 88 | 'room-id': channelid, 89 | subscriber: 1, 90 | 'system-msg': `${username} extended their Tier ${tier} subscription through ${endmonthname}!`, 91 | 'tmi-sent-ts': timestamp, 92 | 'user-id': userid, 93 | 'user-type': null, 94 | message: `${HOST} USERNOTICE #${channel}`, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/data-mocks/giftpaidupgrade.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import faker from 'faker' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 10 | import { incrementStat } from 'helpers/updateStat' 11 | 12 | 13 | 14 | 15 | 16 | // Local constants 17 | const { HOST } = process.env 18 | 19 | 20 | 21 | 22 | 23 | export const defaults = { 24 | months: 3, 25 | } 26 | 27 | /** 28 | * `giftpaidupgrade` events are fired when a user upgrades their subscription from one that was previously gifted to them. 29 | * 30 | * @alias `giftpaidupgrade` 31 | * 32 | * @param {string} color - The color of the user's name in chat. 33 | * @param {string} messageid - The ID of the message. 34 | * @param {number} months=3 - The number of months the subscription has been active. 35 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 36 | * @param {string} userid - The ID of the user sending the message. 37 | * @param {string} username - The username of the user sending the message. 38 | * @param {string} username2 - The username of the user that originally gifted the sub. 39 | * 40 | * @example @lang off Fires an `giftpaidupgrade` event 41 | * giftpaidupgrade 42 | * 43 | * @example @lang off Fires an `giftpaidupgrade` event for a user that's been gifted subs for the past 12 months 44 | * giftpaidupgrade --months 3 45 | */ 46 | export const render = (args = {}) => { 47 | const { 48 | channel, 49 | channelid, 50 | color, 51 | messageid, 52 | months, 53 | timestamp, 54 | userid, 55 | username, 56 | username2 = faker.internet.userName(), 57 | } = { 58 | ...defaults, 59 | ...args, 60 | } 61 | 62 | incrementStat('events/giftpaidupgrade') 63 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 64 | 65 | return { 66 | 'badge-info': [`subscriber/${months}`], 67 | badges: [`subscriber/${months}`], 68 | color: color, 69 | 'display-name': username, 70 | emotes: null, 71 | flags: null, 72 | id: messageid, 73 | login: username, 74 | mod: 0, 75 | 'msg-id': 'giftpaidupgrade', 76 | 'msg-param-sender-login': username2, 77 | 'msg-param-sender-name': username2, 78 | 'room-id': channelid, 79 | subscriber: 1, 80 | 'system-msg': `${username} is continuing the Gift Sub they got from ${username2}!`, 81 | 'tmi-sent-ts': timestamp, 82 | 'user-id': userid, 83 | 'user-type': null, 84 | message: `${HOST} USERNOTICE #${channel}`, 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/data-mocks/primepaidupgrade.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 3 | import { incrementStat } from 'helpers/updateStat' 4 | 5 | 6 | 7 | 8 | 9 | // Local constants 10 | const { HOST } = process.env 11 | 12 | 13 | 14 | 15 | 16 | export const defaults = { 17 | tier: 1, 18 | } 19 | 20 | /** 21 | * `primepaidupgrade` events are fired when a user upgrades from a Prime subscription to a paid subscription. 22 | * 23 | * @alias `primepaidupgrade` 24 | * 25 | * @param {string} color - The color of the user's name in chat. 26 | * @param {string} messageid - The ID of the message. 27 | * @param {number} tier=1 - The tier of the subscription being upgraded to. 28 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 29 | * @param {string} userid - The ID of the user sending the message. 30 | * @param {string} username - The username of the user sending the message. 31 | * 32 | * @example @lang off Fires a `primepaidupgrade` event 33 | * primepaidupgrade 34 | * 35 | * @example @lang off Fires a `primepaidupgrade` event with the user upgrading to Tier 3 36 | * primepaidupgrade --tier 3 37 | */ 38 | export const render = (args = {}) => { 39 | const { 40 | channel, 41 | channelid, 42 | color, 43 | messageid, 44 | tier, 45 | timestamp, 46 | userid, 47 | username, 48 | } = { 49 | ...defaults, 50 | ...args, 51 | } 52 | 53 | incrementStat('events/primepaidupgrade') 54 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 55 | 56 | return { 57 | 'badge-info': ['subscriber/0'], 58 | badges: ['subscriber/0'], 59 | 'display-name': username, 60 | color: color, 61 | emotes: null, 62 | flags: null, 63 | id: messageid, 64 | login: username, 65 | mod: 0, 66 | 'msg-id': 'primepaidupgrade', 67 | 'msg-param-sub-plan': 1000 * tier, 68 | 'msg-param-sub-plan-name': `Tier ${tier}`, 69 | 'room-id': channelid, 70 | subscriber: 1, 71 | 'tmi-sent-ts': timestamp, 72 | 'user-id': userid, 73 | 'user-type': null, 74 | 'system-msg': `${username} converted from a Twitch Prime sub to a Tier ${tier} sub!`, 75 | message: `${HOST} USERNOTICE #${channel}`, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/data-mocks/raid.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import faker from 'faker' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { incrementStat } from 'helpers/updateStat' 10 | 11 | 12 | 13 | 14 | 15 | // Local constants 16 | const { HOST } = process.env 17 | 18 | 19 | 20 | 21 | 22 | export const defaults = { 23 | viewercount: 10, 24 | } 25 | 26 | /** 27 | * `raid` events are fired when a channel is raided by another stream. 28 | * 29 | * @alias `raid` 30 | * 31 | * @param {string} color - The color of the user's name in chat. 32 | * @param {string} messageid - The ID of the message. 33 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 34 | * @param {string} userid - The ID of the user sending the message. 35 | * @param {string} username The channel the raid is coming from. 36 | * @param {number} viewercount=10 The number of viewers that joined the raid. 37 | * 38 | * @example @lang off Fires a `raid` event 39 | * raid 40 | * 41 | * @example @lang off Simulates a `raid` from Dr. Disrespect with 10,000 viewers 42 | * raid --username drdisrespectlive --viewercount 10000 43 | */ 44 | export const render = (args = {}) => { 45 | const { 46 | channel, 47 | channelid, 48 | color, 49 | messageid, 50 | timestamp, 51 | userid, 52 | username, 53 | viewercount, 54 | } = { 55 | ...defaults, 56 | ...args, 57 | } 58 | 59 | incrementStat('events/raid') 60 | 61 | return { 62 | 'badge-info': [], 63 | badges: [], 64 | color, 65 | 'display-name': username, 66 | emotes: null, 67 | flags: null, 68 | id: messageid, 69 | login: username, 70 | mod: 0, 71 | 'msg-id': 'raid', 72 | 'msg-param-displayName': username, 73 | 'msg-param-login': username, 74 | 'msg-param-profileImageURL': `https://api.adorable.io/avatars/256/${username}.png`, 75 | 'msg-param-viewerCount': viewercount, 76 | 'room-id': channelid, 77 | subscriber: 0, 78 | 'system-msg': `${viewercount} raiders from ${username} have joined!`, 79 | 'tmi-sent-ts': timestamp, 80 | 'user-id': userid, 81 | 'user-type': null, 82 | message: `${HOST} USERNOTICE #${channel}`, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/data-mocks/resubscription.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 3 | import { incrementStat } from 'helpers/updateStat' 4 | 5 | 6 | 7 | 8 | 9 | // Local constants 10 | const { HOST } = process.env 11 | 12 | 13 | 14 | 15 | 16 | export const defaults = { 17 | months: 3, 18 | prime: false, 19 | tier: 1, 20 | } 21 | 22 | /** 23 | * `resubscription` events are fired when a when a user continues their existing, non-gifted subscription. 24 | * 25 | * @alias `resubscription` 26 | * 27 | * @param {string} color - The color of the user's name in chat. 28 | * @param {string} messageid - The ID of the message. 29 | * @param {number} months=3 The number of months the user has been subscribed to the channel. 30 | * @param {boolean} prime=false Whether this is a Prime subscription. 31 | * @param {number} tier=1 - The tier of the subscription being extended. 32 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 33 | * @param {string} userid - The ID of the user sending the message. 34 | * @param {string} username - The username of the user sending the message. 35 | * 36 | * @example @lang off Fires a `resubscription` event 37 | * resubscription 38 | * 39 | * @example @lang off Simulates a Prime `resubscription` event 40 | * resubscription --prime 41 | * 42 | * @example @lang off Simulates a Tier 3 `resubscription` event 43 | * resubscription --tier 3 44 | */ 45 | export const render = (args = {}) => { 46 | const { 47 | channel, 48 | channelid, 49 | color, 50 | messageid, 51 | months, 52 | prime, 53 | tier, 54 | timestamp, 55 | userid, 56 | username, 57 | } = { 58 | ...defaults, 59 | ...args, 60 | } 61 | 62 | const plan = prime ? 'Prime' : (1000 * tier) 63 | const planName = prime ? 'Prime' : `Tier ${tier}` 64 | 65 | incrementStat('events/resub') 66 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 67 | 68 | return { 69 | 'badge-info': [ 70 | `subscriber/${months}`, 71 | 'premium/1' 72 | ], 73 | badges: [ 74 | `subscriber/${months}`, 75 | 'premium/1' 76 | ], 77 | color: color, 78 | 'display-name': username, 79 | emotes: null, 80 | flags: null, 81 | id: messageid, 82 | login: username, 83 | mod: 0, 84 | 'msg-id': 'resub', 85 | 'msg-param-cumulative-months': months, 86 | 'msg-param-months': 0, 87 | 'msg-param-should-share-streak': 0, 88 | 'msg-param-sub-plan-name': planName, 89 | 'msg-param-sub-plan': plan, 90 | 'room-id': channelid, 91 | subscriber: 1, 92 | 'system-msg': `${username} subscribed at ${planName}. They've subscribed for ${months} months!`, 93 | 'tmi-sent-ts': timestamp, 94 | 'user-id': userid, 95 | 'user-type': null, 96 | message: `${HOST} USERNOTICE #${channel}`, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/data-mocks/slowmode.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { incrementStat } from 'helpers/updateStat' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { HOST } = process.env 10 | 11 | 12 | 13 | 14 | 15 | export const defaults = { 16 | off: false, 17 | } 18 | 19 | /** 20 | * `slowmode` events are fired when a Twitch channel is switched into `slow mode`. 21 | * 22 | * **NOTE:** This will not actually set the channel to slow mode on `fdgt`. It only simulates the event of changing a channel's slow mode status. 23 | * 24 | * **NOTE:** This event **does not support all global parameters**. The table below is an exhaustive list of the supported parameters for this event. 25 | * 26 | * @alias `slowmode` 27 | * 28 | * @param {boolean} off=false - Whether emote-only mode is being enabled or disabled. 29 | * 30 | * @example @lang off Fires an `slowmode` event, enabling slow mode on the channel. 31 | * slowmode 32 | * 33 | * @example @lang off Fires an `slowmode` event, disabling slow mode on the channel. 34 | * slowmode --off 35 | */ 36 | export const render = (args = {}) => { 37 | const { 38 | channel: channelName, 39 | connection, 40 | off, 41 | } = { 42 | ...defaults, 43 | ...args, 44 | } 45 | 46 | const channel = connection.channels.findByName(channelName) 47 | 48 | channel.slowMode = !off 49 | 50 | incrementStat('events/slowmode') 51 | 52 | return { 53 | message: `${HOST} NOTICE #${channelName} :This room is ${off ? 'no longer' : 'now'} in slow mode.${off ? '' : ' You can send messages every 30 seconds.'}`, 54 | 'msg-id': `slow_${off ? 'off' : 'on'}`, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/data-mocks/subgift.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import faker from 'faker' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 10 | import { incrementStat } from 'helpers/updateStat' 11 | 12 | 13 | 14 | 15 | 16 | // Local constants 17 | const { HOST } = process.env 18 | 19 | 20 | 21 | 22 | 23 | export const defaults = { 24 | count: 1, 25 | months: 1, 26 | tenure: 1, 27 | tier: 1, 28 | } 29 | 30 | /** 31 | * `subgift` events are fired when a user gifts a subscription to another user in the channel. 32 | * 33 | * @alias `subgift` 34 | * 35 | * @param {string} color - The color of the user's name in chat. 36 | * @param {string} count=1 - The total number of gifts the user has given in the channel. 37 | * @param {string} messageid - The ID of the message. 38 | * @param {number} months=1 - The length of the gift sub (for multi-month subs only). 39 | * @param {number} tenure=1 - The total number of months the recipient has been subscribed. 40 | * @param {number} tier=1 - The tier of the subscription being extended. 41 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 42 | * @param {string} userid2 - The ID of the user that is receiving the sub. 43 | * @param {string} username2 - The username of the user that is receiving the sub. 44 | * @param {string} userid - The ID of the user sending the message. 45 | * @param {string} username - The username of the user sending the message. 46 | * 47 | * @example @lang off Fires a `subgift` event 48 | * subgift 49 | * 50 | * @example @lang off Simulates a Tier 3 `subgift` event from glEnd2 51 | * subgift --tier 3 --username glEnd2 52 | */ 53 | export const render = (args = {}) => { 54 | const { 55 | channel, 56 | channelid, 57 | color, 58 | count, 59 | messageid, 60 | months, 61 | tenure, 62 | tier, 63 | timestamp, 64 | userid, 65 | userid2, 66 | username, 67 | username2 = faker.internet.userName(), 68 | } = { 69 | ...defaults, 70 | ...args, 71 | } 72 | 73 | const response = { 74 | 'badge-info': ['subscriber/0'], 75 | badges: [ 76 | 'subscriber/0', 77 | 'sub-gifter/1', 78 | ], 79 | color, 80 | 'display-name': username, 81 | emotes: null, 82 | flags: null, 83 | id: messageid, 84 | login: username, 85 | mod: 0, 86 | 'msg-id': 'subgift', 87 | 'msg-param-months': Math.max(tenure, 1), 88 | 'msg-param-recipient-display-name': username2, 89 | 'msg-param-recipient-id': userid2, 90 | 'msg-param-recipient-user-name': username2, 91 | 'msg-param-sender-count': count, 92 | 'msg-param-sub-plan-name': `Tier ${tier}`, 93 | 'msg-param-sub-plan': 1000 * tier, 94 | 'room-id': channelid, 95 | 'system-msg': `${username} gifted a Tier ${tier} sub to ${username2}! They have given ${count} Gift Sub${count > 1 ? 's' : ''} in the channel!`, 96 | 'tmi-sent-ts': timestamp, 97 | 'user-id': userid, 98 | 'user-type': null, 99 | message: `${HOST} USERNOTICE #${channel}`, 100 | } 101 | 102 | if (months > 1) { 103 | response['msg-params-gift-months'] = months 104 | } 105 | 106 | incrementStat('subs') 107 | incrementStat('events/subgift') 108 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 109 | 110 | return response 111 | } 112 | -------------------------------------------------------------------------------- /src/data-mocks/submysterygift.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 3 | import { incrementStat } from 'helpers/updateStat' 4 | 5 | 6 | 7 | 8 | 9 | // Local constants 10 | const { HOST } = process.env 11 | 12 | 13 | 14 | 15 | 16 | export const defaults = { 17 | count: 5, 18 | tier: 1, 19 | } 20 | 21 | /** 22 | * `submysterygift` events are fired when a user gives mystery subscription gifts. 23 | * 24 | * @alias `submysterygift` 25 | * 26 | * @param {string} color - The color of the user's name in chat. 27 | * @param {number} count=5 - The number of gifts the user is currently giving in the channel. 28 | * @param {string} messageid - The ID of the message. 29 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 30 | * @param {number} totalcount=5 The total number of gifts the user has given in the channel. 31 | * @param {string} userid - The ID of the user sending the message. 32 | * @param {string} username - The username of the user sending the message. 33 | * 34 | * @example @lang off Fires a `submysterygift` event 35 | * submysterygift 36 | * 37 | * @example @lang off Simulates zebiniasis giving 20 mystery sub gifts 38 | * submysterygift --count 20 --username zebiniasis 39 | */ 40 | export const render = (args = {}) => { 41 | const { 42 | channel, 43 | channelid, 44 | color, 45 | count, 46 | messageid, 47 | tier, 48 | timestamp, 49 | totalcount, 50 | userid, 51 | username, 52 | } = { 53 | ...defaults, 54 | ...args, 55 | } 56 | 57 | incrementStat('events/submysterygift') 58 | incrementStat('subs', count) 59 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier] * count) 60 | 61 | return { 62 | 'badge-info': ['subscriber/0'], 63 | badges: [ 64 | 'subscriber/0', 65 | 'sub-gifter/1' 66 | ], 67 | color, 68 | 'display-name': username, 69 | emotes: null, 70 | flags: null, 71 | id: messageid, 72 | login: username, 73 | mod: 0, 74 | 'msg-id': 'submysterygift', 75 | 'msg-param-mass-gift-count': count, 76 | 'msg-param-sender-count': totalcount || count, 77 | 'msg-param-sub-plan-name': `Tier ${tier}`, 78 | 'msg-param-sub-plan': 1000 * tier, 79 | 'room-id': channelid, 80 | subscriber: 1, 81 | 'system-msg': `${username} is gifting ${count} Tier ${tier} Subs to the community! They've gifted a total of ${totalcount || count} in the channel!`, 82 | 'tmi-sent-ts': timestamp, 83 | 'user-id': userid, 84 | 'user-type': null, 85 | message: `${HOST} USERNOTICE #${channel}`, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/data-mocks/subscription.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { DOLLARBUCK_CORRELATIONS } from 'data/DOLLARBUCK_CORRELATIONS' 3 | import { incrementStat } from 'helpers/updateStat' 4 | 5 | 6 | 7 | 8 | 9 | // Local constants 10 | const { HOST } = process.env 11 | 12 | 13 | 14 | 15 | 16 | export const defaults = { 17 | prime: false, 18 | tier: 1, 19 | } 20 | 21 | /** 22 | * `subscription` events are fired when a user subscribes to a channel for the first time. 23 | * 24 | * @alias `subscription` 25 | * 26 | * @param {string} color - The color of the user's name in chat. 27 | * @param {string} messageid - The ID of the message. 28 | * @param {boolean} prime=false Whether this is a Prime subscription. 29 | * @param {number} tier=1 - The tier of the subscription being extended. 30 | * @param {string} timestamp - The millisecond timestamp when the message was sent. 31 | * @param {string} userid - The ID of the user sending the message. 32 | * @param {string} username - The username of the user sending the message. 33 | * 34 | * @example @lang off Fires a `subscription` event 35 | * subscription 36 | * 37 | * @example @lang off Simulates a Prime `subscription` event 38 | * subscription --prime 39 | * 40 | * @example @lang off Simulates a Tier 3 `subscription` event 41 | * subscription --tier 3 42 | */ 43 | export const render = (args = {}) => { 44 | const { 45 | channel, 46 | channelid, 47 | color, 48 | messageid, 49 | prime, 50 | tier, 51 | timestamp, 52 | userid, 53 | username, 54 | } = { 55 | ...defaults, 56 | ...args, 57 | } 58 | 59 | const plan = prime ? 'Prime' : (1000 * tier) 60 | const planName = prime ? 'Prime' : `Tier ${tier}` 61 | 62 | incrementStat('subs') 63 | incrementStat('events/sub') 64 | incrementStat('dollarbucksSaved', DOLLARBUCK_CORRELATIONS['subscription'][tier]) 65 | 66 | return { 67 | 'badge-info': [ 68 | 'subscriber/0', 69 | 'premium/1' 70 | ], 71 | badges: [ 72 | 'subscriber/0', 73 | 'premium/1' 74 | ], 75 | color, 76 | 'display-name': username, 77 | emotes: null, 78 | flags: null, 79 | id: messageid, 80 | login: username, 81 | mod: 0, 82 | 'msg-id': 'sub', 83 | 'msg-param-cumulative-months': 1, 84 | 'msg-param-months': 0, 85 | 'msg-param-should-share-streak': 0, 86 | 'msg-param-sub-plan-name': planName, 87 | 'msg-param-sub-plan': plan, 88 | 'room-id': channelid, 89 | subscriber: 1, 90 | 'system-msg': `${username} subscribed at ${planName}.`, 91 | 'tmi-sent-ts': timestamp, 92 | 'user-id': userid, 93 | 'user-type': null, 94 | message: `${HOST} USERNOTICE #${channel}`, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/data-mocks/subsonly.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { incrementStat } from 'helpers/updateStat' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { HOST } = process.env 10 | 11 | 12 | 13 | 14 | 15 | export const defaults = { 16 | off: false, 17 | } 18 | 19 | /** 20 | * `subsonly` events are fired when a Twitch channel is switched into `subs-only mode`. 21 | * 22 | * **NOTE:** This will not actually set the channel to subs-only mode on `fdgt`. It only simulates the event of changing a channel's subs-only mode status. 23 | * 24 | * **NOTE:** This event **does not support all global parameters**. The table below is an exhaustive list of the supported parameters for this event. 25 | * 26 | * @alias `subsonly` 27 | * 28 | * @param {boolean} off=false - Whether emote-only mode is being enabled or disabled. 29 | * 30 | * @example @lang off Fires an `subsonly` event, enabling slow mode on the channel. 31 | * subsonly 32 | * 33 | * @example @lang off Fires an `subsonly` event, disabling slow mode on the channel. 34 | * subsonly --off 35 | */ 36 | export const render = (args = {}) => { 37 | const { 38 | channel: channelName, 39 | connection, 40 | off, 41 | } = { 42 | ...defaults, 43 | ...args, 44 | } 45 | 46 | const channel = connection.channels.findByName(channelName) 47 | 48 | channel.subsOnly = !off 49 | 50 | incrementStat('events/subsonly') 51 | 52 | return { 53 | message: `${HOST} NOTICE #${channelName} :This room is ${off ? 'no longer' : 'now'} in subscribers-only mode.`, 54 | 'msg-id': `subs_${off ? 'off' : 'on'}`, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/data/CAPABILITIES.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'twitch.tv/commands', 3 | 'twitch.tv/membership', 4 | 'twitch.tv/tags', 5 | ] 6 | -------------------------------------------------------------------------------- /src/data/CHEERMOTE_PREFIXES.js: -------------------------------------------------------------------------------- 1 | export const CHEERMOTE_PREFIXES = [ 2 | 'cheer', 3 | 'pogchamp', 4 | 'cheerwhal', 5 | 'corgo', 6 | 'uni', 7 | 'showlove', 8 | 'party', 9 | 'seemsgood', 10 | 'pride', 11 | 'kappa', 12 | 'frankerz', 13 | 'heyguys', 14 | 'dansgame', 15 | 'elegiggle', 16 | 'trihard', 17 | 'kreygasm', 18 | '4head', 19 | 'swiftrage', 20 | 'notlikethis', 21 | 'failfish', 22 | 'vohiyo', 23 | 'pjsalt', 24 | 'mrdestructoid', 25 | 'bday', 26 | 'ripcheer', 27 | 'shamrock', 28 | 'anon', 29 | ] 30 | -------------------------------------------------------------------------------- /src/data/DOLLARBUCK_CORRELATIONS.js: -------------------------------------------------------------------------------- 1 | export const DOLLARBUCK_CORRELATIONS = { 2 | bit: 1, 3 | subscription: { 4 | 1: 499, 5 | 2: 999, 6 | 3: 2499, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/apps.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { firestore } from 'helpers/firebase' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { NODE_ENV } = process.env 10 | 11 | 12 | 13 | 14 | 15 | async function fixUnlabeledLogs (appID, connectionID) { 16 | const logsCollection = firestore.collection('logs') 17 | const now = new Date 18 | const unlabeledLogIDs = [] 19 | 20 | const logsSnapshot = await logsCollection 21 | .where('connectionID', '==', connectionID) 22 | .where('createdAt', '<', now) 23 | .get() 24 | 25 | logsSnapshot.forEach(doc => unlabeledLogIDs.push(doc.id)) 26 | 27 | try { 28 | await Promise.all(unlabeledLogIDs.map(logID => { 29 | return logsCollection.doc(logID).update({ appID }) 30 | })) 31 | } catch (error) { 32 | console.log(error) 33 | } 34 | } 35 | 36 | export async function getApp (appID, connectionID) { 37 | if ((NODE_ENV !== 'test') && appID) { 38 | try { 39 | const app = await firestore.collection('apps').doc(appID).get() 40 | 41 | if (app) { 42 | return { 43 | id: app.id, 44 | ...app.data(), 45 | } 46 | 47 | fixUnlabeledLogs(appID, connectionID) 48 | } 49 | 50 | return null 51 | } catch (error) { 52 | console.log(error) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/helpers/bodyBuilder.js: -------------------------------------------------------------------------------- 1 | export default () => async (context, next) => { 2 | const meta = { 3 | start_ms: Date.now() 4 | } 5 | let body = {} 6 | 7 | context.errors = [] 8 | 9 | await next() 10 | 11 | if (context.errors.length) { 12 | body.errors = context.errors.map(error => { 13 | if (error instanceof Error) { 14 | return error.message 15 | } 16 | 17 | return error 18 | }) 19 | } else if (context.data) { 20 | body = { 21 | ...body, 22 | data: context.data, 23 | jsonapi: { 24 | version: '1.0', 25 | }, 26 | meta: context.data.meta || {}, 27 | } 28 | 29 | if (context.included) { 30 | body.included = context.included 31 | } 32 | 33 | if (Array.isArray(body.data)) { 34 | body.meta.count = body.data.length 35 | } 36 | } 37 | 38 | meta.end_ms = Date.now() 39 | meta.response_ms = (meta.end_ms - meta.start_ms) 40 | 41 | body.meta = { 42 | ...meta, 43 | ...body.meta, 44 | } 45 | 46 | context.body = body 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/dedupeArray.js: -------------------------------------------------------------------------------- 1 | export default array => Array.from(new Set(array)) 2 | -------------------------------------------------------------------------------- /src/helpers/firebase.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | // Module imports 4 | import * as firebaseAdmin from 'firebase-admin' 5 | 6 | 7 | 8 | 9 | 10 | // Local constants 11 | const { 12 | FIREBASE_AUTH_PROVIDER_X509_CERT_URL, 13 | FIREBASE_AUTH_URI, 14 | FIREBASE_CLIENT_EMAIL, 15 | FIREBASE_CLIENT_ID, 16 | FIREBASE_CLIENT_X509_CERT_URL, 17 | FIREBASE_PRIVATE_KEY_ID, 18 | FIREBASE_PRIVATE_KEY, 19 | FIREBASE_PROJECT_ID, 20 | FIREBASE_TOKEN_URI, 21 | FIREBASE_TYPE, 22 | FIREBASE_DATABASE_URL, 23 | NODE_ENV, 24 | } = process.env 25 | 26 | 27 | 28 | 29 | 30 | // Local variables 31 | let app = null 32 | 33 | 34 | 35 | 36 | 37 | if (NODE_ENV !== 'test') { 38 | app = firebaseAdmin.apps[0] || firebaseAdmin.initializeApp({ 39 | credential: firebaseAdmin.credential.cert({ 40 | auth_provider_x509_cert_url: FIREBASE_AUTH_PROVIDER_X509_CERT_URL, 41 | auth_uri: FIREBASE_AUTH_URI, 42 | client_email: FIREBASE_CLIENT_EMAIL, 43 | client_id: FIREBASE_CLIENT_ID, 44 | client_x509_cert_url: FIREBASE_CLIENT_X509_CERT_URL, 45 | private_key_id: FIREBASE_PRIVATE_KEY_ID, 46 | private_key: FIREBASE_PRIVATE_KEY, 47 | project_id: FIREBASE_PROJECT_ID, 48 | token_uri: FIREBASE_TOKEN_URI, 49 | type: FIREBASE_TYPE, 50 | }), 51 | databaseURL: FIREBASE_DATABASE_URL, 52 | }) 53 | } 54 | 55 | 56 | 57 | 58 | 59 | export const firebase = app 60 | export const firestore = app?.firestore() 61 | export const database = app?.database() 62 | export { firebaseAdmin } 63 | -------------------------------------------------------------------------------- /src/helpers/getMock.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import path from 'path' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { render as bitsMock } from 'data-mocks/bits' 10 | import { render as emoteonlyMock } from 'data-mocks/emoteonly' 11 | import { render as extendsubMock } from 'data-mocks/extendsub' 12 | import { render as giftpaidupgradeMock } from 'data-mocks/giftpaidupgrade' 13 | import { render as primepaidupgradeMock } from 'data-mocks/primepaidupgrade' 14 | import { render as raidMock } from 'data-mocks/raid' 15 | import { render as resubscriptionMock } from 'data-mocks/resubscription' 16 | import { render as slowmodeMock } from 'data-mocks/slowmode' 17 | import { render as subgiftMock } from 'data-mocks/subgift' 18 | import { render as submysterygiftMock } from 'data-mocks/submysterygift' 19 | import { render as subscriptionMock } from 'data-mocks/subscription' 20 | import { render as subsonlyMock } from 'data-mocks/subsonly' 21 | 22 | 23 | 24 | 25 | 26 | // Local constants 27 | const mocks = { 28 | bits: bitsMock, 29 | emoteonly: emoteonlyMock, 30 | extendsub: extendsubMock, 31 | giftpaidupgrade: giftpaidupgradeMock, 32 | primepaidupgrade: primepaidupgradeMock, 33 | raid: raidMock, 34 | resubscription: resubscriptionMock, 35 | slowmode: slowmodeMock, 36 | subgift: subgiftMock, 37 | submysterygift: submysterygiftMock, 38 | subscription: subscriptionMock, 39 | subsonly: subsonlyMock, 40 | } 41 | 42 | 43 | 44 | 45 | 46 | export default options => { 47 | const { command } = options 48 | const mock = mocks[command] 49 | 50 | if (mock) { 51 | return mock 52 | } 53 | 54 | return null 55 | } 56 | -------------------------------------------------------------------------------- /src/helpers/handleCAPMessage.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import CAPABILITIES from 'data/CAPABILITIES' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { HOST } = process.env 10 | 11 | 12 | 13 | 14 | 15 | export default (message, connection) => { 16 | const { 17 | addCapabilities, 18 | send, 19 | sendMOTD, 20 | sendUnknownCommand, 21 | } = connection 22 | const [subcommand, arg] = message.params 23 | 24 | switch (subcommand.toUpperCase()) { 25 | case 'END': 26 | connection.capabilitiesFinished = true 27 | connection.emit('acknowledge') 28 | break 29 | 30 | case 'LIST': 31 | case 'LS': 32 | send(`:${HOST} CAP * ${subcommand.toUpperCase()} :${CAPABILITIES.join(' ')}`) 33 | break 34 | 35 | case 'REQ': 36 | const capabilities = arg?.split(' ') ?? [] 37 | addCapabilities(capabilities) 38 | send(`:${HOST} CAP * ACK :${capabilities.filter(capability => CAPABILITIES.includes(capability)).join(' ')}`) 39 | connection.capabilitiesFinished = true 40 | connection.emit('acknowledge') 41 | break 42 | 43 | default: 44 | sendUnknownCommand(`CAP ${subcommand.toUpperCase()}`) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/helpers/handleJOINMessage.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { incrementStat } from 'helpers/updateStat' 3 | import Channel from 'structures/Channel' 4 | import User from 'structures/User' 5 | 6 | 7 | 8 | 9 | 10 | // Local constants 11 | const { HOST } = process.env 12 | 13 | 14 | 15 | 16 | 17 | export default (message, connection) => { 18 | const { 19 | channels, 20 | getChannel, 21 | getUser, 22 | send, 23 | username, 24 | users, 25 | } = connection 26 | 27 | const channelsToJoin = message.params 28 | 29 | channelsToJoin.forEach(channelName => { 30 | const channel = getChannel(channelName) 31 | const user = getUser(username) 32 | incrementStat('channelsJoined') 33 | 34 | if (!channel.isConnected) { 35 | channel.connect({ user }) 36 | } 37 | 38 | send([ 39 | `:${username}!${username}@${username}.${HOST} JOIN #${channel.name}`, 40 | `:${username}.${HOST} 353 ${username} = #${channel.name} :${username}`, 41 | `:${username}.${HOST} 366 ${username} #${channel.name} :End of /NAMES list`, 42 | ]) 43 | 44 | user.sendUSERSTATE(channel.name) 45 | channel.sendROOMSTATE() 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/handleNICKMessage.js: -------------------------------------------------------------------------------- 1 | export default (message, connection) => { 2 | const [username] = message.params 3 | connection.username = username 4 | connection.emit('acknowledge') 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/handlePARTMessage.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import Channel from 'structures/Channel' 3 | import User from 'structures/User' 4 | 5 | 6 | 7 | 8 | 9 | // Local constants 10 | const { HOST } = process.env 11 | 12 | 13 | 14 | 15 | 16 | export default (message, connection) => { 17 | const { 18 | channels, 19 | getChannel, 20 | getUser, 21 | send, 22 | username, 23 | } = connection 24 | 25 | const channelsToPart = message.params 26 | 27 | channelsToPart.forEach(channelName => { 28 | const channel = getChannel(channelName, false) 29 | const user = getUser(username) 30 | 31 | if (channel) { 32 | channel.removeUser(user) 33 | send(`:${username}!${username}@${username}.${HOST} PART ${channel.hashName}`) 34 | } else { 35 | send(`:${HOST} 403 ${username} ${channelName} :No such channel`) 36 | } 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers/handlePASSMessage.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { 3 | firebaseAdmin, 4 | firestore, 5 | } from 'helpers/firebase' 6 | 7 | 8 | 9 | 10 | 11 | export default async (message, connection) => { 12 | const [token] = message.params 13 | 14 | connection.token = token 15 | connection.emit('acknowledge') 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/handlePINGMessage.js: -------------------------------------------------------------------------------- 1 | export default (message, connection) => { 2 | connection.sendPong(message.params.join(" ")); 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/handlePONGMessage.js: -------------------------------------------------------------------------------- 1 | export default (message, connection) => { 2 | const { pongTimeoutID } = connection 3 | 4 | if (pongTimeoutID) { 5 | clearTimeout(pongTimeoutID) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/handlePRIVMSGMessage.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import mri from 'mri' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import renderCommandResponse from 'helpers/renderCommandResponse' 10 | import User from 'structures/User' 11 | 12 | 13 | 14 | 15 | 16 | // Local constants 17 | const { HOST } = process.env 18 | 19 | 20 | 21 | 22 | 23 | export default (messageData, connection) => { 24 | const { 25 | getChannel, 26 | getUser, 27 | send, 28 | } = connection 29 | const [channelName, message] = messageData.params 30 | 31 | const argv = message 32 | .replace(/"(.*?)"|'(.*?)'/g, (match, singleQuotes, doubleQuotes) => { 33 | return (singleQuotes || doubleQuotes).replace(/\s/g, '\\s') 34 | }) 35 | .split(' ') 36 | .map(item => item.replace(/\\s/g, ' ')) 37 | 38 | const channel = getChannel(channelName) 39 | const { 40 | _: [ 41 | command, 42 | ...messageBody 43 | ], 44 | ...args 45 | } = mri(argv) 46 | 47 | if (command.toLowerCase().trim() === 'reconnect') { 48 | return connection.sendReconnect() 49 | } 50 | 51 | args.message = messageBody.join(' ') 52 | 53 | const username = args.username || connection.username 54 | 55 | let user = getUser(username) 56 | 57 | if (!user) { 58 | user = new User({ 59 | connection, 60 | username, 61 | }) 62 | } 63 | 64 | const response = renderCommandResponse({ 65 | args, 66 | channel, 67 | command, 68 | connection, 69 | user, 70 | }) 71 | 72 | if (response) { 73 | send(response) 74 | } else { 75 | channel.sendErrorMessage(`FDGT doesn't support the "${command}" command.`) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/helpers/handleQUITMessage.js: -------------------------------------------------------------------------------- 1 | export default (message, connection) => { 2 | connection.close() 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/handleUSERMessage.js: -------------------------------------------------------------------------------- 1 | export default (message, connection) => { 2 | // ignore 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/jsdocHelpers/firstLine.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | firstLine: context => context.split('\n')[0], 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/jsdocPartials/docs.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: '{{{name}}}' 3 | description: '{{{firstLine description}}}' 4 | --- 5 | 6 | {{>header~}} 7 | {{>body}} 8 | {{>member-index~}} 9 | {{>separator~}} 10 | {{>members~}} 11 | -------------------------------------------------------------------------------- /src/helpers/jsdocPartials/examples.hbs: -------------------------------------------------------------------------------- 1 | ## Examples 2 | 3 | {{#examples}} 4 | **{{caption}}** 5 | 6 | [[ CodeTemplate template=PRIVMSG message="{{example}}" ]] 7 | {{/examples}} 8 | -------------------------------------------------------------------------------- /src/helpers/jsdocPartials/params.hbs: -------------------------------------------------------------------------------- 1 | ## Parameters 2 | 3 | {{#if (optionEquals "param-list-format" "list")}}{{>params-list~}}{{/if~}} 4 | {{#if (optionEquals "param-list-format" "table")~}} 5 | {{#if (optionEquals "no-gfm" true)}}{{>params-table-html~}}{{else}}{{>params-table~}}{{/if~}} 6 | {{/if~}} 7 | -------------------------------------------------------------------------------- /src/helpers/log.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import Logger from 'ians-logger' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import { 10 | firebaseAdmin, 11 | firestore, 12 | } from 'helpers/firebase' 13 | import { incrementStat } from 'helpers/updateStat' 14 | 15 | 16 | 17 | 18 | 19 | // Local constants 20 | const { 21 | DEBUG, 22 | NODE_ENV, 23 | } = process.env 24 | const logger = Logger.createLoggerFromName('@fdgt/api') 25 | 26 | 27 | 28 | 29 | 30 | module.exports = (message, meta = {}, type = 'log') => { 31 | if (NODE_ENV !== 'test') { 32 | firestore.collection('logs').add({ 33 | createdAt: firebaseAdmin.firestore.Timestamp.now(), 34 | message, 35 | type, 36 | ...meta, 37 | }) 38 | 39 | incrementStat('logs') 40 | 41 | if (DEBUG) { 42 | logger[type](message) 43 | 44 | Object.entries(meta).forEach(([key, value]) => { 45 | console.log(`> ${key}:`, value) 46 | }) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers/renderCommandResponse.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import getMock from 'helpers/getMock' 3 | import renderMessage from 'helpers/renderMessage' 4 | 5 | 6 | 7 | 8 | 9 | export default options => { 10 | const { 11 | args, 12 | channel, 13 | command, 14 | connection, 15 | user, 16 | } = options 17 | 18 | try { 19 | return renderMessage({ 20 | args, 21 | channel, 22 | command, 23 | connection, 24 | template: getMock({ command }), 25 | user, 26 | }) 27 | } catch (error) { 28 | return null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/renderMessage.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import renderTemplate from 'helpers/renderTemplate' 3 | import serializeTwitchObject from 'helpers/serializeTwitchObject' 4 | 5 | 6 | 7 | 8 | 9 | export default options => { 10 | const { 11 | args, 12 | channel, 13 | connection, 14 | template, 15 | user, 16 | } = options 17 | 18 | try { 19 | const renderedTemplate = renderTemplate({ 20 | args, 21 | channel, 22 | connection, 23 | template, 24 | user, 25 | }) 26 | return `@${serializeTwitchObject(renderedTemplate.tags)} :${renderedTemplate.message}` 27 | } catch (error) { 28 | return null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/renderTemplate.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { HOST } = process.env 10 | 11 | 12 | 13 | 14 | 15 | export default options => { 16 | const messageID = uuid() 17 | const { 18 | args = {}, 19 | channel, 20 | connection, 21 | template, 22 | user, 23 | } = options 24 | const parameters = { 25 | channel: channel?.name, 26 | channelid: channel?.id, 27 | color: user?.color, 28 | connection, 29 | host: HOST, 30 | id: messageID, 31 | messageid: messageID, 32 | timestamp: Date.now(), 33 | userid: user?.id, 34 | username: user?.username, 35 | ...args, 36 | } 37 | const response = {} 38 | 39 | const renderedTemplate = template(parameters) 40 | 41 | response.message = renderedTemplate.message || '' 42 | response.tags = renderedTemplate 43 | 44 | delete renderedTemplate.message 45 | 46 | return response 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/serializeTwitchObject.js: -------------------------------------------------------------------------------- 1 | export default object => { 2 | const foo = Object.entries(object).reduce((accumulator, [key, value]) => { 3 | if (accumulator) { 4 | accumulator += ';' 5 | } 6 | 7 | return `${accumulator}${key}=${value || ''}` 8 | }, '').replace(/\s/gu, '\\s') 9 | 10 | return foo 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/statusCodeGenerator.js: -------------------------------------------------------------------------------- 1 | export default () => async (context, next) => { 2 | await next() 3 | 4 | if (!context.status || (context.status === 200)) { 5 | if (context.body.data) { 6 | context.status = 200 7 | } else if (context.body.errors) { 8 | context.status = 500 9 | } else { 10 | context.status = 204 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/updateStat.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { database } from 'helpers/firebase' 3 | 4 | 5 | 6 | 7 | 8 | // Local constants 9 | const { NODE_ENV } = process.env 10 | 11 | 12 | 13 | 14 | 15 | const updateStat = (counterName, increment = 1) => { 16 | if (NODE_ENV !== 'test') { 17 | try { 18 | database.ref(`stats/${counterName}`).transaction(function (currentCount) { 19 | return currentCount + parseInt(increment, 10) 20 | }) 21 | } catch (error) { 22 | console.log(`Failed to update stat: ${counterName}`, error) 23 | } 24 | } 25 | } 26 | 27 | export const decrementStat = (counterName, increment = 1) => updateStat(counterName, increment * -1) 28 | 29 | export const incrementStat = (counterName, increment = 1) => updateStat(counterName, increment) 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('dotenv').config() 4 | 5 | // Module imports 6 | import { v4 as uuid } from 'uuid' 7 | import fs from 'fs-extra' 8 | import net from 'net' 9 | import tls from 'tls' 10 | import WebSocket from 'ws' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import API from 'structures/API' 18 | import Connection from 'structures/Connection' 19 | import log from 'helpers/log' 20 | import User from 'structures/User' 21 | 22 | 23 | 24 | 25 | 26 | // Local constants 27 | const { 28 | CERT_PATH, 29 | KEY_PATH, 30 | IRC_PORT = 6667, 31 | IRC_TLS_PORT = 6697, 32 | USE_TLS = false, 33 | WEB_PORT = 3000, 34 | WS_PORT = 3001, 35 | } = process.env 36 | const fdgtUser = new User({ username: 'fdgt' }) 37 | 38 | 39 | 40 | 41 | 42 | const handleConnection = (socket, request) => { 43 | let headers = {} 44 | let query = {} 45 | 46 | if (request) { 47 | const queryParams = new URL(request.url, `http://${request.headers.host}`).searchParams 48 | query = [...queryParams.entries()].reduce(function (accumulator, [key, value]) { 49 | accumulator[key] = value 50 | return accumulator 51 | }, {}) 52 | headers = { ...request.headers } 53 | } 54 | 55 | const connection = new Connection({ 56 | fdgtUser, 57 | headers, 58 | query, 59 | socket, 60 | }) 61 | } 62 | 63 | ;(async () => { 64 | const wsServer = new WebSocket.Server({ port: WS_PORT }) 65 | 66 | wsServer.on('connection', handleConnection) 67 | 68 | let tcpServer = net.createServer(handleConnection) 69 | let tcpSSLServer = null 70 | 71 | if (USE_TLS) { 72 | let [cert, key] = await Promise.all([ 73 | fs.readFile(CERT_PATH, 'utf8'), 74 | fs.readFile(KEY_PATH, 'utf8'), 75 | ]) 76 | const options = { 77 | cert, 78 | key, 79 | } 80 | tcpSSLServer = tls.createServer(options, handleConnection) 81 | tcpSSLServer.listen(IRC_TLS_PORT) 82 | } 83 | 84 | tcpServer.listen(IRC_PORT) 85 | API.listen(WEB_PORT) 86 | 87 | log('Server started.') 88 | log(`Listening for Web connections on port ${WEB_PORT}.`) 89 | log(`Listening for WebSocket connections on port ${WS_PORT}.`) 90 | log(`Listening for IRC (non-TLS) connections on port ${IRC_PORT}.`) 91 | 92 | if (USE_TLS) { 93 | log(`Listening for IRC (TLS) connections on port ${IRC_TLS_PORT}.`) 94 | } 95 | })() 96 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/commands.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { promisify } from 'util' 3 | import fs from 'fs' 4 | import jsdoc from 'jsdoc-api' 5 | import path from 'path' 6 | 7 | 8 | 9 | 10 | 11 | // Local imports 12 | import Route from 'structures/Route' 13 | 14 | 15 | 16 | 17 | 18 | // Local constants 19 | const isDev = process.env.NODE_ENV !== 'production' 20 | const readdir = promisify(fs.readdir) 21 | const readFile = promisify(fs.readFile) 22 | const sourceDirectory = path.resolve(process.cwd(), (isDev ? 'src' : 'dist')) 23 | 24 | 25 | 26 | 27 | 28 | export const route = new Route({ 29 | handler: async context => { 30 | try { 31 | const commandsPath = path.resolve(sourceDirectory, 'data-mocks') 32 | const dataMockFiles = await readdir(commandsPath) 33 | const commands = dataMockFiles.map(filename => filename.replace(/\.js$/, '')) 34 | 35 | if (context.query.includeParams) { 36 | const params = await Promise.all(commands.map(command => { 37 | const commandPath = path.resolve(commandsPath, `${command}.js`) 38 | 39 | return readFile(commandPath, 'utf8') 40 | .then(source => jsdoc.explain({ source })) 41 | .then(explainer => { 42 | return explainer 43 | .find(item => item.name === `\`${command}\``) 44 | .params 45 | .map(param => ({ 46 | description: param.description, 47 | name: param.name, 48 | types: param.type.names, 49 | })) 50 | }) 51 | })) 52 | 53 | context.data = commands.reduce((accumulator, command, index) => { 54 | accumulator[command] = params[index] 55 | return accumulator 56 | }, {}) 57 | } else { 58 | context.data = commands 59 | } 60 | } catch (error) { 61 | context.errors.push(error.message) 62 | } 63 | }, 64 | route: '/fdgt/v1/commands', 65 | }) 66 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/commands/[command].js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { promisify } from 'util' 3 | import fs from 'fs' 4 | import jsdoc2md from 'jsdoc-to-markdown' 5 | import path from 'path' 6 | 7 | 8 | 9 | 10 | 11 | // Local imports 12 | import Route from 'structures/Route' 13 | 14 | 15 | 16 | 17 | 18 | // Local constants 19 | const isDev = process.env.NODE_ENV !== 'production' 20 | const readFile = promisify(fs.readFile) 21 | const sourceDirectory = path.resolve(process.cwd(), (isDev ? 'src' : 'dist')) 22 | 23 | 24 | 25 | 26 | 27 | export const route = new Route({ 28 | handler: async context => { 29 | const { command } = context.params 30 | 31 | try { 32 | const commandPath = path.resolve(sourceDirectory, 'data-mocks', `${command}.js`) 33 | const jsdocHelpersPath = path.resolve(sourceDirectory, 'helpers', 'jsdocHelpers') 34 | const jsdocPartialsPath = path.resolve(sourceDirectory, 'helpers', 'jsdocPartials') 35 | 36 | const doc = await jsdoc2md.render({ 37 | files: commandPath, 38 | 'heading-depth': 1, 39 | helper: [ 40 | path.resolve(jsdocHelpersPath, 'firstLine.js'), 41 | ], 42 | partial: [ 43 | path.resolve(jsdocPartialsPath, 'docs.hbs'), 44 | path.resolve(jsdocPartialsPath, 'examples.hbs'), 45 | path.resolve(jsdocPartialsPath, 'params.hbs'), 46 | ], 47 | }) 48 | 49 | context.data = doc 50 | } catch (error) { 51 | context.errors.push(error.message) 52 | } 53 | }, 54 | route: '/fdgt/v1/commands/:command', 55 | }) 56 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/commands/[command]/docs.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { promisify } from 'util' 3 | import jsdoc2md from 'jsdoc-to-markdown' 4 | import path from 'path' 5 | 6 | 7 | 8 | 9 | 10 | // Local imports 11 | import Route from 'structures/Route' 12 | 13 | 14 | 15 | 16 | 17 | // Local constants 18 | const isDev = process.env.NODE_ENV !== 'production' 19 | const sourceDirectory = path.resolve(process.cwd(), (isDev ? 'src' : 'dist')) 20 | 21 | 22 | 23 | 24 | 25 | export const route = new Route({ 26 | handler: async context => { 27 | const { command } = context.params 28 | 29 | try { 30 | const commandPath = path.resolve(sourceDirectory, 'data-mocks', `${command}.js`) 31 | const jsdocHelpersPath = path.resolve(sourceDirectory, 'helpers', 'jsdocHelpers') 32 | const jsdocPartialsPath = path.resolve(sourceDirectory, 'helpers', 'jsdocPartials') 33 | 34 | const doc = await jsdoc2md.render({ 35 | files: commandPath, 36 | 'heading-depth': 1, 37 | helper: [ 38 | path.resolve(jsdocHelpersPath, 'firstLine.js'), 39 | ], 40 | partial: [ 41 | path.resolve(jsdocPartialsPath, 'docs.hbs'), 42 | path.resolve(jsdocPartialsPath, 'examples.hbs'), 43 | path.resolve(jsdocPartialsPath, 'params.hbs'), 44 | ], 45 | }) 46 | 47 | context.data = doc 48 | } catch (error) { 49 | context.errors.push(error.message) 50 | } 51 | }, 52 | route: '/fdgt/v1/commands/:command/docs', 53 | }) 54 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/commands/[command]/params.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { promisify } from 'util' 3 | import fs from 'fs' 4 | import jsdoc from 'jsdoc-api' 5 | import path from 'path' 6 | 7 | 8 | 9 | 10 | 11 | // Local imports 12 | import Route from 'structures/Route' 13 | 14 | 15 | 16 | 17 | 18 | // Local constants 19 | const isDev = process.env.NODE_ENV !== 'production' 20 | const readFile = promisify(fs.readFile) 21 | const sourceDirectory = path.resolve(process.cwd(), (isDev ? 'src' : 'dist')) 22 | 23 | 24 | 25 | 26 | 27 | export const route = new Route({ 28 | handler: async context => { 29 | const { command } = context.params 30 | 31 | try { 32 | const commandPath = path.resolve(sourceDirectory, 'data-mocks', `${command}.js`) 33 | const fileContents = await readFile(commandPath, 'utf8') 34 | const explainer = await jsdoc.explain({ source: fileContents }) 35 | const params = explainer.find(item => item.name === `\`${command}\``).params.map(param => ({ 36 | description: param.description, 37 | name: param.name, 38 | types: param.type.names, 39 | })) 40 | 41 | context.data = params 42 | } catch (error) { 43 | context.errors.push(error.message) 44 | } 45 | }, 46 | route: '/fdgt/v1/commands/:command/params', 47 | }) 48 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/contributors.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { promisify } from 'util' 3 | import fetch from 'node-fetch' 4 | import fs from 'fs' 5 | import path from 'path' 6 | 7 | 8 | 9 | 10 | 11 | // Local imports 12 | import Route from 'structures/Route' 13 | 14 | 15 | 16 | 17 | 18 | // Local constants 19 | const readFile = promisify(fs.readFile) 20 | 21 | 22 | 23 | 24 | 25 | export const route = new Route({ 26 | handler: async context => { 27 | const contributorsPath = path.resolve(process.cwd(), '.all-contributorsrc') 28 | let allContributorsFile = null 29 | 30 | try { 31 | allContributorsFile = await readFile(contributorsPath, 'utf8') 32 | } catch (error) { 33 | context.errors.push(error.message) 34 | return 35 | } 36 | 37 | const { contributors } = JSON.parse(allContributorsFile) 38 | 39 | try { 40 | await Promise.all(contributors.map(async contributor => { 41 | const profile = await fetch(`https://api.github.com/users/${contributor.login}`, { 42 | headers: { 43 | Authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}` 44 | }, 45 | }) 46 | const profileJSON = await profile.json() 47 | 48 | contributor.twitter = profileJSON.twitter_username 49 | 50 | return contributor 51 | })) 52 | 53 | context.data = contributors 54 | } catch (error) { 55 | context.errors.push(error.message) 56 | return 57 | } 58 | }, 59 | route: '/fdgt/v1/contributors', 60 | }) 61 | -------------------------------------------------------------------------------- /src/routes/fdgt/v1/sponsors.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import fetch from 'node-fetch' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import Route from 'structures/Route' 10 | 11 | 12 | 13 | 14 | 15 | export const route = new Route({ 16 | handler: async context => { 17 | let sponsors = null 18 | let sponsorshipTiers = null 19 | 20 | try { 21 | const { 22 | data, 23 | errors, 24 | } = await fetch('https://api.github.com/graphql', { 25 | body: JSON.stringify({ 26 | query: `query { 27 | user(login: "trezy") { 28 | sponsorsListing { 29 | tiers(first: 10) { 30 | nodes { 31 | description 32 | id 33 | monthlyPriceInDollars 34 | name 35 | } 36 | } 37 | } 38 | sponsorshipsAsMaintainer(first: 100) { 39 | nodes { 40 | createdAt 41 | privacyLevel 42 | sponsorEntity { 43 | ... on User { 44 | avatarUrl 45 | id 46 | login 47 | name 48 | status { 49 | emoji 50 | message 51 | } 52 | twitterUsername 53 | url 54 | websiteUrl 55 | } 56 | ... on Organization { 57 | avatarUrl 58 | id 59 | login 60 | name 61 | twitterUsername 62 | url 63 | websiteUrl 64 | } 65 | } 66 | tier { 67 | id 68 | monthlyPriceInDollars 69 | name 70 | } 71 | } 72 | } 73 | } 74 | }`.replace(/\t/g, ''), 75 | }), 76 | headers: { 77 | Authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}` 78 | }, 79 | method: 'post', 80 | }).then(response => response.json()) 81 | 82 | if (errors) { 83 | errors.forEach(error => context.errors.push(error)) 84 | } 85 | 86 | if (data) { 87 | sponsorshipTiers = data.user.sponsorsListing.tiers.nodes.map(sponsorshipTier => ({ 88 | attributes: { ...sponsorshipTier }, 89 | id: sponsorshipTier.id, 90 | type: 'tier', 91 | })).slice(1) 92 | 93 | sponsors = data.user.sponsorshipsAsMaintainer.nodes.reduce((accumulator, node) => { 94 | const { 95 | privacyLevel, 96 | sponsorEntity, 97 | tier, 98 | } = node 99 | 100 | const sponsorshipTierExists = Boolean(sponsorshipTiers.find(sponsorshipTier => sponsorshipTier.id === tier.id)) 101 | 102 | if (sponsorshipTierExists && (privacyLevel === 'PUBLIC')) { 103 | const sponsor = { 104 | attributes: { ...sponsorEntity }, 105 | id: sponsorEntity.id, 106 | relationships: { 107 | tier: { 108 | data: { 109 | id: tier.id, 110 | type: 'tier', 111 | } 112 | }, 113 | }, 114 | } 115 | 116 | accumulator[sponsorEntity.id] = sponsor 117 | } 118 | 119 | return accumulator 120 | }, {}) 121 | } 122 | 123 | context.data = sponsors 124 | context.included = sponsorshipTiers 125 | } catch (error) { 126 | context.errors.push(error.message) 127 | return 128 | } 129 | }, 130 | route: '/fdgt/v1/sponsors', 131 | }) 132 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import 'routes/fdgt/v1/commands' 2 | import 'routes/fdgt/v1/commands/[command]' 3 | import 'routes/fdgt/v1/commands/[command]/docs' 4 | import 'routes/fdgt/v1/commands/[command]/params' 5 | import 'routes/fdgt/v1/contributors' 6 | import 'routes/fdgt/v1/sponsors' 7 | -------------------------------------------------------------------------------- /src/structures/API.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import body from 'koa-body' 3 | import cors from '@koa/cors' 4 | import compress from 'koa-compress' 5 | import Koa from 'koa' 6 | import noTrailingSlash from 'koa-no-trailing-slash' 7 | 8 | 9 | 10 | 11 | 12 | // Local imports 13 | import * as routes from 'routes' 14 | import bodyBuilder from 'helpers/bodyBuilder' 15 | import statusCodeGenerator from 'helpers/statusCodeGenerator' 16 | import router from 'structures/Router' 17 | 18 | 19 | 20 | 21 | 22 | // Local constants 23 | const { 24 | WEB_PORT = 3000, 25 | } = process.env 26 | const app = new Koa() 27 | 28 | 29 | 30 | 31 | 32 | // Attach middlewares 33 | app.use(noTrailingSlash()) 34 | app.use(compress()) 35 | app.use(cors()) 36 | app.use(body()) 37 | app.use(statusCodeGenerator()) 38 | app.use(bodyBuilder()) 39 | 40 | app.use(router.routes()) 41 | app.use(router.allowedMethods()) 42 | 43 | 44 | 45 | 46 | 47 | export default app 48 | -------------------------------------------------------------------------------- /src/structures/Channel.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import renderMessage from 'helpers/renderMessage' 10 | import serializeTwitchObject from 'helpers/serializeTwitchObject' 11 | import UserList from 'structures/UserList' 12 | 13 | 14 | 15 | 16 | 17 | // Local constants 18 | const { HOST } = process.env 19 | 20 | 21 | 22 | 23 | 24 | export default class extends UserList { 25 | /***************************************************************************\ 26 | Local Properties 27 | \***************************************************************************/ 28 | 29 | emoteOnly = false 30 | 31 | followersOnly = false 32 | 33 | id = uuid() 34 | 35 | isConnected = false 36 | 37 | slowMode = false 38 | 39 | subsOnly = false 40 | 41 | 42 | 43 | 44 | 45 | /***************************************************************************\ 46 | Public Methods 47 | \***************************************************************************/ 48 | 49 | addUser = this.add 50 | 51 | connect = (options = {}) => { 52 | const { user } = options 53 | 54 | if (user) { 55 | this.addUser(user) 56 | } 57 | 58 | this.isConnected = true 59 | } 60 | 61 | constructor (options) { 62 | super(options) 63 | 64 | if (options.name) { 65 | options.name = options.name 66 | .replace(/^#/u, '') 67 | .toLowerCase() 68 | } 69 | 70 | this.options = options 71 | 72 | this.isConnected = Boolean(options.isConnected) 73 | } 74 | 75 | disconnect = () => { 76 | this.isConnected = false 77 | } 78 | 79 | getRandomUser = this.getRandom 80 | 81 | removeUser = this.remove 82 | 83 | sendErrorMessage = error => { 84 | const user = this.connection.fdgtUser 85 | 86 | this.connection.send(renderMessage({ 87 | channel: this, 88 | template: () => ({ 89 | 'badge-info': [], 90 | badges: [], 91 | color: user.color, 92 | 'display-name': user.username, 93 | emotes: null, 94 | flags: null, 95 | id: uuid(), 96 | mod: 0, 97 | 'room-id': this.id, 98 | subscriber: 0, 99 | 'tmi-sent-ts': Date.now(), 100 | turbo: 0, 101 | 'user-id': user.id, 102 | 'user-type': null, 103 | message: `${user.username}!${user.username}@${user.username}.${HOST} PRIVMSG #${this.name} :${error}`, 104 | }), 105 | user, 106 | })) 107 | } 108 | 109 | sendROOMSTATE = () => { 110 | this.connection.send(renderMessage({ 111 | channel: this, 112 | template: () => ({ 113 | 'emote-only': Number(this.emoteOnly), 114 | 'followers-only': Number(this.followersOnly), 115 | r9k: 0, 116 | rituals: 0, 117 | 'room-id': this.id, 118 | slow: Number(this.slowMode), 119 | 'subs-only': Number(this.subsOnly), 120 | message: `${HOST} ROOMSTATE #${this.name}`, 121 | }), 122 | })) 123 | } 124 | 125 | 126 | 127 | 128 | 129 | /***************************************************************************\ 130 | Getters 131 | \***************************************************************************/ 132 | 133 | get connection () { 134 | return this.options.connection 135 | } 136 | 137 | get hashName () { 138 | return `#${this.options.name}` 139 | } 140 | 141 | get name () { 142 | return this.options.name 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/structures/ChannelList.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import Collection from 'structures/Collection' 3 | 4 | 5 | 6 | 7 | 8 | export default class extends Collection { 9 | /***************************************************************************\ 10 | Public Methods 11 | \***************************************************************************/ 12 | 13 | findByName = name => this.findByKey('name', name.replace(/^#/, '').toLowerCase()) 14 | 15 | 16 | 17 | 18 | 19 | /***************************************************************************\ 20 | Getters 21 | \***************************************************************************/ 22 | 23 | get channelNames () { 24 | return this.data.map(({ name }) => name) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/structures/Collection.js: -------------------------------------------------------------------------------- 1 | export default class { 2 | /***************************************************************************\ 3 | Local Properties 4 | \***************************************************************************/ 5 | 6 | _data = [] 7 | 8 | 9 | 10 | 11 | 12 | /***************************************************************************\ 13 | Public Methods 14 | \***************************************************************************/ 15 | 16 | add = item => this.data.push(item) 17 | 18 | clear = item => this.data.splice(0, this.data.length) 19 | 20 | findByID = id => this.findByKey('id', id) 21 | 22 | findByKey = (key, value) => this.data.find(item => (item[key] === value)) 23 | 24 | getRandom = () => this.data[Math.floor(Math.random() * this.data.length)] 25 | 26 | remove = item => this.data.splice(this.data.indexOf(item), 1) 27 | 28 | 29 | 30 | 31 | 32 | /***************************************************************************\ 33 | Getters 34 | \***************************************************************************/ 35 | 36 | get isEmpty () { 37 | return !this.data.length 38 | } 39 | 40 | get ids () { 41 | return this.data.map(({ id }) => id) 42 | } 43 | 44 | get data () { 45 | return this._data 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/structures/Connection.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { parse as parseIRCMessage } from 'irc-message' 3 | import { v4 as uuid } from 'uuid' 4 | import EventEmitter from 'events' 5 | 6 | 7 | 8 | 9 | 10 | // Local imports 11 | import { 12 | decrementStat, 13 | incrementStat, 14 | } from 'helpers/updateStat' 15 | import { getApp } from 'helpers/apps' 16 | import Channel from 'structures/Channel' 17 | import ChannelList from 'structures/ChannelList' 18 | import log from 'helpers/log' 19 | import handleCAPMessage from 'helpers/handleCAPMessage' 20 | import handleJOINMessage from 'helpers/handleJOINMessage' 21 | import handleNICKMessage from 'helpers/handleNICKMessage' 22 | import handlePARTMessage from 'helpers/handlePARTMessage' 23 | import handlePASSMessage from 'helpers/handlePASSMessage' 24 | import handlePINGMessage from 'helpers/handlePINGMessage' 25 | import handlePONGMessage from 'helpers/handlePONGMessage' 26 | import handlePRIVMSGMessage from 'helpers/handlePRIVMSGMessage' 27 | import handleUSERMessage from 'helpers/handleUSERMessage' 28 | import handleQUITMessage from 'helpers/handleQUITMessage' 29 | import User from 'structures/User' 30 | import UserList from 'structures/UserList' 31 | 32 | 33 | 34 | 35 | 36 | // Local constants 37 | const { HOST } = process.env 38 | 39 | 40 | 41 | 42 | 43 | export default class extends EventEmitter { 44 | /***************************************************************************\ 45 | Local Properties 46 | \***************************************************************************/ 47 | 48 | app = null 49 | 50 | capabilities = [] 51 | 52 | channels = new ChannelList 53 | 54 | capabilitiesFinished = false 55 | 56 | id = uuid() 57 | 58 | isAcknowledged = false 59 | 60 | pingIntervalID = null 61 | 62 | pongTimeoutID = null 63 | 64 | token = null 65 | 66 | username = null 67 | 68 | users = new UserList 69 | 70 | 71 | 72 | 73 | 74 | /***************************************************************************\ 75 | Private Properties 76 | \***************************************************************************/ 77 | 78 | #acknowledge = () => { 79 | if (this.username && this.capabilitiesFinished && (this.token || /^justinfan\d+$/.test(this.username))) { 80 | this.off('acknowledge', this.#acknowledge) 81 | this.isAcknowledged = true 82 | this.sendMOTD() 83 | } 84 | } 85 | 86 | #handleMessages = rawMessages => { 87 | const messages = rawMessages.toString() 88 | .replace(/\r\n$/, '') 89 | .split('\r\n') 90 | .map(item => parseIRCMessage(item)) 91 | 92 | incrementStat('messagesReceived', messages.length) 93 | 94 | messages.forEach(message => { 95 | let handler = null 96 | 97 | this.#log('Message from client', { message: message.raw }, 'info') 98 | 99 | switch (message.command.toUpperCase()) { 100 | case 'CAP': 101 | handler = handleCAPMessage 102 | break 103 | 104 | case 'JOIN': 105 | handler = handleJOINMessage 106 | break 107 | 108 | case 'NICK': 109 | handler = handleNICKMessage 110 | break 111 | 112 | case 'PART': 113 | handler = handlePARTMessage 114 | break 115 | 116 | case 'PASS': 117 | handler = handlePASSMessage 118 | break 119 | 120 | case 'PING': 121 | handler = handlePINGMessage 122 | break 123 | 124 | case 'PONG': 125 | handler = handlePONGMessage 126 | break 127 | 128 | case 'PRIVMSG': 129 | handler = handlePRIVMSGMessage 130 | break 131 | 132 | case 'USER': 133 | handler = handleUSERMessage 134 | break 135 | 136 | case 'QUIT': 137 | handler = handleQUITMessage 138 | break 139 | 140 | default: 141 | this.#log(`No handler for ${message.command} messages`, {}, 'error') 142 | this.sendUnknownCommand(message.command) 143 | } 144 | 145 | if (handler) { 146 | handler(message, this) 147 | } 148 | }) 149 | } 150 | 151 | #initialize () { 152 | this.on('acknowledge', this.#acknowledge) 153 | 154 | const appID = this.options.query.token || this.options.headers.Authorization?.replace(/^Bearer\s/, '') 155 | getApp(appID, this.id) 156 | .then(app => this.app = app) 157 | 158 | this.#initializeConnectionCloseHandler() 159 | this.#initializeMessageHandler() 160 | this.#initializePing() 161 | } 162 | 163 | #initializeConnectionCloseHandler = () => { 164 | const closeEvents = [ 165 | 'close', 166 | 'end', 167 | 'error', 168 | ] 169 | 170 | closeEvents.forEach(eventType => { 171 | this.socket.on(eventType, () => this.close()) 172 | }) 173 | } 174 | 175 | #initializeMessageHandler = () => { 176 | if (this.#isWebsocket()) { 177 | this.socket.on('message', this.#handleMessages) 178 | } else { 179 | this.socket.on('data', this.#handleMessages) 180 | } 181 | } 182 | 183 | #initializePing = () => { 184 | // Ping the client every 30 seconds. Otherwise, Heroku will kill the 185 | // connection. 186 | this.pingIntervalID = setInterval(() => { 187 | const { id } = this 188 | 189 | this.pongTimeoutID = setTimeout(() => { 190 | this.#log('Client didn\'t PONG in time - terminating connection', {}, 'error') 191 | 192 | clearInterval(this.pingIntervalID) 193 | 194 | this.close() 195 | }, 5000) 196 | 197 | this.#log('Pinging client', {}, 'info') 198 | 199 | this.send(`PING :${HOST}`) 200 | }, 30000) 201 | } 202 | 203 | #isIRCSocket = () => (this.type === 'irc') 204 | 205 | #isWebsocket = () => (this.type === 'websocket') 206 | 207 | #log (message, meta, type) { 208 | const compiledMeta = { 209 | ...(meta || {}), 210 | appID: null, 211 | connectionID: this.id, 212 | } 213 | 214 | if (this.app) { 215 | compiledMeta.appID = this.app.id 216 | } 217 | 218 | log(message, compiledMeta, type) 219 | } 220 | 221 | 222 | 223 | 224 | 225 | /***************************************************************************\ 226 | Public Properties 227 | \***************************************************************************/ 228 | 229 | addCapabilities = capabilities => { 230 | this.capabilities = [ 231 | ...this.capabilities, 232 | ...capabilities, 233 | ] 234 | } 235 | 236 | close = () => { 237 | clearTimeout(this.pongTimeoutID) 238 | clearInterval(this.pingIntervalID) 239 | 240 | if (this.#isWebsocket()) { 241 | this.socket.terminate() 242 | } else { 243 | this.socket.end() 244 | } 245 | 246 | decrementStat('activeConnections') 247 | 248 | this.emit('close') 249 | } 250 | 251 | constructor (options) { 252 | super() 253 | 254 | this.options = options 255 | 256 | this.#log('New client connected', { type: this.type }, 'info') 257 | incrementStat('connections') 258 | incrementStat('activeConnections') 259 | 260 | this.#initialize() 261 | } 262 | 263 | getChannel = (channelName, create = true) => { 264 | let channel = this.channels.findByName(channelName) 265 | 266 | if (!channel && create) { 267 | channel = new Channel({ 268 | connection: this, 269 | name: channelName, 270 | }) 271 | this.channels.add(channel) 272 | } 273 | 274 | return channel 275 | } 276 | 277 | getUser = username => { 278 | let user = this.users.findByUsername(username) 279 | 280 | if (!user) { 281 | user = new User({ 282 | connection: this, 283 | username, 284 | }) 285 | this.users.add(user) 286 | } 287 | 288 | return user 289 | } 290 | 291 | send = response => { 292 | let messages = response 293 | 294 | if (!Array.isArray(messages)) { 295 | messages = [messages] 296 | } 297 | 298 | try { 299 | incrementStat('messagesSent', messages.length) 300 | messages.forEach(message => { 301 | this.#log(`Sending message to client`, { message }) 302 | incrementStat('messagesSent') 303 | if (this.#isWebsocket()) { 304 | this.socket.send(message) 305 | } else { 306 | this.socket.write(`${message}\r\n`) 307 | } 308 | }) 309 | } catch (error) { 310 | this.#log('Failed to send response', {}, 'error') 311 | } 312 | } 313 | 314 | sendMOTD = () => { 315 | const motdMessages = [ 316 | `:${HOST} 001 ${this.username} :Welcome, GLHF!`, // WELCOME 317 | `:${HOST} 002 ${this.username} :Your host is ${HOST}`, // YOURHOST 318 | `:${HOST} 003 ${this.username} :This server is rather new`, // CREATED 319 | `:${HOST} 004 ${this.username} :-`, // MYINFO 320 | `:${HOST} 375 ${this.username} :-`, // MOTDSTART 321 | `:${HOST} 372 ${this.username} :You are in a maze of twisty passages, all alike.`, // MOTD 322 | `:${HOST} 372 ${this.username} :Your FDGT connection ID is ${this.id}.`, // MOTD 323 | `:${HOST} 376 ${this.username} :>`, // MOTDEND 324 | ] 325 | 326 | // if (this.app) { 327 | // motdMessages.splice(7, 0, `:${HOST} 372 ${this.username} :You can view the logs for this connection at https://fdgt.dev/dashboard/app/${this.app.id}.`) 328 | // } 329 | 330 | this.send(motdMessages) 331 | } 332 | 333 | sendPong = (payload) => { 334 | if (payload) { 335 | this.send(`PONG ${payload}`) 336 | } else { 337 | this.send(`PONG`) 338 | } 339 | } 340 | 341 | sendReconnect = () => { 342 | incrementStat('reconnects') 343 | this.send('RECONNECT') 344 | } 345 | 346 | sendUnknownCommand = command => { 347 | this.send(`:${HOST} 421 ${this.username} ${command} :Unknown command`) 348 | } 349 | 350 | 351 | 352 | 353 | 354 | /***************************************************************************\ 355 | Getters 356 | \***************************************************************************/ 357 | 358 | get fdgtUser () { 359 | return this.options.fdgtUser 360 | } 361 | 362 | get socket () { 363 | return this.options.socket 364 | } 365 | 366 | get type () { 367 | if (this.socket.send) { 368 | return 'websocket' 369 | } 370 | 371 | return 'irc' 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/structures/Route.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import router from 'structures/Router' 3 | 4 | 5 | 6 | 7 | 8 | export default class { 9 | defaultOptions = { 10 | methods: ['get'], 11 | } 12 | 13 | constructor (options) { 14 | const allOptions = { 15 | ...this.defaultOptions, 16 | ...options, 17 | } 18 | this.options = allOptions 19 | 20 | const { 21 | handler, 22 | route, 23 | } = allOptions 24 | 25 | if (!route) { 26 | throw new Error('route is required') 27 | } 28 | 29 | if (!handler) { 30 | throw new Error('handler is required') 31 | } 32 | 33 | let methods = allOptions.methods 34 | 35 | if (!Array.isArray(methods)) { 36 | methods = [methods] 37 | } 38 | 39 | methods.forEach(method => router[method](route, handler)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/structures/Router.js: -------------------------------------------------------------------------------- 1 | import Router from 'koa-router' 2 | 3 | const router = new Router() 4 | 5 | export default router 6 | -------------------------------------------------------------------------------- /src/structures/User.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | import tinycolor from 'tinycolor2' 4 | 5 | 6 | 7 | 8 | 9 | // Local imports 10 | import renderMessage from 'helpers/renderMessage' 11 | 12 | 13 | 14 | 15 | 16 | // Local constants 17 | const { HOST } = process.env 18 | 19 | 20 | 21 | 22 | 23 | export default class { 24 | /***************************************************************************\ 25 | Local Properties 26 | \***************************************************************************/ 27 | 28 | color = tinycolor.random().toHexString() 29 | 30 | id = uuid() 31 | 32 | 33 | 34 | 35 | 36 | /***************************************************************************\ 37 | Public Methods 38 | \***************************************************************************/ 39 | 40 | constructor (options) { 41 | this.options = options 42 | } 43 | 44 | sendUSERSTATE = channelName => { 45 | this.connection.send(renderMessage({ 46 | template: () => ({ 47 | 'badge-info': null, 48 | badges: null, 49 | color: this.color, 50 | 'display-name': this.displayName, 51 | 'emote-sets': 0, 52 | mod: 0, 53 | subscriber: 0, 54 | 'user-type': null, 55 | message: `${HOST} USERSTATE #${channelName}` 56 | }), 57 | })) 58 | } 59 | 60 | 61 | 62 | 63 | 64 | /***************************************************************************\ 65 | Getters 66 | \***************************************************************************/ 67 | 68 | get connection () { 69 | return this.options.connection 70 | } 71 | 72 | get displayName () { 73 | return this.options.username 74 | } 75 | 76 | get username () { 77 | return this.options.username.toLowerCase() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/structures/UserList.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import Collection from 'structures/Collection' 3 | 4 | 5 | 6 | 7 | 8 | export default class extends Collection { 9 | /***************************************************************************\ 10 | Public Methods 11 | \***************************************************************************/ 12 | 13 | findByUsername = username => this.findByKey('username', username) 14 | 15 | 16 | 17 | 18 | 19 | /***************************************************************************\ 20 | Getters 21 | \***************************************************************************/ 22 | 23 | get usernames () { 24 | return this.data.map(({ username }) => username) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/commands/bits.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | spy, 4 | useFakeTimers, 5 | } from 'sinon' 6 | import { expect } from 'chai' 7 | import { parse as parseIRCMessage } from 'irc-message' 8 | import { v4 as uuid } from 'uuid' 9 | import EventEmitter from 'events' 10 | import faker from 'faker' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import { createConnection } from '../test-helpers/createConnection' 18 | import CAPABILITIES from 'data/CAPABILITIES' 19 | import Channel from 'structures/Channel' 20 | 21 | 22 | 23 | 24 | 25 | // Local constants 26 | const testChannelName = 'TestChannel' 27 | const testOauthToken = 'oauth:1234567890' 28 | const testUsername = 'Bob' 29 | 30 | 31 | 32 | 33 | 34 | describe('bits events', function() { 35 | const clock = useFakeTimers() 36 | 37 | let connection = null 38 | let socket = null 39 | 40 | beforeEach(() => { 41 | connection = createConnection() 42 | socket = connection.socket 43 | 44 | socket.emit('message', `NICK ${testUsername}`) 45 | socket.emit('message', `PASS ${testOauthToken}`) 46 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 47 | socket.emit('message', 'CAP END') 48 | socket.emit('message', `JOIN #${testChannelName}`) 49 | 50 | spy(connection) 51 | spy(socket) 52 | }) 53 | 54 | afterEach(() => { 55 | connection.close() 56 | connection = null 57 | socket = null 58 | }) 59 | 60 | it('should simulate a `bits` event', () => { 61 | socket.emit('message', `PRIVMSG #${testChannelName} :bits`) 62 | 63 | const rawMessage = connection.send.getCall(0)?.firstArg 64 | const { tags } = parseIRCMessage(rawMessage) 65 | 66 | expect(tags.bits).to.exist 67 | }) 68 | 69 | describe('with message', () => { 70 | it('should forward the message', () => { 71 | const message = faker.lorem.sentence() 72 | socket.emit('message', `PRIVMSG #${testChannelName} :bits ${message}`) 73 | 74 | const rawMessage = connection.send.getCall(0).firstArg 75 | const { 76 | params: [, forwardedMessage], 77 | tags, 78 | } = parseIRCMessage(rawMessage) 79 | 80 | expect(forwardedMessage).to.include(message) 81 | }) 82 | 83 | it('should attach a cheermote with the default amount of bits', () => { 84 | const message = faker.lorem.sentence() 85 | 86 | socket.emit('message', `PRIVMSG #${testChannelName} :bits ${message}`) 87 | 88 | const rawMessage = connection.send.getCall(0).firstArg 89 | const { 90 | params: [, forwardedMessage], 91 | tags, 92 | } = parseIRCMessage(rawMessage) 93 | 94 | expect(tags.bits).to.equal('100') 95 | expect(forwardedMessage).to.equal(`${message} cheer100`) 96 | }) 97 | 98 | it('should send the amount of bits defined by cheermotes in the message', () => { 99 | const message = 'foo cheer1000 bar cheer1000 baz' 100 | 101 | socket.emit('message', `PRIVMSG #${testChannelName} :bits ${message}`) 102 | 103 | const rawMessage = connection.send.getCall(0).firstArg 104 | const { tags } = parseIRCMessage(rawMessage) 105 | 106 | expect(tags.bits).to.equal('2000') 107 | }) 108 | 109 | it('should attach a cheermote with the amount of bits defined by `--bitscount`', () => { 110 | const message = faker.lorem.sentence() 111 | const bitscount = 99999 112 | 113 | socket.emit('message', `PRIVMSG #${testChannelName} :bits --bitscount ${bitscount} ${message}`) 114 | 115 | const rawMessage = connection.send.getCall(0).firstArg 116 | const { 117 | params: [, forwardedMessage], 118 | tags, 119 | } = parseIRCMessage(rawMessage) 120 | 121 | expect(forwardedMessage).to.equal(`${message} cheer${bitscount}`) 122 | }) 123 | }) 124 | 125 | describe('without message', () => { 126 | it('should attach a cheermote with the default amount of bits', () => { 127 | socket.emit('message', `PRIVMSG #${testChannelName} :bits`) 128 | 129 | const rawMessage = connection.send.getCall(0).firstArg 130 | const { 131 | params: [, message], 132 | tags, 133 | } = parseIRCMessage(rawMessage) 134 | 135 | expect(tags.bits).to.equal('100') 136 | expect(message).to.equal('cheer100') 137 | }) 138 | 139 | it('should attach a cheermote with the amount of bits defined by `--bitscount`', () => { 140 | const bitscount = 99999 141 | 142 | socket.emit('message', `PRIVMSG #${testChannelName} :bits --bitscount ${bitscount}`) 143 | 144 | const rawMessage = connection.send.getCall(0).firstArg 145 | const { 146 | params: [, message], 147 | tags, 148 | } = parseIRCMessage(rawMessage) 149 | 150 | expect(message).to.equal(`cheer${bitscount}`) 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /test/commands/reconnect.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | spy, 4 | useFakeTimers, 5 | } from 'sinon' 6 | import { expect } from 'chai' 7 | import { parse as parseIRCMessage } from 'irc-message' 8 | import { v4 as uuid } from 'uuid' 9 | import EventEmitter from 'events' 10 | import faker from 'faker' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import { createConnection } from '../test-helpers/createConnection' 18 | import CAPABILITIES from 'data/CAPABILITIES' 19 | 20 | 21 | 22 | 23 | 24 | // Local constants 25 | const testChannelName = 'TestChannel' 26 | const testOauthToken = 'oauth:1234567890' 27 | const testUsername = 'Bob' 28 | 29 | 30 | 31 | 32 | 33 | describe('reconnect events', function() { 34 | const clock = useFakeTimers() 35 | 36 | let connection = null 37 | let socket = null 38 | 39 | beforeEach(() => { 40 | connection = createConnection() 41 | socket = connection.socket 42 | 43 | socket.emit('message', `NICK ${testUsername}`) 44 | socket.emit('message', `PASS ${testOauthToken}`) 45 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 46 | socket.emit('message', 'CAP END') 47 | socket.emit('message', `JOIN #${testChannelName}`) 48 | 49 | spy(connection) 50 | spy(socket) 51 | }) 52 | 53 | afterEach(() => { 54 | connection.close() 55 | connection = null 56 | socket = null 57 | }) 58 | 59 | it('should simulate a `reconnect` event', () => { 60 | socket.emit('message', `PRIVMSG #${testChannelName} :reconnect`) 61 | 62 | const rawMessage = connection.send.getCall(0)?.firstArg 63 | const responseMessage = parseIRCMessage(rawMessage) 64 | 65 | expect(responseMessage.command).to.equal('RECONNECT') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/commands/subgift.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | spy, 4 | useFakeTimers, 5 | } from 'sinon' 6 | import { expect } from 'chai' 7 | import { parse as parseIRCMessage } from 'irc-message' 8 | import { v4 as uuid } from 'uuid' 9 | import EventEmitter from 'events' 10 | import faker from 'faker' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import { createConnection } from '../test-helpers/createConnection' 18 | import CAPABILITIES from 'data/CAPABILITIES' 19 | import Channel from 'structures/Channel' 20 | 21 | 22 | 23 | 24 | 25 | // Local constants 26 | const testChannelName = 'TestChannel' 27 | const testMultiMonthSubLength = 3 28 | const testSubTenure = 2 29 | const testOauthToken = 'oauth:1234567890' 30 | const testUsername = 'Bob' 31 | 32 | 33 | 34 | 35 | 36 | describe('subgift events', function() { 37 | const clock = useFakeTimers() 38 | 39 | let connection = null 40 | let socket = null 41 | 42 | beforeEach(() => { 43 | connection = createConnection() 44 | socket = connection.socket 45 | 46 | socket.emit('message', `NICK ${testUsername}`) 47 | socket.emit('message', `PASS ${testOauthToken}`) 48 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 49 | socket.emit('message', 'CAP END') 50 | socket.emit('message', `JOIN #${testChannelName}`) 51 | 52 | spy(connection) 53 | spy(socket) 54 | }) 55 | 56 | afterEach(() => { 57 | connection.close() 58 | connection = null 59 | socket = null 60 | }) 61 | 62 | it('should simulate a `subgift` event', () => { 63 | socket.emit('message', `PRIVMSG #${testChannelName} :subgift`) 64 | 65 | const rawMessage = connection.send.getCall(0)?.firstArg 66 | const { 67 | command, 68 | tags, 69 | } = parseIRCMessage(rawMessage) 70 | 71 | expect(tags.badges).to.include('sub-gifter/') 72 | expect(tags['msg-param-sub-plan-name']).to.equal('Tier\\s1') 73 | expect(tags['msg-param-sub-plan']).to.equal('1000') 74 | expect(command).to.equal('USERNOTICE') 75 | }) 76 | 77 | it('should set the sender\'s username', () => { 78 | socket.emit('message', `PRIVMSG #${testChannelName} :subgift --username ${testUsername}`) 79 | 80 | const rawMessage = connection.send.getCall(0).firstArg 81 | const { tags } = parseIRCMessage(rawMessage) 82 | 83 | expect(tags['display-name']).to.equal(testUsername) 84 | expect(tags.login).to.equal(testUsername) 85 | }) 86 | 87 | it('should set the recipient\'s username', () => { 88 | socket.emit('message', `PRIVMSG #${testChannelName} :subgift --username2 ${testUsername}`) 89 | 90 | const rawMessage = connection.send.getCall(0).firstArg 91 | const { tags } = parseIRCMessage(rawMessage) 92 | 93 | expect(tags['msg-param-recipient-display-name']).to.equal(testUsername) 94 | expect(tags['msg-param-recipient-user-name']).to.equal(testUsername) 95 | }) 96 | 97 | it('should set the recipient\'s username', () => { 98 | socket.emit('message', `PRIVMSG #foobar :subgift --channel ${testChannelName}`) 99 | 100 | const rawMessage = connection.send.getCall(0).firstArg 101 | const { params: [channelName] } = parseIRCMessage(rawMessage) 102 | 103 | expect(channelName).to.equal(`#${testChannelName}`) 104 | }) 105 | 106 | it('should handle multi-month gift subs', () => { 107 | socket.emit('message', `PRIVMSG #${testChannelName} :subgift --months ${testMultiMonthSubLength}`) 108 | 109 | const rawMessage = connection.send.getCall(0).firstArg 110 | const { tags } = parseIRCMessage(rawMessage) 111 | 112 | expect(tags['msg-params-gift-months']).to.equal(`${testMultiMonthSubLength}`) 113 | }) 114 | 115 | it('should set the subscription tenure', () => { 116 | socket.emit('message', `PRIVMSG #${testChannelName} :subgift --tenure ${testSubTenure}`) 117 | 118 | const rawMessage = connection.send.getCall(0).firstArg 119 | const { tags } = parseIRCMessage(rawMessage) 120 | 121 | expect(tags['msg-param-months']).to.equal(`${testSubTenure}`) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /test/routes/fdgt/v1/commands.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | expect, 4 | request, 5 | } from 'chai' 6 | import fs from 'fs' 7 | import Koa from 'koa' 8 | import path from 'path' 9 | 10 | 11 | 12 | 13 | 14 | // Local imports 15 | import API from 'structures/API' 16 | 17 | 18 | 19 | 20 | 21 | // Local constants 22 | const url = '/fdgt/v1/commands' 23 | 24 | 25 | 26 | 27 | 28 | describe(url, function () { 29 | this.slow(400) 30 | 31 | const commandsPath = path.resolve(process.cwd(), 'src', 'data-mocks') 32 | const commandFilenames = fs.readdirSync(commandsPath) 33 | const commands = commandFilenames.map(filename => filename.replace(/\.js$/, '')) 34 | let requester = null 35 | 36 | beforeEach(() => { 37 | requester = request(API.callback()).keepOpen() 38 | }) 39 | 40 | afterEach(() => { 41 | requester.close() 42 | }) 43 | 44 | it('should complete successfully', async () => { 45 | const response = await requester.get(url) 46 | 47 | expect(response).to.have.status(200) 48 | expect(response).to.be.json 49 | }) 50 | 51 | it('should return the list of commands', async () => { 52 | const response = await requester.get(url) 53 | 54 | expect(response.body.data).to.have.members(commands) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/routes/fdgt/v1/commands/[command].test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | expect, 4 | request, 5 | } from 'chai' 6 | import frontmatter from 'frontmatter' 7 | import fs from 'fs' 8 | import jsdoc2md from 'jsdoc-to-markdown' 9 | import Koa from 'koa' 10 | import path from 'path' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import API from 'structures/API' 18 | 19 | 20 | 21 | 22 | 23 | // Local constants 24 | const url = '/fdgt/v1/commands/:command' 25 | 26 | 27 | 28 | 29 | 30 | describe(url, function () { 31 | this.slow(400) 32 | 33 | const commandsPath = path.resolve(process.cwd(), 'src', 'data-mocks') 34 | const commandFilenames = fs.readdirSync(commandsPath) 35 | const commands = commandFilenames.map(filename => filename.replace(/\.js$/, '')) 36 | let requester = null 37 | 38 | beforeEach(() => { 39 | requester = request(API.callback()).keepOpen() 40 | }) 41 | 42 | afterEach(() => { 43 | requester.close() 44 | }) 45 | 46 | commands.forEach(command => { 47 | describe(command, () => { 48 | const commandURL = url.replace(/:command$/, command) 49 | const commandPath = path.resolve(commandsPath, `${command}.js`) 50 | const jsdocHelpersPath = path.resolve(process.cwd(), 'src', 'helpers', 'jsdocHelpers') 51 | const jsdocPartialsPath = path.resolve(process.cwd(), 'src', 'helpers', 'jsdocPartials') 52 | 53 | let doc = null 54 | let docData = null 55 | 56 | before(async () => { 57 | doc = await jsdoc2md.render({ 58 | files: commandPath, 59 | 'heading-depth': 1, 60 | helper: [ 61 | path.resolve(jsdocHelpersPath, 'firstLine.js'), 62 | ], 63 | partial: [ 64 | path.resolve(jsdocPartialsPath, 'docs.hbs'), 65 | path.resolve(jsdocPartialsPath, 'examples.hbs'), 66 | path.resolve(jsdocPartialsPath, 'params.hbs'), 67 | ], 68 | }) 69 | 70 | docData = frontmatter(doc) 71 | }) 72 | 73 | it('should complete successfully', async () => { 74 | const response = await requester.get(commandURL) 75 | 76 | expect(response).to.have.status(200) 77 | expect(response).to.be.json 78 | }) 79 | 80 | 81 | describe('frontmatter', () => { 82 | it('should have a title', async () => { 83 | const { body } = await requester.get(commandURL) 84 | const parsedFrontmatter = frontmatter(body.data) 85 | 86 | expect(parsedFrontmatter.data.title).to.equal(docData.data.title) 87 | }) 88 | 89 | it('should have a description', async () => { 90 | const { body } = await requester.get(commandURL) 91 | const parsedFrontmatter = frontmatter(body.data) 92 | 93 | expect(parsedFrontmatter.data.description).to.equal(docData.data.description) 94 | }) 95 | }) 96 | 97 | it('should return docs', async () => { 98 | const { body } = await requester.get(commandURL) 99 | 100 | expect(body.data).to.equal(doc) 101 | }) 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /test/routes/fdgt/v1/contributors.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | expect, 4 | request, 5 | } from 'chai' 6 | import { promisify } from 'util' 7 | import fs from 'fs' 8 | import Koa from 'koa' 9 | import nock from 'nock' 10 | import path from 'path' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import API from 'structures/API' 18 | 19 | 20 | 21 | 22 | 23 | // Local constants 24 | const allContributorsPath = path.resolve(process.cwd(), '.all-contributorsrc') 25 | const readFile = promisify(fs.readFile) 26 | const url = '/fdgt/v1/contributors' 27 | 28 | 29 | 30 | 31 | 32 | describe(url, function () { 33 | this.slow(400) 34 | 35 | const allContributorsFile = fs.readFileSync(allContributorsPath, 'utf8') 36 | const allContributors = JSON.parse(allContributorsFile) 37 | let requester = null 38 | let scope = null 39 | 40 | before(() => { 41 | scope = nock('https://api.github.com') 42 | .persist() 43 | .get(/\/users\/([\w-]+)\/?/) 44 | .reply(200, (uri, requestBody) => { 45 | const login = uri.replace(/\/users\/([\w-]+)\/?/, '$1') 46 | return { twitter_username: login } 47 | }) 48 | }) 49 | 50 | after(() => { 51 | scope.persist(false) 52 | }) 53 | 54 | beforeEach(() => { 55 | requester = request(API.callback()).keepOpen() 56 | }) 57 | 58 | afterEach(() => { 59 | requester.close() 60 | }) 61 | 62 | it('should complete successfully', async () => { 63 | const response = await requester.get(url) 64 | 65 | expect(response).to.have.status(200) 66 | expect(response).to.be.json 67 | }) 68 | 69 | it('should return the list of contributors', async () => { 70 | const { body } = await requester.get(url) 71 | 72 | expect(body.data).to.be.an('array') 73 | }) 74 | 75 | describe('contributors', () => { 76 | allContributors.contributors.forEach(contributor => { 77 | describe(contributor.name, () => { 78 | it('should be returned', async () => { 79 | const { body } = await requester.get(url) 80 | const matchedContributor = body.data.find(({ login }) => (login === contributor.login)) 81 | 82 | expect(matchedContributor).to.exist 83 | }) 84 | 85 | Object.entries(contributor).forEach(([key, value]) => { 86 | it(`should return ${key}`, async () => { 87 | const { body } = await requester.get(url) 88 | const matchedContributor = body.data.find(({ login }) => (login === contributor.login)) 89 | 90 | if (Array.isArray(value)) { 91 | expect(matchedContributor[key]).to.have.members(value) 92 | } else { 93 | expect(matchedContributor[key]).to.equal(value) 94 | } 95 | }) 96 | }) 97 | }) 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /test/routes/fdgt/v1/sponsors.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | expect, 4 | request, 5 | } from 'chai' 6 | import Koa from 'koa' 7 | import nock from 'nock' 8 | 9 | 10 | 11 | 12 | 13 | // Local imports 14 | import API from 'structures/API' 15 | 16 | 17 | 18 | 19 | 20 | // Local constants 21 | const url = '/fdgt/v1/sponsors' 22 | 23 | 24 | 25 | 26 | 27 | describe(url, function () { 28 | this.slow(400) 29 | 30 | let requester = null 31 | 32 | beforeEach(() => { 33 | nock('https://api.github.com') 34 | .post('/graphql') 35 | .reply(200, { 36 | data: { 37 | user: { 38 | sponsorsListing: { 39 | tiers: { 40 | nodes: [ 41 | { 42 | description: 'Beep', 43 | id: '0', 44 | monthlyPriceInDollars: 7, 45 | name: 'Non-publicized Tier', 46 | }, 47 | { 48 | description: 'Boop', 49 | id: '1', 50 | monthlyPriceInDollars: 7, 51 | name: 'Publicized Tier', 52 | }, 53 | ], 54 | }, 55 | }, 56 | sponsorshipsAsMaintainer: { 57 | nodes: [ 58 | { 59 | createdAt: '2020-07-02T18:05:27Z', 60 | privacyLevel: 'PUBLIC', 61 | sponsorEntity: { 62 | id: '0', 63 | }, 64 | tier: { 65 | description: 'Beep', 66 | id: '0', 67 | monthlyPriceInDollars: 7, 68 | name: 'Non-publicized Tier', 69 | }, 70 | }, 71 | ], 72 | }, 73 | }, 74 | }, 75 | }) 76 | requester = request(API.callback()).keepOpen() 77 | }) 78 | 79 | afterEach(() => { 80 | requester.close() 81 | }) 82 | 83 | it('should complete successfully', async () => { 84 | const response = await requester.get(url) 85 | 86 | expect(response).to.have.status(200) 87 | expect(response).to.be.json 88 | }) 89 | 90 | it('should return the list of contributors', async () => { 91 | const { body } = await requester.get(url) 92 | 93 | expect(body.data).to.be.an('object') 94 | }) 95 | 96 | it('should return the list of sponsorship tiers', async () => { 97 | const { body } = await requester.get(url) 98 | 99 | expect(body.included).to.be.an('array') 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import chai from 'chai' 3 | import chaiHTTP from 'chai-http' 4 | 5 | 6 | 7 | 8 | 9 | // Chai config 10 | chai.use(chaiHTTP) 11 | -------------------------------------------------------------------------------- /test/structures/API.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { expect } from 'chai' 3 | import Koa from 'koa' 4 | 5 | 6 | 7 | 8 | 9 | // Local imports 10 | import API from 'structures/API' 11 | 12 | 13 | 14 | 15 | 16 | describe('API', function() { 17 | it('should be a Koa app', () => { 18 | expect(API).to.be.instanceof(Koa) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/structures/Channel.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { parse as parseIRCMessage } from 'irc-message' 3 | import { spy } from 'sinon' 4 | import { expect } from 'chai' 5 | import EventEmitter from 'events' 6 | import validateUUID from 'uuid-validate' 7 | 8 | 9 | 10 | 11 | 12 | // Local imports 13 | import { createConnection } from '../test-helpers/createConnection' 14 | import Channel from 'structures/Channel' 15 | import Connection from 'structures/Connection' 16 | import User from 'structures/User' 17 | 18 | 19 | 20 | 21 | 22 | // Local constants 23 | const testErrorMessage = 'Error foo bar baz!' 24 | const testChannelName = 'TestChannel' 25 | 26 | 27 | 28 | 29 | 30 | describe('Channel', function() { 31 | const user = new User() 32 | let connection = null 33 | let channel = null 34 | let socket = null 35 | 36 | beforeEach(() => { 37 | connection = createConnection() 38 | socket = connection.socket 39 | channel = new Channel({ 40 | connection, 41 | name: testChannelName, 42 | }) 43 | spy(channel) 44 | spy(connection) 45 | spy(socket) 46 | }) 47 | 48 | afterEach(() => { 49 | connection.close() 50 | channel = null 51 | connection = null 52 | socket = null 53 | }) 54 | 55 | describe('before connecting', () => { 56 | describe('connection', () => { 57 | it('should be a Connection', () => { 58 | expect(channel.connection).to.be.instanceof(Connection) 59 | }) 60 | }) 61 | 62 | describe('emoteOnly', () => { 63 | it('should be off when the channel is first created', () => { 64 | expect(channel.emoteOnly).to.be.false 65 | }) 66 | }) 67 | 68 | describe('followersOnly', () => { 69 | it('should be off when the channel is first created', () => { 70 | expect(channel.followersOnly).to.be.false 71 | }) 72 | }) 73 | 74 | describe('hashName', () => { 75 | it('should match the channel name', () => { 76 | expect(channel.hashName).to.be.string(`#${testChannelName.toLowerCase()}`) 77 | }) 78 | }) 79 | 80 | describe('id', () => { 81 | it('should be a valid UUID', () => { 82 | expect(channel.id).to.satisfy(validateUUID) 83 | }) 84 | }) 85 | 86 | describe('isConnected', () => { 87 | it('should be false when the channel is first created', () => { 88 | expect(channel.isConnected).to.be.false 89 | }) 90 | }) 91 | 92 | describe('name', () => { 93 | it('should match the passed in channel name', () => { 94 | expect(channel.name).to.be.string(testChannelName.toLowerCase()) 95 | }) 96 | }) 97 | 98 | describe('subsOnly', () => { 99 | it('should be off when the channel is first created', () => { 100 | expect(channel.subsOnly).to.be.false 101 | }) 102 | }) 103 | }) 104 | 105 | describe('public methods', () => { 106 | describe('addUser', () => { 107 | it('should only be called once', () => { 108 | channel.addUser(user) 109 | expect(channel.addUser.calledOnce).to.be.true 110 | }) 111 | 112 | it('should add the passed user to the channel\'s user list', () => { 113 | channel.addUser(user) 114 | expect(channel.data).to.include(user) 115 | }) 116 | }) 117 | 118 | describe('connect', () => { 119 | it('should only be called once', () => { 120 | channel.connect() 121 | expect(channel.connect.calledOnce).to.be.true 122 | }) 123 | 124 | it('should change the channel\'s connected state', () => { 125 | channel.connect() 126 | expect(channel.isConnected).to.be.true 127 | }) 128 | 129 | it('should add the current user to the channel\'s user list', () => { 130 | channel.connect({ user }) 131 | expect(channel.addUser.calledOnce).to.be.true 132 | expect(channel.data).to.include(user) 133 | }) 134 | }) 135 | 136 | describe('disconnect', () => { 137 | beforeEach(() => { 138 | channel.connect({ user }) 139 | }) 140 | 141 | it('should only be called once', () => { 142 | channel.disconnect() 143 | expect(channel.disconnect.calledOnce).to.be.true 144 | }) 145 | 146 | it('should change the channel\'s connected state', () => { 147 | channel.disconnect() 148 | expect(channel.isConnected).to.be.false 149 | }) 150 | }) 151 | 152 | describe('removeUser', () => { 153 | beforeEach(() => { 154 | channel.addUser(user) 155 | }) 156 | 157 | it('should only be called once', () => { 158 | channel.removeUser(user) 159 | expect(channel.removeUser.calledOnce).to.be.true 160 | }) 161 | 162 | it('should remove the passed user from the channel\'s user list', () => { 163 | channel.removeUser(user) 164 | expect(channel.data).to.not.include(user) 165 | }) 166 | }) 167 | 168 | describe('sendErrorMessage', () => { 169 | it('should only be called once', () => { 170 | channel.sendErrorMessage(testErrorMessage) 171 | expect(channel.sendErrorMessage.calledOnce).to.be.true 172 | }) 173 | 174 | it('should send via the channel\'s Connection', () => { 175 | channel.sendErrorMessage(testErrorMessage) 176 | expect(socket.send.callCount).to.equal(1) 177 | }) 178 | 179 | it('should send the passed message', () => { 180 | channel.sendErrorMessage(testErrorMessage) 181 | 182 | const [rawMessage] = socket.send.getCall(0).args 183 | const { params: [channelName, ...[message]] } = parseIRCMessage(rawMessage) 184 | 185 | expect(channelName).to.be.string(`#${testChannelName.toLowerCase()}`) 186 | expect(message).to.be.string(testErrorMessage) 187 | }) 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /test/structures/ChannelList.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | import { expect } from 'chai' 4 | import faker from 'faker' 5 | 6 | 7 | 8 | 9 | 10 | // Local imports 11 | import Channel from 'structures/Channel' 12 | import ChannelList from 'structures/ChannelList' 13 | import dedupeArray from 'helpers/dedupeArray' 14 | 15 | 16 | 17 | 18 | 19 | // Local constants 20 | const testChannelName = 'TestChannel' 21 | 22 | 23 | 24 | 25 | 26 | describe('ChannelList', () => { 27 | const randomChannelNames = dedupeArray(Array(10).fill(null).map(() => faker.hacker.noun())) 28 | const randomChannels = randomChannelNames.map(channelName => new Channel({ name: channelName })) 29 | 30 | let channel = null 31 | let channelList = null 32 | 33 | beforeEach(() => { 34 | channel = new Channel({ name: testChannelName }) 35 | channelList = new ChannelList 36 | channelList.add(channel) 37 | randomChannels.forEach(randomChannel => channelList.add(randomChannel)) 38 | }) 39 | 40 | afterEach(() => { 41 | channelList.clear() 42 | channelList = null 43 | channel = null 44 | }) 45 | 46 | describe('findByName', () => { 47 | it('should return the correct channel', () => { 48 | expect(channelList.findByName(testChannelName.toLowerCase())).to.equal(channel) 49 | }) 50 | }) 51 | 52 | describe('channelNames', () => { 53 | it('should be a list of all channel names', () => { 54 | expect(channelList.channelNames).to.have.members(randomChannelNames.concat(testChannelName.toLowerCase())) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/structures/Collection.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | import { spy } from 'sinon' 4 | import { expect } from 'chai' 5 | 6 | 7 | 8 | 9 | 10 | // Local imports 11 | import Collection from 'structures/Collection' 12 | 13 | 14 | 15 | 16 | 17 | describe('Collection', function() { 18 | const createItem = () => ({ id: uuid() }) 19 | const randomItems = Array(10).fill(null).map(createItem) 20 | const fillCollection = () => randomItems.forEach(randomItem => collection.add(randomItem)) 21 | 22 | let collection = null 23 | 24 | beforeEach(() => { 25 | collection = new Collection 26 | spy(collection) 27 | }) 28 | 29 | afterEach(() => { 30 | collection.clear() 31 | collection = null 32 | }) 33 | 34 | describe('add', () => { 35 | let item = createItem() 36 | 37 | it('should only be called once', () => { 38 | collection.add(item) 39 | expect(collection.add.calledOnce).to.be.true 40 | }) 41 | 42 | it('should be add the passed item to the collection\'s data', () => { 43 | collection.add(item) 44 | expect(collection.data).to.include(item) 45 | }) 46 | }) 47 | 48 | describe('clear', () => { 49 | it('should only be called once', () => { 50 | collection.clear() 51 | expect(collection.clear.calledOnce).to.be.true 52 | }) 53 | 54 | it('should remove all items from the collection', () => { 55 | fillCollection() 56 | collection.clear() 57 | expect(collection.isEmpty).to.be.true 58 | }) 59 | }) 60 | 61 | describe('findByID', () => { 62 | let item = createItem() 63 | 64 | beforeEach(() => { 65 | fillCollection() 66 | collection.add(item) 67 | }) 68 | 69 | it('should only be called once', () => { 70 | collection.findByID(item.id) 71 | expect(collection.findByID.calledOnce).to.be.true 72 | }) 73 | 74 | it('should return the correct item', () => { 75 | expect(collection.findByID(item.id)).to.equal(item) 76 | }) 77 | }) 78 | 79 | describe('findByKey', () => { 80 | let item = createItem() 81 | 82 | beforeEach(() => { 83 | fillCollection() 84 | collection.add(item) 85 | }) 86 | 87 | it('should only be called once', () => { 88 | collection.findByKey('id', item.id) 89 | expect(collection.findByKey.calledOnce).to.be.true 90 | }) 91 | 92 | it('should be a Connection', () => { 93 | expect(collection.findByKey('id', item.id)).to.equal(item) 94 | }) 95 | }) 96 | 97 | describe('isEmpty', () => { 98 | it('should be true if nothing has been added to the collection', () => { 99 | expect(collection.isEmpty).to.be.true 100 | }) 101 | 102 | it('should be false if nothing has been added to the collection', () => { 103 | fillCollection() 104 | expect(collection.isEmpty).to.be.false 105 | }) 106 | }) 107 | 108 | describe('ids', () => { 109 | it('should be a list of IDs for all items in a collection', () => { 110 | fillCollection() 111 | expect(collection.ids).to.have.members(randomItems.map(({ id }) => id)) 112 | }) 113 | }) 114 | 115 | describe('remove', () => { 116 | let itemID = uuid() 117 | let item = { id: itemID } 118 | 119 | beforeEach(() => { 120 | fillCollection() 121 | collection.add(item) 122 | }) 123 | 124 | it('should only be called once', () => { 125 | collection.remove(item) 126 | expect(collection.remove.calledOnce).to.be.true 127 | }) 128 | 129 | it('should remove the passed item from the collection\'s data', () => { 130 | collection.remove(item) 131 | expect(collection.data).not.to.include(item) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /test/structures/Connection.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { 3 | spy, 4 | useFakeTimers, 5 | } from 'sinon' 6 | import { parse as parseIRCMessage } from 'irc-message' 7 | import { v4 as uuid } from 'uuid' 8 | import { expect } from 'chai' 9 | import EventEmitter from 'events' 10 | import faker from 'faker' 11 | 12 | 13 | 14 | 15 | 16 | // Local imports 17 | import { createConnection } from '../test-helpers/createConnection' 18 | import CAPABILITIES from 'data/CAPABILITIES' 19 | import Channel from 'structures/Channel' 20 | import User from 'structures/User' 21 | 22 | 23 | 24 | 25 | 26 | // Local constants 27 | const testChannelName = 'TestChannel' 28 | const testOauthToken = 'oauth:1234567890' 29 | const testUsername = 'Bob' 30 | 31 | 32 | 33 | 34 | 35 | describe('Connection', function() { 36 | const clock = useFakeTimers() 37 | 38 | let connection = null 39 | let socket = null 40 | 41 | beforeEach(() => { 42 | connection = createConnection() 43 | socket = connection.socket 44 | 45 | spy(connection) 46 | spy(socket) 47 | }) 48 | 49 | afterEach(() => { 50 | connection.close() 51 | connection = null 52 | socket = null 53 | }) 54 | 55 | describe('new Connection', () => { 56 | describe('close events', () => { 57 | it('should close when the socket emits a `close` event', () => { 58 | socket.emit('close') 59 | expect(connection.close.calledOnce).to.be.true 60 | }) 61 | 62 | it('should close when the socket emits an `end` event', () => { 63 | socket.emit('end') 64 | expect(connection.close.calledOnce).to.be.true 65 | }) 66 | 67 | it('should close when the socket emits an `error` event', () => { 68 | socket.emit('error') 69 | expect(connection.close.calledOnce).to.be.true 70 | }) 71 | }) 72 | 73 | describe('commands', () => { 74 | it('should send an unknown command for unrecognized events', () => { 75 | socket.emit('message', `:${testUsername}!${testUsername}@${testUsername}.tmi.twitch.tv PRIVMSG #${testChannelName.toLowerCase()} :foo`) 76 | const [[message]] = socket.send.args 77 | const { 78 | command, 79 | params: [ 80 | channelName, 81 | eventName, 82 | ], 83 | } = parseIRCMessage(message) 84 | expect(command).to.be.string('PRIVMSG') 85 | expect(channelName).to.be.string(`#${testChannelName.toLowerCase()}`) 86 | expect(eventName).to.be.string('foo') 87 | expect(socket.send.calledOnce).to.be.true 88 | }) 89 | 90 | describe('CAP', () => { 91 | ['LIST', 'LS'].forEach(subcommand => { 92 | describe(`${subcommand} subcommand`, () => { 93 | it('should list all available capabilities', () => { 94 | socket.emit('message', `CAP ${subcommand} 302`) 95 | 96 | const [[rawMessage]] = socket.send.args 97 | const { 98 | params: [ 99 | client, 100 | responseSubcommand, 101 | capabilitiesList 102 | ], 103 | } = parseIRCMessage(rawMessage) 104 | 105 | const capabilities = capabilitiesList.split(' ') 106 | 107 | expect(client).to.be.string('*') 108 | expect(responseSubcommand).to.be.string(subcommand) 109 | expect(capabilities).to.have.members(CAPABILITIES) 110 | }) 111 | }) 112 | }) 113 | 114 | describe('END subcommand', () => { 115 | it('should send the MOTD if the user\'s NICK and PASS have already been set', () => { 116 | socket.emit('message', `PASS ${testOauthToken}`) 117 | socket.emit('message', `NICK ${testUsername}`) 118 | socket.emit('message', 'CAP END') 119 | 120 | expect(connection.sendMOTD.calledOnce).to.be.true 121 | }) 122 | }) 123 | 124 | describe('REQ subcommand', () => { 125 | CAPABILITIES.forEach(capability => { 126 | it(`should acknowledge '${capability}' capability`, () => { 127 | socket.emit('message', `CAP REQ :${capability}`) 128 | 129 | const [[rawMessage]] = socket.send.args 130 | const { 131 | params: [ 132 | client, 133 | responseSubcommand, 134 | ...requestedCapabilities 135 | ], 136 | } = parseIRCMessage(rawMessage) 137 | 138 | expect(responseSubcommand).to.be.string('ACK') 139 | expect(requestedCapabilities).to.have.members([capability]) 140 | }) 141 | }) 142 | 143 | it('should acknowledge all capabilities', () => { 144 | socket.emit('message', `CAP REQ :${CAPABILITIES.join(' ')}`) 145 | 146 | const [[rawMessage]] = socket.send.args 147 | const { 148 | params: [ 149 | client, 150 | responseSubcommand, 151 | requestedCapabilitiesList 152 | ], 153 | } = parseIRCMessage(rawMessage) 154 | 155 | const requestedCapabilities = requestedCapabilitiesList.split(' ') 156 | 157 | expect(responseSubcommand).to.be.string('ACK') 158 | expect(requestedCapabilities).to.have.members(CAPABILITIES) 159 | }) 160 | 161 | it('should ignore unrecognized capabilities', () => { 162 | socket.emit('message', `CAP REQ :${CAPABILITIES.join(' ')} foobar`) 163 | 164 | const [[rawMessage]] = socket.send.args 165 | const { 166 | params: [ 167 | client, 168 | responseSubcommand, 169 | requestedCapabilitiesList 170 | ], 171 | } = parseIRCMessage(rawMessage) 172 | 173 | const requestedCapabilities = requestedCapabilitiesList.split(' ') 174 | 175 | expect(responseSubcommand).to.be.string('ACK') 176 | expect(requestedCapabilities).to.have.members(CAPABILITIES) 177 | expect(requestedCapabilities).to.not.have.members(['foobar']) 178 | }) 179 | }) 180 | }) 181 | 182 | describe('JOIN', () => { 183 | beforeEach(() => { 184 | socket.emit('message', `NICK ${testUsername}`) 185 | socket.emit('message', `PASS ${testOauthToken}`) 186 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 187 | socket.emit('message', 'CAP END') 188 | socket.emit('message', `JOIN #${testChannelName}`) 189 | }) 190 | 191 | it('should add user to the channel', () => { 192 | const channel = connection.channels.findByName(testChannelName) 193 | const user = connection.users.findByUsername(testUsername.toLowerCase()) 194 | 195 | expect(channel.findByUsername(testUsername.toLowerCase())).to.equal(user) 196 | }) 197 | 198 | it('should respond with a JOIN message', () => { 199 | const messages = socket.send.getCalls().map(({ args }) => parseIRCMessage(args[0])) 200 | const messageSent = messages.some(({ command }) => (command === 'JOIN')) 201 | 202 | expect(messageSent).to.be.true 203 | }) 204 | 205 | it('should respond with a NAMREPLY message', () => { 206 | const messages = socket.send.getCalls().map(({ args }) => parseIRCMessage(args[0])) 207 | const messageSent = messages.some(({ command }) => (command === '353')) 208 | 209 | expect(messageSent).to.be.true 210 | }) 211 | 212 | it('should respond with an ENDOFNAMES message', () => { 213 | const messages = socket.send.getCalls().map(({ args }) => parseIRCMessage(args[0])) 214 | const messageSent = messages.some(({ command }) => (command === '366')) 215 | 216 | expect(messageSent).to.be.true 217 | }) 218 | }) 219 | 220 | describe('NICK', () => { 221 | it('should set the username on the connection', () => { 222 | socket.emit('message', `NICK ${testUsername}`) 223 | expect(connection.username).to.be.string(testUsername) 224 | }) 225 | 226 | it('should send the MOTD if capabilities have been requested and the token has been set', () => { 227 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 228 | socket.emit('message', 'CAP END') 229 | socket.emit('message', `PASS ${testUsername}`) 230 | socket.emit('message', `NICK ${testUsername}`) 231 | 232 | expect(connection.sendMOTD.calledOnce).to.be.true 233 | }) 234 | }) 235 | 236 | describe('PART', () => { 237 | beforeEach(() => { 238 | socket.emit('message', `NICK ${testUsername}`) 239 | socket.emit('message', `PASS ${testOauthToken}`) 240 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 241 | socket.emit('message', 'CAP END') 242 | socket.emit('message', `JOIN #${testChannelName}`) 243 | }) 244 | 245 | it('should remove user from the channel', () => { 246 | const channel = connection.channels.findByName(testChannelName.toLowerCase()) 247 | const user = connection.users.findByUsername(testUsername.toLowerCase()) 248 | 249 | socket.emit('message', `PART #${testChannelName}`) 250 | 251 | expect(channel.findByUsername(testUsername.toLowerCase())).to.not.exist 252 | }) 253 | 254 | it('should respond with a PART message', () => { 255 | socket.emit('message', `PART #${testChannelName}`) 256 | 257 | const messages = socket.send.getCalls().map(({ args }) => parseIRCMessage(args[0])) 258 | const messageSent = messages.some(({ command }) => (command === 'PART')) 259 | 260 | expect(messageSent).to.be.true 261 | }) 262 | }) 263 | 264 | describe('PASS', () => { 265 | it('should set the token on the connection', () => { 266 | socket.emit('message', `PASS ${testOauthToken}`) 267 | expect(connection.token).to.be.string(testOauthToken) 268 | }) 269 | 270 | it('should send the MOTD if capabilities have been requested and the username has been set', () => { 271 | socket.emit('message', `CAP REQ ${CAPABILITIES.join(' ')}`) 272 | socket.emit('message', 'CAP END') 273 | socket.emit('message', `NICK ${testUsername}`) 274 | socket.emit('message', `PASS ${testUsername}`) 275 | 276 | expect(connection.sendMOTD.calledOnce).to.be.true 277 | }) 278 | }) 279 | 280 | describe('PING', () => { 281 | it('should send a PONG message', () => { 282 | socket.emit('message', 'PING') 283 | expect(connection.send.calledOnceWithExactly('PONG')).to.be.true 284 | }) 285 | }) 286 | 287 | describe('PING with args', () => { 288 | it('should send a PONG message', () => { 289 | socket.emit('message', 'PING custom arg') 290 | expect(connection.send.calledOnceWithExactly('PONG custom arg')).to.be.true 291 | }) 292 | }) 293 | 294 | xdescribe('PONG', () => { 295 | it('should clear the ping timeout', () => {}) 296 | }) 297 | 298 | // Handled by FDGTCommands.test.js 299 | xdescribe('PRIVMSG', () => {}) 300 | 301 | describe('QUIT', () => { 302 | it('should close the connection', () => { 303 | socket.emit('message', 'QUIT') 304 | expect(connection.close.calledOnce).to.be.true 305 | }) 306 | }) 307 | }) 308 | 309 | xdescribe('ping pong', () => {}) 310 | }) 311 | 312 | describe('addCapabilities', () => { 313 | it('should only be called once', () => { 314 | connection.addCapabilities(CAPABILITIES) 315 | expect(connection.addCapabilities.calledOnce).to.be.true 316 | }) 317 | 318 | it('should add capabilities to the connection', () => { 319 | connection.addCapabilities(CAPABILITIES) 320 | expect(connection.capabilities).to.have.members(CAPABILITIES) 321 | }) 322 | }) 323 | 324 | describe('close', () => { 325 | it('should only be called once', () => { 326 | connection.close() 327 | expect(connection.close.calledOnce).to.be.true 328 | }) 329 | 330 | xit('should stop the ping timer', async () => { 331 | expect(connection.pingIntervalID).to.exist 332 | connection.close() 333 | await clock.runAllAsync() 334 | expect(connection.pingIntervalID).to.be.null 335 | }) 336 | 337 | xit('should stop the pong timer', () => { 338 | connection.pongTimeoutID 339 | connection.close() 340 | expect(connection.close.calledOnce).to.be.true 341 | }) 342 | 343 | it('should close the socket', () => { 344 | connection.close() 345 | expect(socket.terminate.calledOnce).to.be.true 346 | }) 347 | 348 | it('should emit a `close` event', () => { 349 | connection.close() 350 | expect(connection.emit.calledOnceWithExactly('close')).to.be.true 351 | }) 352 | }) 353 | 354 | describe('getChannel', () => { 355 | it('should create a new channel and add it to the connection\'s channel list', () => { 356 | expect(connection.channels.isEmpty).to.be.true 357 | 358 | const channel = connection.getChannel(testChannelName) 359 | expect(channel).to.be.instanceof(Channel) 360 | expect(connection.channels.data).to.include(channel) 361 | }) 362 | 363 | it('should retrieve an existing channel from the connection\'s channel list', () => { 364 | const channel = new Channel({ name: testChannelName }) 365 | connection.channels.add(channel) 366 | expect(connection.channels.isEmpty).to.be.false 367 | 368 | const retrievedChannel = connection.getChannel(testChannelName.toLowerCase()) 369 | expect(retrievedChannel).to.equal(channel) 370 | }) 371 | }) 372 | 373 | describe('getUser', () => { 374 | it('should create a new user and add it to the connection\'s user list', () => { 375 | expect(connection.users.isEmpty).to.be.true 376 | 377 | const user = connection.getUser(testUsername) 378 | expect(user).to.be.instanceof(User) 379 | expect(connection.users.data).to.include(user) 380 | }) 381 | 382 | it('should retrieve an existing user from the connection\'s user list', () => { 383 | const user = new User({ username: testUsername }) 384 | connection.users.add(user) 385 | expect(connection.users.isEmpty).to.be.false 386 | 387 | const retrievedUser = connection.getUser(testUsername.toLowerCase()) 388 | expect(retrievedUser).to.equal(user) 389 | }) 390 | }) 391 | 392 | describe('send', () => { 393 | it('sends a single message', () => { 394 | const testMessage = faker.lorem.sentences() 395 | connection.send(testMessage) 396 | expect(socket.send.calledOnceWithExactly(testMessage)).to.be.true 397 | }) 398 | 399 | it('sends multiple messages', () => { 400 | const testMessages = Array(10).fill(null).map(() => faker.lorem.sentences()) 401 | connection.send(testMessages) 402 | 403 | testMessages.forEach((message, index) => { 404 | expect(socket.send.getCall(index).calledWithExactly(message)).to.be.true 405 | }) 406 | }) 407 | }) 408 | 409 | describe('sendMOTD', () => { 410 | beforeEach(() => { 411 | connection.sendMOTD() 412 | }) 413 | 414 | it('should send the "Message of the Day"', () => { 415 | expect(socket.send.callCount).to.be.at.least(7) 416 | }) 417 | 418 | it('should send a WELCOME message', () => { 419 | const sendCalls = socket.send.getCalls() 420 | const messageSent = sendCalls.some(({ args: [message] }) => { 421 | const { command } = parseIRCMessage(message) 422 | return (command === '001') 423 | }) 424 | 425 | expect(messageSent).to.be.true 426 | }) 427 | 428 | it('should send a YOURHOST message', () => { 429 | const sendCalls = socket.send.getCalls() 430 | const messageSent = sendCalls.some(({ args: [message] }) => { 431 | const { command } = parseIRCMessage(message) 432 | return (command === '002') 433 | }) 434 | 435 | expect(messageSent).to.be.true 436 | }) 437 | 438 | it('should send a CREATED message', () => { 439 | const sendCalls = socket.send.getCalls() 440 | const messageSent = sendCalls.some(({ args: [message] }) => { 441 | const { command } = parseIRCMessage(message) 442 | return (command === '003') 443 | }) 444 | 445 | expect(messageSent).to.be.true 446 | }) 447 | 448 | it('should send a MYINFO message', () => { 449 | const sendCalls = socket.send.getCalls() 450 | const messageSent = sendCalls.some(({ args: [message] }) => { 451 | const { command } = parseIRCMessage(message) 452 | return (command === '004') 453 | }) 454 | 455 | expect(messageSent).to.be.true 456 | }) 457 | 458 | it('should send a MOTDSTART message', () => { 459 | const sendCalls = socket.send.getCalls() 460 | const messageSent = sendCalls.some(({ args: [message] }) => { 461 | const { command } = parseIRCMessage(message) 462 | return (command === '375') 463 | }) 464 | 465 | expect(messageSent).to.be.true 466 | }) 467 | 468 | it('should send a MOTD message', () => { 469 | const sendCalls = socket.send.getCalls() 470 | const messageSent = sendCalls.some(({ args: [message] }) => { 471 | const { command } = parseIRCMessage(message) 472 | return (command === '372') 473 | }) 474 | 475 | expect(messageSent).to.be.true 476 | }) 477 | 478 | it('should send a MOTDEND message', () => { 479 | const sendCalls = socket.send.getCalls() 480 | const messageSent = sendCalls.some(({ args: [message] }) => { 481 | const { command } = parseIRCMessage(message) 482 | return (command === '376') 483 | }) 484 | 485 | expect(messageSent).to.be.true 486 | }) 487 | }) 488 | 489 | describe('sendPong', () => { 490 | beforeEach(() => { 491 | connection.sendPong() 492 | }) 493 | 494 | it('should only be called once', () => { 495 | expect(connection.send.calledOnce) 496 | }) 497 | 498 | it('should send a PONG message', () => { 499 | expect(connection.send.calledOnceWithExactly('PONG')).to.be.true 500 | }) 501 | }) 502 | 503 | describe('sendUnknownCommand', () => { 504 | it('should only be called once', () => { 505 | connection.sendUnknownCommand('foo') 506 | expect(connection.send.calledOnce).to.be.true 507 | }) 508 | 509 | it('should send an UNKNOWN command', () => { 510 | connection.sendUnknownCommand('foo') 511 | 512 | const [[message]] = connection.send.args 513 | const { command } = parseIRCMessage(message) 514 | 515 | expect(command).to.be.string('421') 516 | }) 517 | }) 518 | }) 519 | -------------------------------------------------------------------------------- /test/structures/Route.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { expect } from 'chai' 3 | 4 | 5 | 6 | 7 | 8 | // Local imports 9 | import Route from 'structures/Route' 10 | 11 | 12 | 13 | 14 | 15 | describe('Route', function() { 16 | const handler = async ctx => (ctx.body = 'boop') 17 | const route = '/beep' 18 | 19 | describe('new Route', () => { 20 | describe('required `options`', () => { 21 | it('should throw if missing `handler`', () => { 22 | expect(() => new Route({ route })).to.throw() 23 | }) 24 | 25 | it('should throw if missing `route`', () => { 26 | expect(() => new Route({ handler })).to.throw() 27 | }) 28 | }) 29 | 30 | it('should store its `options`', () => { 31 | const { options } = new Route({ 32 | handler, 33 | route, 34 | }) 35 | expect(options).to.have.own.property('handler', handler) 36 | expect(options).to.have.own.property('route', route) 37 | }) 38 | 39 | it('should use its default `options`', () => { 40 | const { options } = new Route({ 41 | handler, 42 | route, 43 | }) 44 | expect(options.methods).to.have.include('get') 45 | }) 46 | 47 | it('should handle a custom `methods` list', () => { 48 | const methods = ['delete', 'patch', 'post', 'put'] 49 | const { options } = new Route({ 50 | handler, 51 | methods, 52 | route, 53 | }) 54 | expect(options.methods).to.have.members(methods) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/structures/Router.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { expect } from 'chai' 3 | import KoaRouter from 'koa-router' 4 | 5 | 6 | 7 | 8 | 9 | // Local imports 10 | import Router from 'structures/Router' 11 | 12 | 13 | 14 | 15 | 16 | describe('Router', function() { 17 | it('should be a Koa Router', () => { 18 | expect(Router).to.be.instanceof(KoaRouter) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/structures/User.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | import { expect } from 'chai' 4 | import tinycolor from 'tinycolor2' 5 | import validateUUID from 'uuid-validate' 6 | 7 | 8 | 9 | 10 | 11 | // Local imports 12 | import User from 'structures/User' 13 | 14 | 15 | 16 | 17 | 18 | // Local constants 19 | const username = 'Bob' 20 | 21 | 22 | 23 | 24 | 25 | describe('User', () => { 26 | let user = null 27 | 28 | beforeEach(() => { 29 | user = new User({ username }) 30 | }) 31 | 32 | afterEach(() => { 33 | user = null 34 | }) 35 | 36 | describe('color', () => { 37 | it('should be a valid color', () => { 38 | const color = tinycolor(user.color) 39 | expect(color.isValid()).to.be.true 40 | }) 41 | }) 42 | 43 | describe('id', () => { 44 | it('should be a valid UUID', () => { 45 | expect(validateUUID(user.id)).to.be.true 46 | }) 47 | }) 48 | 49 | describe('displayName', () => { 50 | it('should be the correct display name', () => { 51 | expect(user.displayName).to.be.string(username) 52 | }) 53 | }) 54 | 55 | describe('username', () => { 56 | it('should be the correct username', () => { 57 | expect(user.username).to.be.string(username.toLowerCase()) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/structures/UserList.test.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import { v4 as uuid } from 'uuid' 3 | import { expect } from 'chai' 4 | import faker from 'faker' 5 | 6 | 7 | 8 | 9 | 10 | // Local imports 11 | import User from 'structures/User' 12 | import UserList from 'structures/UserList' 13 | import dedupeArray from 'helpers/dedupeArray' 14 | 15 | 16 | 17 | 18 | 19 | // Local constants 20 | const testUsername = faker.internet.userName() 21 | 22 | 23 | 24 | 25 | 26 | describe('UserList', () => { 27 | const randomDisplayNames = dedupeArray(Array(10).fill(null).map(() => faker.internet.userName())) 28 | const randomUsernames = randomDisplayNames.map(name => name.toLowerCase()) 29 | const randomUsers = randomDisplayNames.map(displayName => new User({ username: displayName })) 30 | 31 | let user = null 32 | let userList = null 33 | 34 | beforeEach(() => { 35 | user = new User({ username: testUsername }) 36 | userList = new UserList 37 | userList.add(user) 38 | randomUsers.forEach(randomUser => userList.add(randomUser)) 39 | }) 40 | 41 | afterEach(() => { 42 | userList.clear() 43 | userList = null 44 | user = null 45 | }) 46 | 47 | describe('findByUsername', () => { 48 | it('should return the correct user', () => { 49 | expect(userList.findByUsername(testUsername.toLowerCase())).to.equal(user) 50 | }) 51 | }) 52 | 53 | describe('usernames', () => { 54 | it('should be a list of all usernames', () => { 55 | expect(userList.usernames).to.have.members(randomUsernames.concat(testUsername.toLowerCase())) 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /test/test-helpers/createConnection.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import { createFDGTUser } from '../test-helpers/createFDGTUser' 3 | import { createWSSocket } from '../test-helpers/createWSSocket' 4 | import Connection from 'structures/Connection' 5 | 6 | 7 | 8 | 9 | 10 | export function createConnection (socket) { 11 | return new Connection({ 12 | fdgtUser: createFDGTUser(), 13 | headers: {}, 14 | query: {}, 15 | socket: createWSSocket(), 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /test/test-helpers/createFDGTUser.js: -------------------------------------------------------------------------------- 1 | // Local imports 2 | import User from 'structures/User' 3 | 4 | 5 | 6 | 7 | 8 | export function createFDGTUser () { 9 | return new User({ username: 'fdgt' }) 10 | } 11 | -------------------------------------------------------------------------------- /test/test-helpers/createIRCSocket.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import EventEmitter from 'events' 3 | 4 | 5 | 6 | 7 | 8 | export class IRCSocket extends EventEmitter { 9 | end = () => {} 10 | write = () => {} 11 | } 12 | 13 | export function createIRCSocket () { 14 | return new IRCSocket 15 | } 16 | -------------------------------------------------------------------------------- /test/test-helpers/createWSSocket.js: -------------------------------------------------------------------------------- 1 | // Module imports 2 | import EventEmitter from 'events' 3 | 4 | 5 | 6 | 7 | 8 | class WSSocket extends EventEmitter { 9 | send = () => {} 10 | terminate = () => {} 11 | } 12 | 13 | export function createWSSocket () { 14 | return new WSSocket 15 | } 16 | --------------------------------------------------------------------------------