├── .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": "
-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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
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 |
--------------------------------------------------------------------------------