├── index.js ├── test ├── utils.js ├── test.js ├── parser.js ├── samples.js └── socketstub.js ├── package.json ├── LICENSE ├── .circleci └── config.yml ├── .gitignore ├── lib ├── parser.js └── bot.js ├── README.md └── yarn.lock /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/bot') 2 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const TwitchBot = require('../index') 2 | 3 | module.exports = { 4 | CONFIG: { 5 | USERNAME: process.env.TWITCHBOT_USERNAME, 6 | OAUTH: process.env.TWITCHBOT_OAUTH, 7 | CHANNEL: process.env.TWITCHBOT_CHANNEL 8 | }, 9 | 10 | NON_MOD_CONFIG: { 11 | USERNAME: process.env.TWITCHBOT_USERNAME_NON_MOD, 12 | OAUTH: process.env.TWITCHBOT_OAUTH_NON_MOD, 13 | CHANNEL: process.env.TWITCHBOT_CHANNEL_NON_MOD 14 | }, 15 | 16 | createBotInstance({ username, oauth, channels }) { 17 | return new TwitchBot({ 18 | username: username || this.CONFIG.USERNAME, 19 | oauth: oauth || this.CONFIG.OAUTH, 20 | channels: channels || [this.CONFIG.CHANNEL] 21 | }) 22 | }, 23 | 24 | createNonModBotInstance({ username, oauth, channels }) { 25 | return new TwitchBot({ 26 | username: username || this.NON_MOD_CONFIG.USERNAME, 27 | oauth: oauth || this.NON_MOD_CONFIG.OAUTH, 28 | channels: channels || [this.CONFIG.CHANNEL] 29 | }) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitch-bot", 3 | "version": "1.3.5", 4 | "description": "Easily create chat bots for Twitch.tv", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test/parser.js test/test.js test/socketstub.js --timeout 5000", 8 | "teststub": "mocha test/socketstub.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/kritzware/twitch-bot.git" 13 | }, 14 | "keywords": [ 15 | "twitchbot", 16 | "twitch", 17 | "chatbot", 18 | "bot", 19 | "twitch chatbot", 20 | "twitch bot" 21 | ], 22 | "author": "kritzware ", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/kritzware/twitch-bot/issues" 26 | }, 27 | "homepage": "https://github.com/kritzware/twitch-bot#readme", 28 | "devDependencies": { 29 | "chai": "^4.1.0", 30 | "mocha": "^3.4.2", 31 | "should": "^13.1.3", 32 | "sinon": "^4.1.3" 33 | }, 34 | "dependencies": { 35 | "lodash": "^4.17.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 kritzware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:7.10 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: yarn install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: yarn test 38 | 39 | 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | rtest.js -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const expect = require('chai').expect 3 | 4 | const TwitchBot = require('../index') 5 | const samples = require('./samples') 6 | const utils = require('./utils') 7 | const socketstub = require('./socketstub') 8 | 9 | describe('TwitchBot()', () => { 10 | it('should create a new Bot instance', () => { 11 | const bot = utils.createBotInstance({username: 'Test', oauth: '123435', channels: ['twitch']}) 12 | expect(bot).to.be.an.instanceOf(TwitchBot) 13 | bot.close() 14 | }) 15 | it('should throw an error if missing required arguments', () => { 16 | try { 17 | const bot = new TwitchBot({}) 18 | } catch(err) { 19 | expect(err.message).to.equal('missing or invalid required arguments') 20 | } 21 | }) 22 | 23 | it('should throw an error if channels is not an array', () => { 24 | try { 25 | const bot = new TwitchBot({channels: '#channel'}) 26 | } catch(err) { 27 | expect(err.message).to.equal('missing or invalid required arguments') 28 | } 29 | }) 30 | 31 | it('should normalize the channel name', () => { 32 | const bot = utils.createBotInstance({ username: 'Test', oauth: '123435', channels: ['Channel'] }) 33 | const bot2 = utils.createBotInstance({ username: 'Test', oauth: '123435', channels: ['#ChanneL'] }) 34 | expect(bot.channels[0]).to.equal('#channel') 35 | expect(bot2.channels[0]).to.equal('#channel') 36 | bot.close() 37 | bot2.close() 38 | }) 39 | 40 | }) 41 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = { 4 | 5 | formatCHANNEL(channel){ 6 | channel = channel.toLowerCase() 7 | return channel.charAt(0) !== '#' ? '#' + channel : channel 8 | }, 9 | 10 | formatJOIN(event){ 11 | event = event.replace(/\r\n/g, '') 12 | return event.split('JOIN ')[1] 13 | }, 14 | 15 | formatPART(event){ 16 | event = event.replace(/\r\n/g, '') 17 | return event.split('PART ')[1] 18 | }, 19 | 20 | formatPRIVMSG(event) { 21 | const parsed = {} 22 | 23 | const msg_parts = event.split('PRIVMSG ')[1] 24 | let split_msg_parts = msg_parts.split(' :') 25 | const channel = split_msg_parts[0] 26 | 27 | if(split_msg_parts.length >= 2) { 28 | split_msg_parts.shift() 29 | } 30 | const message = split_msg_parts.join(' :').replace(/\r\n/g, '') 31 | 32 | let [tags,username] = event.split('PRIVMSG')[0].split(' :') 33 | parsed.username = username.split('!')[0] 34 | 35 | Object.assign(parsed,this.formatTAGS(tags)) 36 | parsed.mod = !!parsed.mod 37 | parsed.subscriber = !!parsed.subscriber 38 | parsed.turbo = !!parsed.turbo 39 | 40 | if(parsed.emote_only) parsed.emote_only = !!parsed.emote_only 41 | 42 | parsed.channel = channel 43 | parsed.message = message 44 | 45 | return parsed 46 | }, 47 | 48 | formatCLEARCHAT(event) { 49 | const parsed = {} 50 | 51 | const msg_parts = event.split('CLEARCHAT ')[1] 52 | let split_msg_parts = msg_parts.split(' :') 53 | 54 | const channel = split_msg_parts[0] 55 | const target_username = split_msg_parts[1] 56 | 57 | let [tags] = event.split('CLEARCHAT')[0].split(' :') 58 | Object.assign(parsed,this.formatTAGS(tags)) 59 | 60 | if(parsed.ban_reason) { 61 | parsed.ban_reason = parsed.ban_reason.replace(/\\s/g, ' ') 62 | } 63 | 64 | if(parsed.ban_duration) parsed.type = 'timeout' 65 | else parsed.type = 'ban' 66 | 67 | parsed.channel = channel 68 | if (target_username) { 69 | parsed.target_username = target_username.replace(/\r\n/g, '') 70 | } 71 | 72 | /* TODO: This needs a proper fix */ 73 | parsed.tmi_sent_ts = parseInt(parsed.tmi_sent_ts) 74 | 75 | return parsed 76 | }, 77 | 78 | formatTAGS(tagstring) { 79 | let tagObject = {} 80 | const tags =tagstring.replace(/\s/g,' ').split(';') 81 | 82 | tags.forEach(tag => { 83 | const split_tag = tag.split('=') 84 | const name = this.formatTagName(split_tag[0]) 85 | let val = this.formatTagVal(split_tag[1]) 86 | tagObject[name] = val 87 | }) 88 | 89 | if (tagObject.badges){ 90 | tagObject.badges = this.formatBADGES(tagObject.badges) 91 | } 92 | 93 | return tagObject 94 | }, 95 | 96 | formatBADGES(badges){ 97 | let badgesObj = {} 98 | if(badges) { 99 | badges = badges.split(',') 100 | 101 | badges.forEach(badge => { 102 | const split_badge = badge.split('/') 103 | badgesObj[split_badge[0]] = +split_badge[1] 104 | }) 105 | } 106 | return badgesObj 107 | }, 108 | 109 | 110 | formatUSERNOTICE(event){ 111 | const parsed = {} 112 | 113 | const msg_parts = event.split('USERNOTICE')[1] 114 | let split_msg_parts = msg_parts.split(' :') 115 | 116 | parsed.channel = split_msg_parts[0].trim() 117 | parsed.message = split_msg_parts[1] || null 118 | 119 | let tags = event.split('USERNOTICE')[0].split(':')[0].trim() 120 | 121 | Object.assign(parsed,this.formatTAGS(tags)) 122 | return parsed 123 | }, 124 | 125 | formatTagName(tag) { 126 | if(tag.includes('-')) { 127 | tag = tag.replace(/-/g, '_') 128 | } 129 | if(tag.includes('@')) { 130 | tag = tag.replace('@', '') 131 | } 132 | return tag.trim() 133 | }, 134 | 135 | formatTagVal(val) { 136 | if(!val) return null 137 | if(val.match(/^[0-9]+$/) !== null) { 138 | return +val 139 | } 140 | if (val.includes('\s')){ 141 | val = val.replace(/\\s/g, ' ') 142 | } 143 | return val.trim() 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /test/parser.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const expect = require('chai').expect 3 | 4 | const samples = require('./samples') 5 | const parser = require('../lib/parser') 6 | 7 | describe('parser', () => { 8 | 9 | describe('formatTagName', () => { 10 | it('should convert irc tag names to use underscores', () => { 11 | expect(parser.formatTagName('user-id')).to.equal('user_id') 12 | expect(parser.formatTagName('user-id-long-tag')).to.equal('user_id_long_tag') 13 | }) 14 | it('should removed @ symbols from tag names', () => { 15 | expect(parser.formatTagName('@test-tag')).to.equal('test_tag') 16 | }) 17 | }) 18 | 19 | describe('formatTagVal', () => { 20 | it('should convert irc tag values to the correct type', () => { 21 | expect(parser.formatTagVal('123')).to.equal(123) 22 | expect(parser.formatTagVal('string:-val')).to.equal('string:-val') 23 | expect(parser.formatTagVal('')).to.equal(null) 24 | }) 25 | }) 26 | 27 | describe('formatPRIVMSG()', () => { 28 | it('should format a PRIVMSG event', () => { 29 | const event = samples.PRIVMSG.raw 30 | const parsed = parser.formatPRIVMSG(event) 31 | expect(parsed).to.eql(samples.PRIVMSG.expected) 32 | }) 33 | }) 34 | 35 | describe('formatCLEARCHAT()', () => { 36 | it('should format a CLEARCHAT timeout event', () => { 37 | const event = samples.CLEARCHAT.timeout_raw 38 | const parsed = parser.formatCLEARCHAT(event) 39 | expect(parsed).to.eql(samples.CLEARCHAT.timeout_expected) 40 | }) 41 | it('should format a CLEARCHAT ban event', () => { 42 | const event = samples.CLEARCHAT.ban_raw 43 | const parsed = parser.formatCLEARCHAT(event) 44 | expect(parsed).to.eql(samples.CLEARCHAT.ban_expected) 45 | }) 46 | }) 47 | 48 | describe('formatBADGES()', () => { 49 | it('should format a badge-tag-string', () => { 50 | const event = samples.TAGSAMPLES.badges_raw 51 | const parsed = parser.formatBADGES(event) 52 | expect(parsed).to.eql(samples.TAGSAMPLES.badges_expected) 53 | }) 54 | }) 55 | 56 | describe('formatTAGS()', () => { 57 | it('should format a tag-string', () => { 58 | const event = samples.TAGSAMPLES.tags_raw 59 | const parsed = parser.formatTAGS(event) 60 | expect(parsed).to.eql(samples.TAGSAMPLES.tags_expected) 61 | }) 62 | }) 63 | 64 | describe('formatJOIN()', () => { 65 | it('should format a tag-string', () => { 66 | const event = ':!@.tmi.twitch.tv JOIN #testchannel'; 67 | const parsed = parser.formatJOIN(event) 68 | expect(parsed).to.eql('#testchannel') 69 | }) 70 | }) 71 | 72 | describe('formatPART()', () => { 73 | it('should format a tag-string', () => { 74 | const event = ':!@.tmi.twitch.tv PART #testchannel'; 75 | const parsed = parser.formatPART(event) 76 | expect(parsed).to.eql('#testchannel') 77 | }) 78 | }) 79 | 80 | describe('formatUSERNOTICE()', () => { 81 | it('should format a USERNOTICE subscription event', () => { 82 | const event = samples.USERNOTICE.subscription_raw 83 | const parsed = parser.formatUSERNOTICE(event) 84 | expect(parsed).to.eql(samples.USERNOTICE.subscription_expected) 85 | }) 86 | 87 | it('should format a USERNOTICE subscription event and set message null if no message given', () => { 88 | const event = samples.USERNOTICE.subscription_nomessage_raw 89 | const parsed = parser.formatUSERNOTICE(event) 90 | expect(parsed).to.eql(samples.USERNOTICE.subscription_nomessage_expected) 91 | }) 92 | }) 93 | 94 | describe('formatCHANNEL()', () => { 95 | it('should format a channel without hashtag and some uppercase letters to contain a hashtag and only lowercase afterwards', () => { 96 | const parsed = parser.formatCHANNEL('someChannEl') 97 | expect(parsed).to.eql('#somechannel') 98 | }) 99 | 100 | it('should format a channel with hashtag and some uppercase letters to contain a hashtag and only lowercase afterwards', () => { 101 | const parsed = parser.formatCHANNEL('#someChannEl') 102 | expect(parsed).to.eql('#somechannel') 103 | }) 104 | }) 105 | 106 | }) 107 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tls = require('tls') 4 | const assert = require('assert') 5 | const EventEmitter = require('events').EventEmitter 6 | 7 | const parser = require('./parser') 8 | 9 | const TwitchBot = class TwitchBot extends EventEmitter { 10 | 11 | constructor({ 12 | username, 13 | oauth, 14 | channels=[], 15 | port=443, 16 | silence=false 17 | }) { 18 | super() 19 | 20 | try { 21 | assert(username) 22 | assert(oauth) 23 | } catch(err) { 24 | throw new Error('missing or invalid required arguments') 25 | } 26 | 27 | this.username = username 28 | this.oauth = oauth 29 | this.channels = channels.map(channel => parser.formatCHANNEL(channel)) 30 | 31 | this.irc = new tls.TLSSocket() 32 | this.port = port 33 | this.silence = silence 34 | 35 | this._connect() 36 | } 37 | 38 | async _connect() { 39 | this.irc.connect({ 40 | host: 'irc.chat.twitch.tv', 41 | port: this.port 42 | }) 43 | this.irc.setEncoding('utf8') 44 | this.irc.once('connect', () => { 45 | this.afterConnect() 46 | }) 47 | 48 | } 49 | 50 | afterConnect(){ 51 | this.irc.on('error', err => this.emit('error', err)) 52 | this.listen() 53 | this.writeIrcMessage("PASS " + this.oauth) 54 | this.writeIrcMessage("NICK " + this.username) 55 | 56 | this.writeIrcMessage("CAP REQ :twitch.tv/tags") 57 | this.channels.forEach(c => this.join(c)) 58 | 59 | this.writeIrcMessage("CAP REQ :twitch.tv/membership") 60 | this.writeIrcMessage("CAP REQ :twitch.tv/commands") 61 | 62 | this.emit('connected') 63 | } 64 | 65 | // TODO: Make this parsing better 66 | listen() { 67 | this.irc.on('data', data => { 68 | this.checkForError(data) 69 | 70 | /* Twitch sends keep-alive PINGs, need to respond with PONGs */ 71 | if(data.includes('PING :tmi.twitch.tv')) { 72 | this.irc.write('PONG :tmi.twitch.tv\r\n') 73 | } 74 | 75 | if(data.includes('PRIVMSG')) { 76 | const chatter = parser.formatPRIVMSG(data) 77 | this.emit('message', chatter) 78 | } 79 | 80 | if(data.includes('CLEARCHAT')) { 81 | const event = parser.formatCLEARCHAT(data) 82 | if(event.type === 'timeout') this.emit('timeout', event) 83 | if(event.type === 'ban') this.emit('ban', event) 84 | } 85 | 86 | if(data.includes('USERNOTICE ')) { 87 | const event = parser.formatUSERNOTICE(data) 88 | if (['sub', 'resub'].includes(event.msg_id) ){ 89 | this.emit('subscription', event) 90 | } 91 | } 92 | 93 | // https://dev.twitch.tv/docs/irc#join-twitch-membership 94 | // TODO: Use code 353 for detecting channel JOIN 95 | if(data.includes(`@${this.username}.tmi.twitch.tv JOIN`)) { 96 | const channel = parser.formatJOIN(data) 97 | if(channel) { 98 | if(!this.channels.includes(channel)) { 99 | this.channels.push(channel) 100 | } 101 | this.emit('join', channel) 102 | } 103 | } 104 | 105 | if(data.includes(`@${this.username}.tmi.twitch.tv PART`)) { 106 | const channel = parser.formatPART(data) 107 | if(channel) { 108 | if(this.channels.includes(channel)) { 109 | this.channels.pop(channel) 110 | } 111 | this.emit('part', channel) 112 | } 113 | } 114 | }) 115 | } 116 | 117 | checkForError(event) { 118 | /* Login Authentication Failed */ 119 | if(event.includes('Login authentication failed')) { 120 | this.irc.emit('error', { 121 | message: 'Login authentication failed' 122 | }) 123 | } 124 | /* Auth formatting */ 125 | if(event.includes('Improperly formatted auth')) { 126 | this.irc.emit('error', { 127 | message: 'Improperly formatted auth' 128 | }) 129 | } 130 | /* Notice about blocked messages */ 131 | if(event.includes('Your message was not sent because you are sending messages too quickly')) { 132 | this.irc.emit('error', { 133 | message: 'Your message was not sent because you are sending messages too quickly' 134 | }) 135 | } 136 | } 137 | 138 | writeIrcMessage(text) { 139 | this.irc.write(text + "\r\n") 140 | } 141 | 142 | join(channel) { 143 | channel = parser.formatCHANNEL(channel) 144 | this.writeIrcMessage(`JOIN ${channel}`) 145 | } 146 | 147 | part(channel) { 148 | if(!channel && this.channels.length > 0) { 149 | channel = this.channels[0] 150 | } 151 | channel = parser.formatCHANNEL(channel) 152 | this.writeIrcMessage(`PART ${channel}`) 153 | } 154 | 155 | say(message, channel, callback ) { 156 | if(!channel) { 157 | channel = this.channels[0] 158 | } 159 | if(message.length >= 500) { 160 | this.cb(callback, { 161 | sent: false, 162 | message: 'Exceeded PRIVMSG character limit (500)' 163 | }) 164 | } else { 165 | this.writeIrcMessage('PRIVMSG ' + channel + ' :' + message) 166 | } 167 | } 168 | 169 | timeout(username, channel, duration=600, reason='') { 170 | if(!channel) { 171 | channel = this.channels[0] 172 | } 173 | this.say(`/timeout ${username} ${duration} ${reason}`, channel) 174 | } 175 | 176 | ban(username, channel, reason='') { 177 | if(!channel) { 178 | channel = this.channels[0] 179 | } 180 | this.say(`/ban ${username} ${reason}`, channel) 181 | } 182 | 183 | close() { 184 | this.irc.destroy() 185 | this.emit('close') 186 | } 187 | 188 | cb(callback, obj) { 189 | if(callback) { 190 | obj.ts = new Date() 191 | callback(obj) 192 | } 193 | } 194 | 195 | } 196 | 197 | module.exports = TwitchBot 198 | -------------------------------------------------------------------------------- /test/samples.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | PRIVMSG: { 4 | raw: `@badges=subscriber/0,turbo/1;color=#000000;display-name=l1nk3n_; emotes=266588:0-8;id=7ecde2f1-a171-4354-80c7-36ffdf358e77;mod=0;room-id=23161357;sent-ts=1501441092245;subscriber=1;tmi-sent-ts=1501441089643;turbo=1;user-id=120412737;user-type= :l1nk3n_!l1nk3n_@l1nk3n_.tmi.twitch.tv PRIVMSG #lirik :lirikPRAY : PogChamp\r\n`, 5 | expected: { 6 | color: '#000000', 7 | display_name: 'l1nk3n_', 8 | emotes: '266588:0-8', 9 | id: '7ecde2f1-a171-4354-80c7-36ffdf358e77', 10 | mod: false, 11 | room_id: 23161357, 12 | sent_ts: 1501441092245, 13 | subscriber: true, 14 | tmi_sent_ts: 1501441089643, 15 | turbo: true, 16 | user_id: 120412737, 17 | user_type: null, 18 | badges: { subscriber: 0, turbo: 1 }, 19 | channel: '#lirik', 20 | message: 'lirikPRAY : PogChamp', 21 | username: 'l1nk3n_' 22 | }, 23 | long: 'PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp PogChamp' 24 | }, 25 | 26 | CLEARCHAT: { 27 | timeout_raw: `@ban-duration=10;ban-reason=This\\sis\\sthe\\sreason\\smessage\\sKappa;room-id=44667418;target-user-id=37798112;tmi-sent-ts=1503346029068 :tmi.twitch.tv CLEARCHAT #kritzware :blarev`, 28 | timeout_expected: { 29 | ban_duration: 10, 30 | ban_reason: 'This is the reason message Kappa', 31 | room_id: 44667418, 32 | target_user_id: 37798112, 33 | tmi_sent_ts: 1503346029068, 34 | type: 'timeout', 35 | channel: '#kritzware', 36 | target_username: 'blarev' 37 | }, 38 | ban_raw: `@ban-reason=This\\sis\\sthe\\sreason\\smessage;room-id=44667418;target-user-id=37798112;tmi-sent-ts=1503346078025 :tmi.twitch.tv CLEARCHAT #kritzware :blarev`, 39 | ban_expected: { 40 | ban_reason: 'This is the reason message', 41 | room_id: 44667418, 42 | target_user_id: 37798112, 43 | tmi_sent_ts: 1503346078025, 44 | type: 'ban', 45 | channel: '#kritzware', 46 | target_username: 'blarev' 47 | } 48 | }, 49 | 50 | USERNOTICE: { 51 | subscription_raw: `@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-months=6;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=1337;subscriber=1;system-msg=ronni\\shas\\ssubscribed\\sfor\\s6\\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=1337;user-type=staff :tmi.twitch.tv USERNOTICE #dallas :Great stream -- keep it up!`, 52 | subscription_expected: { 53 | "badges": { 54 | "broadcaster": 1, 55 | "staff": 1, 56 | "turbo": 1 57 | }, 58 | "channel": "#dallas", 59 | "color": "#008000", 60 | "display_name": "ronni", 61 | "emotes": null, 62 | "id": "db25007f-7a18-43eb-9379-80131e44d633", 63 | "login": "ronni", 64 | "message": "Great stream -- keep it up!", 65 | "mod": 0, 66 | "msg_id": "resub", 67 | "msg_param_months": 6, 68 | "msg_param_sub_plan": "Prime", 69 | "msg_param_sub_plan_name": "Prime", 70 | "room_id": 1337, 71 | "subscriber": 1, 72 | "system_msg": "ronni has subscribed for 6 months!", 73 | "tmi_sent_ts": 1507246572675, 74 | "turbo": 1, 75 | "user_id": 1337, 76 | "user_type": "staff" 77 | }, 78 | subscription_nomessage_raw: `@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-months=6;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=1337;subscriber=1;system-msg=ronni\\shas\\ssubscribed\\sfor\\s6\\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=1337;user-type=staff :tmi.twitch.tv USERNOTICE #dallas :`, 79 | subscription_nomessage_expected: { 80 | "badges": { 81 | "broadcaster": 1, 82 | "staff": 1, 83 | "turbo": 1 84 | }, 85 | "channel": "#dallas", 86 | "color": "#008000", 87 | "display_name": "ronni", 88 | "emotes": null, 89 | "id": "db25007f-7a18-43eb-9379-80131e44d633", 90 | "login": "ronni", 91 | "message": null, 92 | "mod": 0, 93 | "msg_id": "resub", 94 | "msg_param_months": 6, 95 | "msg_param_sub_plan": "Prime", 96 | "msg_param_sub_plan_name": "Prime", 97 | "room_id": 1337, 98 | "subscriber": 1, 99 | "system_msg": "ronni has subscribed for 6 months!", 100 | "tmi_sent_ts": 1507246572675, 101 | "turbo": 1, 102 | "user_id": 1337, 103 | "user_type": "staff" 104 | } 105 | 106 | }, 107 | 108 | TAGSAMPLES: { 109 | badges_raw: 'staff/1,broadcaster/1,turbo/1', 110 | badges_expected: { 111 | staff: 1, 112 | broadcaster: 1, 113 | turbo: 1 114 | }, 115 | 116 | tags_raw:`@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-months=6;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=1337;subscriber=1;system-msg=ronni\\shas\\ssubscribed\\sfor\\s6\\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=1337;user-type=staff`, 117 | tags_expected: { 118 | "badges": { 119 | "broadcaster": 1, 120 | "staff": 1, 121 | "turbo": 1 122 | }, 123 | "color": "#008000", 124 | "display_name": "ronni", 125 | "emotes": null, 126 | "id": "db25007f-7a18-43eb-9379-80131e44d633", 127 | "login": "ronni", 128 | "mod": 0, 129 | "msg_id": "resub", 130 | "msg_param_months": 6, 131 | "msg_param_sub_plan": "Prime", 132 | "msg_param_sub_plan_name": "Prime", 133 | "room_id": 1337, 134 | "subscriber": 1, 135 | "system_msg": "ronni has subscribed for 6 months!", 136 | "tmi_sent_ts": 1507246572675, 137 | "turbo": 1, 138 | "user_id": 1337, 139 | "user_type": "staff" 140 | } 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /test/socketstub.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | 3 | const TwitchBot = require('../index'); 4 | const expect = require('chai').expect; 5 | 6 | var connectStub = sinon.stub(TwitchBot.prototype, '_connect') 7 | var myBot = null; 8 | var writeStub = null; 9 | const samples= require('./samples'); 10 | 11 | const USERNAME = 'test' 12 | 13 | beforeEach((done)=>{ 14 | myBot = new TwitchBot({ 15 | username: USERNAME, 16 | oauth: 'oauth:123abc' 17 | }) 18 | 19 | writeStub = sinon.stub(myBot.irc, 'write') 20 | connectStub.callsFake(function(){ 21 | this.emit('connect'); 22 | }) 23 | 24 | myBot.afterConnect(); 25 | done(); 26 | }); 27 | 28 | describe('emulated IO tests', function() { 29 | 30 | it ("should handle error if invalid auth", function(done) { 31 | 32 | myBot.on('error', (err) => { 33 | expect(err.message).to.equal('Login authentication failed'); 34 | done(); 35 | }) 36 | myBot.irc.emit("data","Login authentication failed\r\n"); 37 | 38 | }); 39 | 40 | it ("should handle error if improperly formatted auth", function(done) { 41 | 42 | myBot.on('error', (err) => { 43 | expect(err.message).to.equal('Improperly formatted auth'); 44 | done(); 45 | }) 46 | myBot.irc.emit("data","Improperly formatted auth\r\n"); 47 | 48 | }); 49 | 50 | it ("should handle a channel message", function(done) { 51 | 52 | myBot.on('message', (chatter) => { 53 | expect(chatter).to.eql(samples.PRIVMSG.expected) 54 | done(); 55 | }) 56 | myBot.irc.emit("data",samples.PRIVMSG.raw) 57 | 58 | }); 59 | 60 | it ("should handle a subscription message", function(done) { 61 | 62 | myBot.on('subscription', (chatter) => { 63 | expect(chatter).to.eql(samples.USERNOTICE.subscription_expected) 64 | done(); 65 | }) 66 | myBot.irc.emit("data",samples.USERNOTICE.subscription_raw) 67 | 68 | }); 69 | 70 | it ("should handle a timeout message", function(done) { 71 | 72 | myBot.on('timeout', (chatter) => { 73 | expect(chatter).to.eql(samples.CLEARCHAT.timeout_expected); 74 | done(); 75 | }) 76 | myBot.irc.emit("data",samples.CLEARCHAT.timeout_raw); 77 | 78 | }); 79 | 80 | it ("should handle a ban message", function(done) { 81 | 82 | myBot.on('ban', (chatter) => { 83 | expect(chatter).to.eql(samples.CLEARCHAT.ban_expected); 84 | done(); 85 | }) 86 | myBot.irc.emit("data",samples.CLEARCHAT.ban_raw); 87 | 88 | }); 89 | 90 | it ("should handle a self-channel-join message", function(done) { 91 | const JOIN_MESSAGE = `:${USERNAME}!${USERNAME}@${USERNAME}.tmi.twitch.tv JOIN #testchannel` 92 | 93 | myBot.on('join', channel => { 94 | expect(channel).to.eql("#testchannel") 95 | expect(myBot.channels).to.eql(["#testchannel"]) 96 | done() 97 | }) 98 | myBot.irc.emit("data", JOIN_MESSAGE) 99 | }) 100 | 101 | it ("should handle a self-channel-join message with \\r\\n", function(done) { 102 | const JOIN_MESSAGE = `:${USERNAME}!${USERNAME}@${USERNAME}.tmi.twitch.tv JOIN #testchannel\r\n` 103 | 104 | myBot.on('join', (chatter) => { 105 | expect(chatter).to.eql("#testchannel") 106 | expect(myBot.channels).to.eql(["#testchannel"]) 107 | done() 108 | }) 109 | myBot.irc.emit("data", JOIN_MESSAGE) 110 | }) 111 | 112 | it ("should handle a self-channel-part message", function(done) { 113 | const PART_MESSAGE = `:${USERNAME}!${USERNAME}@${USERNAME}.tmi.twitch.tv PART #testchannel` 114 | 115 | myBot.on('part', (chatter) => { 116 | expect(chatter).to.eql("#testchannel") 117 | expect(myBot.channels).to.eql([]) 118 | done() 119 | }) 120 | myBot.channels = ["#testchannel"] 121 | myBot.irc.emit("data", PART_MESSAGE) 122 | }) 123 | 124 | it ("should handle a self-channel-part message with \\r\\n", function(done) { 125 | const PART_MESSAGE = `:${USERNAME}!${USERNAME}@${USERNAME}.tmi.twitch.tv PART #testchannel\r\n` 126 | 127 | myBot.on('part', (chatter) => { 128 | expect(chatter).to.eql("#testchannel") 129 | expect(myBot.channels).to.eql([]) 130 | done() 131 | }) 132 | myBot.channels = ["#testchannel"] 133 | myBot.irc.emit("data", PART_MESSAGE) 134 | }) 135 | 136 | it ("should reply to a server ping", function(done) { 137 | 138 | writeStub.callsFake(function (data, encoding, cb) { 139 | let received=writeStub.args[writeStub.callCount - 1][0]; 140 | expect(received).to.eql('PONG :tmi.twitch.tv\r\n'); 141 | done(); 142 | }); 143 | myBot.irc.emit("data",'PING :tmi.twitch.tv'); 144 | 145 | }); 146 | 147 | }) 148 | 149 | describe('say()', () => { 150 | 151 | it('should send a message in the channel', done => { 152 | writeStub.callsFake(function (data, encoding, cb) { 153 | let received=writeStub.args[writeStub.callCount - 1][0]; 154 | expect(received).to.eql(`PRIVMSG ${myBot.channels[0]} :testmessage\r\n`); 155 | done(); 156 | }); 157 | myBot.say('testmessage',myBot.channels[0]); 158 | 159 | 160 | 161 | }) 162 | it('should fail when the message to send is over 500 characters', done => { 163 | myBot.say(samples.PRIVMSG.long, myBot.channels[0], err => { 164 | expect(err.sent).to.equal(false) 165 | expect(err.message).to.equal('Exceeded PRIVMSG character limit (500)') 166 | done() 167 | }) 168 | }) 169 | }) 170 | 171 | describe('join()', () => { 172 | 173 | it('should send properly formatted message to join a channel without a leading hashtag', done => { 174 | writeStub.callsFake(function (data, encoding, cb) { 175 | let received=writeStub.args[writeStub.callCount - 1][0]; 176 | expect(received).to.eql(`JOIN #testchannel\r\n`); 177 | done(); 178 | }); 179 | myBot.join('testchannel'); 180 | 181 | }) 182 | 183 | it('should send properly formatted message to join a channel with a leading hashtag', done => { 184 | writeStub.callsFake(function (data, encoding, cb) { 185 | let received=writeStub.args[writeStub.callCount - 1][0]; 186 | expect(received).to.eql(`JOIN #testchannel\r\n`); 187 | done(); 188 | }); 189 | myBot.join('#testchannel'); 190 | 191 | }) 192 | }) 193 | 194 | describe('part()', () => { 195 | 196 | it('should send properly formatted message to part from a channel without a leading hashtag', done => { 197 | writeStub.callsFake(function (data, encoding, cb) { 198 | let received=writeStub.args[writeStub.callCount - 1][0]; 199 | expect(received).to.eql(`PART #testchannel\r\n`); 200 | done(); 201 | }); 202 | myBot.part('testchannel'); 203 | 204 | }) 205 | 206 | it('should send properly formatted message to part from a channel with a leading hashtag', done => { 207 | writeStub.callsFake(function (data, encoding, cb) { 208 | let received=writeStub.args[writeStub.callCount - 1][0]; 209 | expect(received).to.eql(`PART #testchannel\r\n`); 210 | done(); 211 | }); 212 | myBot.part('#testchannel'); 213 | 214 | }) 215 | }) 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitch-bot [![CircleCI](https://circleci.com/gh/kritzware/twitch-bot.svg?style=svg&circle-token=3d338af28058e84dde13bee88751a50f55aefab3)](https://circleci.com/gh/kritzware/twitch-bot) 2 | Easily create chat bots for Twitch.tv 3 | 4 | ## Install 5 | Install via NPM 6 | ``` 7 | $ npm install twitch-bot 8 | ``` 9 | 10 | ## Example 11 | ```javascript 12 | const TwitchBot = require('twitch-bot') 13 | 14 | const Bot = new TwitchBot({ 15 | username: 'Kappa_Bot', 16 | oauth: 'oauth:dwiaj91j1KKona9j9d1420', 17 | channels: ['twitch'] 18 | }) 19 | 20 | Bot.on('join', channel => { 21 | console.log(`Joined channel: ${channel}`) 22 | }) 23 | 24 | Bot.on('error', err => { 25 | console.log(err) 26 | }) 27 | 28 | Bot.on('message', chatter => { 29 | if(chatter.message === '!test') { 30 | Bot.say('Command executed! PogChamp') 31 | } 32 | }) 33 | 34 | ``` 35 | 36 | ## Index 37 | - [Events](https://github.com/kritzware/twitch-bot#events) 38 | - [`connected`](https://github.com/kritzware/twitch-bot#connected---) 39 | - [`join`](https://github.com/kritzware/twitch-bot#join---) 40 | - [`part`](https://github.com/kritzware/twitch-bot#part---) 41 | - [`message`](https://github.com/kritzware/twitch-bot#message---chatter-object) 42 | - [`timeout`](https://github.com/kritzware/twitch-bot#timeout---event-object) 43 | - [`subscription`](https://github.com/kritzware/twitch-bot#subscription---event-object) 44 | - [`ban`](https://github.com/kritzware/twitch-bot#ban---event-object) 45 | - [`error`](https://github.com/kritzware/twitch-bot#error---err-object) 46 | - [`close`](https://github.com/kritzware/twitch-bot#close---) 47 | - [Methods](https://github.com/kritzware/twitch-bot#methods) 48 | - [`join()`](https://github.com/kritzware/twitch-bot#join-channelname--string)) 49 | - [`part()`](https://github.com/kritzware/twitch-bot#part-channelname--string) 50 | - [`say()`](https://github.com/kritzware/twitch-bot#saymessage-string-err-callback) 51 | - [`timeout()`](https://github.com/kritzware/twitch-bot#timeoutusername-string-duration-int-reason-string) 52 | - [`ban()`](https://github.com/kritzware/twitch-bot#banusername-string-reason-string) 53 | - [`close()`](https://github.com/kritzware/twitch-bot#close) 54 | - [Tests](https://github.com/kritzware/twitch-bot#running-tests) 55 | 56 | ## Events 57 | ### `connected - ()` 58 | This event is emitted when the bot has connected to the IRC server. 59 | #### Usage 60 | ```javascript 61 | Bot.on('connected', () => ... ) 62 | ``` 63 | 64 | ### `join - ()` 65 | This event is emitted when a channel has been joined successfully. 66 | #### Usage 67 | ```javascript 68 | Bot.on('join', channel => ... ) 69 | ``` 70 | 71 | ### `part - ()` 72 | This event is emitted when a channel has been left successfully. 73 | #### Usage 74 | ```javascript 75 | Bot.on('part', channel => ... ) 76 | ``` 77 | 78 | 79 | ### `message - (chatter: Object)` 80 | Emitted when a `PRIVSMSG` event is sent over IRC. Chatter object attributes can be found on the [Twitch developers site](https://dev.twitch.tv/docs/v5/guides/irc/#privmsg-twitch-tags) 81 | 82 | #### Usage 83 | ```javascript 84 | Bot.on('message', chatter => ... ) 85 | ``` 86 | 87 | #### Example Response 88 | ```javascript 89 | { color: '#3C78FD', 90 | display_name: 'kritzware', 91 | emotes: '88:18-25', 92 | id: 'c5ee7248-3cea-43f5-ae44-2916d9a1325a', 93 | mod: true, 94 | room_id: 44667418, 95 | sent_ts: 1501541672959, 96 | subscriber: true, 97 | tmi_sent_ts: 1501541673368, 98 | turbo: false, 99 | user_id: 44667418, 100 | user_type: 'mod', 101 | badges: { broadcaster: 1, subscriber: 0 }, 102 | channel: '#kritzware', 103 | message: 'This is a message PogChamp', 104 | username: 'Kritzware' } 105 | ``` 106 | 107 | ### `timeout - (event: Object)` 108 | Emitted when a user is timed out in the chat. The `ban_reason` attribute is `null` when no reason message is used. 109 | 110 | #### Chat Trigger 111 | ```javascript 112 | kritzware: "/timeout {user} {duration} {reason}" 113 | ``` 114 | 115 | #### Usage 116 | ```javascript 117 | Bot.on('timeout', event => ... ) 118 | ``` 119 | 120 | #### Example Response 121 | ```javascript 122 | { ban_duration: 10, // seconds 123 | ban_reason: 'Using a banned word', 124 | room_id: 44667418, 125 | target_user_id: 37798112, 126 | tmi_sent_ts: 1503346029068, 127 | type: 'timeout', 128 | channel: '#kritzware', 129 | target_username: 'blarev' } 130 | ``` 131 | 132 | ### `subscription - (event: Object)` 133 | Emitted when a user subscribes to a channel and chooses to share the subscription in chat. 134 | 135 | #### Usage 136 | ```javascript 137 | Bot.on('subscription', event => ... ) 138 | ``` 139 | 140 | #### Example Response 141 | ```javascript 142 | { 143 | "badges": { 144 | "broadcaster": 1, 145 | "staff": 1, 146 | "turbo": 1 147 | }, 148 | "channel": "#dallas", 149 | "color": "#008000", 150 | "display_name": "ronni", 151 | "emotes": null, 152 | "id": "db25007f-7a18-43eb-9379-80131e44d633", 153 | "login": "ronni", 154 | "message": "Great stream -- keep it up!", // null if no message given 155 | "mod": 0, 156 | "msg_id": "resub", 157 | "msg_param_months": 6, 158 | "msg_param_sub_plan": "Prime", 159 | "msg_param_sub_plan_name": "Prime", 160 | "room_id": 1337, 161 | "subscriber": 1, 162 | "system_msg": "ronni has subscribed for 6 months!", 163 | "tmi_sent_ts": 1507246572675, 164 | "turbo": 1, 165 | "user_id": 1337, 166 | "user_type": "staff" 167 | } 168 | ``` 169 | 170 | ### `ban - (event: Object)` 171 | Emitted when a user is permanently banned from the chat. The `ban_reason` attribute is `null` when no reason message is used. 172 | 173 | #### Usage 174 | ```javascript 175 | Bot.on('ban', event => ... ) 176 | ``` 177 | 178 | #### Chat Trigger 179 | ```javascript 180 | kritzware: "/ban {user} {reason}" 181 | ``` 182 | 183 | #### Example Response 184 | ```javascript 185 | { ban_reason: 'Using a banned word', 186 | room_id: 44667418, 187 | target_user_id: 37798112, 188 | tmi_sent_ts: 1503346078025, 189 | type: 'ban', 190 | channel: '#kritzware', 191 | target_username: 'blarev' } 192 | ``` 193 | 194 | ### `error - (err: Object)` 195 | Emitted when any errors occurs in the Twitch IRC channel, or when attempting to connect to a channel. 196 | 197 | #### Error types 198 | ##### `Login authentication failed` 199 | This error occurs when either your twitch username or oauth are incorrect/invalid. 200 | 201 | Response: 202 | ```javscript 203 | { message: 'Login authentication failed' } 204 | ``` 205 | 206 | ##### `Improperly formatted auth` 207 | This error occurs when your oauth password is not formatted correctly. The valid format should be `"oauth:your-oauth-password-123"`. 208 | 209 | Response: 210 | ```javscript 211 | { message: 'Improperly formatted auth' } 212 | ``` 213 | 214 | ##### `Your message was not sent because you are sending messages too quickly` 215 | This error occurs when a message fails to send due to sending messages too quickly. You can avoid this by making the bot a moderator in the channel, if applicable/allowed. 216 | 217 | Response: 218 | ```javascript 219 | { message: 'Your message was not sent because you are sending messages too quickly' } 220 | ``` 221 | 222 | #### Usage 223 | ```javascript 224 | Bot.on('error', err => ... ) 225 | ``` 226 | 227 | #### Example Response 228 | ```javascript 229 | { message: 'Some error happened in the IRC channel' } 230 | ``` 231 | 232 | ### `close - ()` 233 | This event is emitted when the irc connection is destroyed via the `Bot.close()` method. 234 | #### Usage 235 | ```javascript 236 | Bot.on('close', () => { 237 | console.log('closed bot irc connection') 238 | }) 239 | ``` 240 | 241 | ## Methods 242 | ### `join(channel: String)` 243 | Attempts to join a channel. If successful, emits the 'join' event. 244 | 245 | #### Example 246 | ```javascript 247 | Bot.on('join', channel => { 248 | console.log(`Bot joined ${channel}`) 249 | }) 250 | Bot.join('channel2') 251 | ``` 252 | 253 | ### `part(channel: String)` 254 | Attempts to part from a channel. If successful, emits the 'part' event. 255 | 256 | #### Example 257 | ```javascript 258 | Bot.on('part', channel => { 259 | console.log(`Bot left ${channel}`) 260 | }) 261 | Bot.part('channel2') 262 | ``` 263 | 264 | ### `say(message: String, channel: []Channel, err: Callback)` 265 | Send a message in the currently connected Twitch channel. `channels` parameter not needed when connected to a single channel. An optional callback is provided for validating if the message was sent correctly. 266 | 267 | #### Example 268 | ```javascript 269 | Bot.say('This is a message') 270 | 271 | Bot.say('Pretend this message is over 500 characters', err => { 272 | sent: false, 273 | message: 'Exceeded PRIVMSG character limit (500)' 274 | ts: '2017-08-13T16:38:54.989Z' 275 | }) 276 | 277 | // If connected to multiple channels 278 | Bot.say('message to #channel1', 'channel1') 279 | Bot.say('message to #channel2', 'channel2') 280 | ``` 281 | 282 | ### `timeout(username: String, channel: []Channel, duration: int, reason: String)` 283 | Timeout a user from the chat. `channels` parameter not needed when connected to a single channel. Default `duration` is 600 seconds. Optional `reason` message. 284 | 285 | #### Example 286 | ```javascript 287 | Bot.timeout('kritzware', 10) 288 | // "kritzware was timed out for 10 seconds" 289 | 290 | Bot.timeout('kritzware', 5, 'Using a banned word') 291 | // "kritzware was timed out for 5 seconds, reason: 'Using a banned word'" 292 | 293 | Bot.on('message', chatter => { 294 | if(chatter.message === 'xD') Bot.timeout(chatter.username, 10) 295 | }) 296 | ``` 297 | 298 | ### `ban(username: String, reason: String)` 299 | Permanently ban a user from the chat. `channels` parameter not needed when connected to a single channel. Optional `reason` message. 300 | 301 | #### Example 302 | ```javascript 303 | Bot.ban('kritzware') 304 | // "kritzware is now banned from the room" 305 | 306 | Bot.timeout('kritzware', 'Using a banned word') 307 | // "kritzware is now banned from the room, reason: 'Using a banned word'" 308 | 309 | Bot.on('message', chatter => { 310 | if(chatter.message === 'Ban me!') Bot.ban(chatter.username) 311 | }) 312 | ``` 313 | 314 | ### `close()` 315 | Closes the Twitch irc connection. Bot will be removed from the Twitch channel AND the irc server. 316 | 317 | #### Example 318 | ```javascript 319 | Bot.close() 320 | ``` 321 | 322 | ## Running Tests 323 | Running the test suite requires at least two twitch accounts, one moderator account and one normal account. The channel used must be the same - This is so timeout/ban methods can be tested with the mod account. Using these two accounts, set the following environment variables: 324 | ```javascript 325 | TWITCHBOT_USERNAME=mod_username 326 | TWITCHBOT_OAUTH=oauth:mod-oauth-token 327 | TWITCHBOT_CHANNEL=mod_channel 328 | TWITCHBOT_USERNAME_NON_MOD=non_mod_username 329 | TWITCHBOT_OAUTH_NON_MOD=oauth:non-mod-oauth-token 330 | TWITCHBOT_CHANNEL_NON_MOD=mod_channel 331 | ``` 332 | To run the tests (powered with [Mocha](https://mochajs.org/)), use the following command: 333 | ```bash 334 | yarn test 335 | ``` 336 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | assertion-error@^1.0.1: 6 | version "1.0.2" 7 | resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" 8 | 9 | balanced-match@^1.0.0: 10 | version "1.0.0" 11 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 12 | 13 | brace-expansion@^1.1.7: 14 | version "1.1.8" 15 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" 16 | dependencies: 17 | balanced-match "^1.0.0" 18 | concat-map "0.0.1" 19 | 20 | browser-stdout@1.3.0: 21 | version "1.3.0" 22 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" 23 | 24 | chai@^4.1.0: 25 | version "4.1.0" 26 | resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.0.tgz#331a0391b55c3af8740ae9c3b7458bc1c3805e6d" 27 | dependencies: 28 | assertion-error "^1.0.1" 29 | check-error "^1.0.1" 30 | deep-eql "^2.0.1" 31 | get-func-name "^2.0.0" 32 | pathval "^1.0.0" 33 | type-detect "^4.0.0" 34 | 35 | check-error@^1.0.1: 36 | version "1.0.2" 37 | resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" 38 | 39 | commander@2.9.0: 40 | version "2.9.0" 41 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" 42 | dependencies: 43 | graceful-readlink ">= 1.0.0" 44 | 45 | concat-map@0.0.1: 46 | version "0.0.1" 47 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 48 | 49 | debug@2.6.0: 50 | version "2.6.0" 51 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b" 52 | dependencies: 53 | ms "0.7.2" 54 | 55 | deep-eql@^2.0.1: 56 | version "2.0.2" 57 | resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-2.0.2.tgz#b1bac06e56f0a76777686d50c9feb75c2ed7679a" 58 | dependencies: 59 | type-detect "^3.0.0" 60 | 61 | diff@3.2.0, diff@^3.1.0: 62 | version "3.2.0" 63 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.2.0.tgz#c9ce393a4b7cbd0b058a725c93df299027868ff9" 64 | 65 | escape-string-regexp@1.0.5: 66 | version "1.0.5" 67 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 68 | 69 | formatio@1.2.0, formatio@^1.2.0: 70 | version "1.2.0" 71 | resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb" 72 | dependencies: 73 | samsam "1.x" 74 | 75 | fs.realpath@^1.0.0: 76 | version "1.0.0" 77 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 78 | 79 | get-func-name@^2.0.0: 80 | version "2.0.0" 81 | resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" 82 | 83 | glob@7.1.1: 84 | version "7.1.1" 85 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" 86 | dependencies: 87 | fs.realpath "^1.0.0" 88 | inflight "^1.0.4" 89 | inherits "2" 90 | minimatch "^3.0.2" 91 | once "^1.3.0" 92 | path-is-absolute "^1.0.0" 93 | 94 | "graceful-readlink@>= 1.0.0": 95 | version "1.0.1" 96 | resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" 97 | 98 | growl@1.9.2: 99 | version "1.9.2" 100 | resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f" 101 | 102 | has-flag@^1.0.0: 103 | version "1.0.0" 104 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" 105 | 106 | has-flag@^2.0.0: 107 | version "2.0.0" 108 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" 109 | 110 | inflight@^1.0.4: 111 | version "1.0.6" 112 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 113 | dependencies: 114 | once "^1.3.0" 115 | wrappy "1" 116 | 117 | inherits@2: 118 | version "2.0.3" 119 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 120 | 121 | isarray@0.0.1: 122 | version "0.0.1" 123 | resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" 124 | 125 | json3@3.3.2: 126 | version "3.3.2" 127 | resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" 128 | 129 | just-extend@^1.1.26: 130 | version "1.1.27" 131 | resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" 132 | 133 | lodash._baseassign@^3.0.0: 134 | version "3.2.0" 135 | resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" 136 | dependencies: 137 | lodash._basecopy "^3.0.0" 138 | lodash.keys "^3.0.0" 139 | 140 | lodash._basecopy@^3.0.0: 141 | version "3.0.1" 142 | resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" 143 | 144 | lodash._basecreate@^3.0.0: 145 | version "3.0.3" 146 | resolved "https://registry.yarnpkg.com/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz#1bc661614daa7fc311b7d03bf16806a0213cf821" 147 | 148 | lodash._getnative@^3.0.0: 149 | version "3.9.1" 150 | resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" 151 | 152 | lodash._isiterateecall@^3.0.0: 153 | version "3.0.9" 154 | resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" 155 | 156 | lodash.create@3.1.1: 157 | version "3.1.1" 158 | resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7" 159 | dependencies: 160 | lodash._baseassign "^3.0.0" 161 | lodash._basecreate "^3.0.0" 162 | lodash._isiterateecall "^3.0.0" 163 | 164 | lodash.get@^4.4.2: 165 | version "4.4.2" 166 | resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" 167 | 168 | lodash.isarguments@^3.0.0: 169 | version "3.1.0" 170 | resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" 171 | 172 | lodash.isarray@^3.0.0: 173 | version "3.0.4" 174 | resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" 175 | 176 | lodash.keys@^3.0.0: 177 | version "3.1.2" 178 | resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" 179 | dependencies: 180 | lodash._getnative "^3.0.0" 181 | lodash.isarguments "^3.0.0" 182 | lodash.isarray "^3.0.0" 183 | 184 | lodash@^4.17.4: 185 | version "4.17.4" 186 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" 187 | 188 | lolex@^1.6.0: 189 | version "1.6.0" 190 | resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6" 191 | 192 | lolex@^2.2.0: 193 | version "2.3.1" 194 | resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.3.1.tgz#3d2319894471ea0950ef64692ead2a5318cff362" 195 | 196 | minimatch@^3.0.2: 197 | version "3.0.4" 198 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 199 | dependencies: 200 | brace-expansion "^1.1.7" 201 | 202 | minimist@0.0.8: 203 | version "0.0.8" 204 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 205 | 206 | mkdirp@0.5.1: 207 | version "0.5.1" 208 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 209 | dependencies: 210 | minimist "0.0.8" 211 | 212 | mocha@^3.4.2: 213 | version "3.4.2" 214 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.4.2.tgz#d0ef4d332126dbf18d0d640c9b382dd48be97594" 215 | dependencies: 216 | browser-stdout "1.3.0" 217 | commander "2.9.0" 218 | debug "2.6.0" 219 | diff "3.2.0" 220 | escape-string-regexp "1.0.5" 221 | glob "7.1.1" 222 | growl "1.9.2" 223 | json3 "3.3.2" 224 | lodash.create "3.1.1" 225 | mkdirp "0.5.1" 226 | supports-color "3.1.2" 227 | 228 | ms@0.7.2: 229 | version "0.7.2" 230 | resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" 231 | 232 | nise@^1.2.0: 233 | version "1.2.0" 234 | resolved "https://registry.yarnpkg.com/nise/-/nise-1.2.0.tgz#079d6cadbbcb12ba30e38f1c999f36ad4d6baa53" 235 | dependencies: 236 | formatio "^1.2.0" 237 | just-extend "^1.1.26" 238 | lolex "^1.6.0" 239 | path-to-regexp "^1.7.0" 240 | text-encoding "^0.6.4" 241 | 242 | once@^1.3.0: 243 | version "1.4.0" 244 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 245 | dependencies: 246 | wrappy "1" 247 | 248 | path-is-absolute@^1.0.0: 249 | version "1.0.1" 250 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 251 | 252 | path-to-regexp@^1.7.0: 253 | version "1.7.0" 254 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" 255 | dependencies: 256 | isarray "0.0.1" 257 | 258 | pathval@^1.0.0: 259 | version "1.1.0" 260 | resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" 261 | 262 | samsam@1.x: 263 | version "1.3.0" 264 | resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" 265 | 266 | should-equal@^2.0.0: 267 | version "2.0.0" 268 | resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" 269 | dependencies: 270 | should-type "^1.4.0" 271 | 272 | should-format@^3.0.3: 273 | version "3.0.3" 274 | resolved "https://registry.yarnpkg.com/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" 275 | dependencies: 276 | should-type "^1.3.0" 277 | should-type-adaptors "^1.0.1" 278 | 279 | should-type-adaptors@^1.0.1: 280 | version "1.1.0" 281 | resolved "https://registry.yarnpkg.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" 282 | dependencies: 283 | should-type "^1.3.0" 284 | should-util "^1.0.0" 285 | 286 | should-type@^1.3.0, should-type@^1.4.0: 287 | version "1.4.0" 288 | resolved "https://registry.yarnpkg.com/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" 289 | 290 | should-util@^1.0.0: 291 | version "1.0.0" 292 | resolved "https://registry.yarnpkg.com/should-util/-/should-util-1.0.0.tgz#c98cda374aa6b190df8ba87c9889c2b4db620063" 293 | 294 | should@^13.1.3: 295 | version "13.2.1" 296 | resolved "https://registry.yarnpkg.com/should/-/should-13.2.1.tgz#84e6ebfbb145c79e0ae42307b25b3f62dcaf574e" 297 | dependencies: 298 | should-equal "^2.0.0" 299 | should-format "^3.0.3" 300 | should-type "^1.4.0" 301 | should-type-adaptors "^1.0.1" 302 | should-util "^1.0.0" 303 | 304 | sinon@^4.1.3: 305 | version "4.1.5" 306 | resolved "https://registry.yarnpkg.com/sinon/-/sinon-4.1.5.tgz#620a9b2ac599f88b0455763070f16f4057ed6395" 307 | dependencies: 308 | diff "^3.1.0" 309 | formatio "1.2.0" 310 | lodash.get "^4.4.2" 311 | lolex "^2.2.0" 312 | nise "^1.2.0" 313 | supports-color "^4.4.0" 314 | type-detect "^4.0.5" 315 | 316 | supports-color@3.1.2: 317 | version "3.1.2" 318 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5" 319 | dependencies: 320 | has-flag "^1.0.0" 321 | 322 | supports-color@^4.4.0: 323 | version "4.5.0" 324 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" 325 | dependencies: 326 | has-flag "^2.0.0" 327 | 328 | text-encoding@^0.6.4: 329 | version "0.6.4" 330 | resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19" 331 | 332 | type-detect@^3.0.0: 333 | version "3.0.0" 334 | resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-3.0.0.tgz#46d0cc8553abb7b13a352b0d6dea2fd58f2d9b55" 335 | 336 | type-detect@^4.0.0: 337 | version "4.0.3" 338 | resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.3.tgz#0e3f2670b44099b0b46c284d136a7ef49c74c2ea" 339 | 340 | type-detect@^4.0.5: 341 | version "4.0.5" 342 | resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.5.tgz#d70e5bc81db6de2a381bcaca0c6e0cbdc7635de2" 343 | 344 | wrappy@1: 345 | version "1.0.2" 346 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 347 | --------------------------------------------------------------------------------