Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: use a ChannelManager to handle channel instances #33

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 7 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,25 +253,18 @@ Bot.on('part', channel => {
Bot.part('channel2')
```

### `say(message: String, channel: []Channel, err: Callback)`
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.
### `say(message: String, channel: String, err: function)`
Send a message in a specific channel. Bot has to be part of the channel and membership must have been acknowledged by the server.
Callback function is mandatory and will only be called in case of an error. If no callback is provided and an error occurs, no message will be sent and the bot continues operating.

#### Example
```javascript
Bot.say('This is a message')

Bot.say('Pretend this message is over 500 characters', err => {
sent: false,
message: 'Exceeded PRIVMSG character limit (500)'
ts: '2017-08-13T16:38:54.989Z'
})

// If connected to multiple channels
Bot.say('message to #channel1', 'channel1')
Bot.say('message to #channel2', 'channel2')
Bot.say('This is a message', '#channel1')
Bot.say('Pretend this message is over 500 characters','#channel1', err => console.log(err.message))
//logs: Exceeded PRIVMSG character limit (500)
```

### `timeout(username: String, channel: []Channel, duration: int, reason: String)`
### `timeout(username: String, channel: String, duration: int, reason: String)`
Timeout a user from the chat. `channels` parameter not needed when connected to a single channel. Default `duration` is 600 seconds. Optional `reason` message.

#### Example
Expand Down Expand Up @@ -312,15 +305,6 @@ Bot.close()
```

## Running Tests
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:
```javascript
TWITCHBOT_USERNAME=mod_username
TWITCHBOT_OAUTH=oauth:mod-oauth-token
TWITCHBOT_CHANNEL=mod_channel
TWITCHBOT_USERNAME_NON_MOD=non_mod_username
TWITCHBOT_OAUTH_NON_MOD=oauth:non-mod-oauth-token
TWITCHBOT_CHANNEL_NON_MOD=mod_channel
```
To run the tests (powered with [Mocha](https://mochajs.org/)), use the following command:
```bash
yarn test
Expand Down
132 changes: 58 additions & 74 deletions lib/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,38 @@
const tls = require('tls')
const assert = require('assert')
const EventEmitter = require('events').EventEmitter

const ChannelManager = require('./channelManager')
const parser = require('./parser')

const TwitchBot = class TwitchBot extends EventEmitter {

constructor({
constructor ({
username,
oauth,
channels=[],
port=443,
silence=false
channels = [],
port = 443,
silence = false
}) {
super()

try {
assert(username)
assert(oauth)
} catch(err) {
} catch (err) {
throw new Error('missing or invalid required arguments')
}

this.username = username
this.oauth = oauth
this.channels = channels.map(channel => parser.formatCHANNEL(channel))

this._channels = channels.map(channel => parser.formatCHANNEL(channel))
this.channelManager = new ChannelManager(this.writeIrcMessage.bind(this))
this.irc = new tls.TLSSocket()
this.port = port
this.silence = silence

this._connect()
}

async _connect() {
_connect () {
this.irc.connect({
host: 'irc.chat.twitch.tv',
port: this.port
Expand All @@ -44,155 +43,140 @@ const TwitchBot = class TwitchBot extends EventEmitter {
this.irc.once('connect', () => {
this.afterConnect()
})

}

afterConnect(){
afterConnect () {
this.irc.on('error', err => this.emit('error', err))
this.listen()
this.writeIrcMessage("PASS " + this.oauth)
this.writeIrcMessage("NICK " + this.username)

this.writeIrcMessage("CAP REQ :twitch.tv/tags")
this.channels.forEach(c => this.join(c))

this.writeIrcMessage("CAP REQ :twitch.tv/membership")
this.writeIrcMessage("CAP REQ :twitch.tv/commands")

this.writeIrcMessage('PASS ' + this.oauth)
this.writeIrcMessage('NICK ' + this.username)

this.writeIrcMessage('CAP REQ :twitch.tv/tags')
this._channels.forEach(c => this.join(c))

this.writeIrcMessage('CAP REQ :twitch.tv/membership')
this.writeIrcMessage('CAP REQ :twitch.tv/commands')
}

// TODO: Make this parsing better
listen() {
listen () {
this.irc.on('data', data => {
this.checkForError(data)

/* Twitch sends keep-alive PINGs, need to respond with PONGs */
if(data.includes('PING :tmi.twitch.tv')) {
if (data.includes('PING :tmi.twitch.tv')) {
this.irc.write('PONG :tmi.twitch.tv\r\n')
}

if(data.includes('PRIVMSG')) {
if (data.includes('PRIVMSG')) {
const chatter = parser.formatPRIVMSG(data)
this.emit('message', chatter)
}

if(data.includes('CLEARCHAT')) {
if (data.includes('CLEARCHAT')) {
const event = parser.formatCLEARCHAT(data)
if(event.type === 'timeout') this.emit('timeout', event)
if(event.type === 'ban') this.emit('ban', event)
if (event.type === 'timeout') this.emit('timeout', event)
if (event.type === 'ban') this.emit('ban', event)
}

if(data.includes('USERNOTICE ')) {
if (data.includes('USERNOTICE ')) {
const event = parser.formatUSERNOTICE(data)
if (['sub', 'resub'].includes(event.msg_id) ){
if (['sub', 'resub'].includes(event.msg_id)) {
this.emit('subscription', event)
}
}

// https://dev.twitch.tv/docs/irc#join-twitch-membership
// TODO: Use code 353 for detecting channel JOIN
if(data.includes(`@${this.username}.tmi.twitch.tv JOIN`)) {
if (data.includes(`@${this.username}.tmi.twitch.tv JOIN`)) {
const channel = parser.formatJOIN(data)
if(channel) {
if(!this.channels.includes(channel)) {
this.channels.push(channel)
if (channel) {
if (this.channelManager.join(channel)) {
this.emit('join', channel)
}
this.emit('join', channel)
}
}

if(data.includes(`@${this.username}.tmi.twitch.tv PART`)) {
if (data.includes(`@${this.username}.tmi.twitch.tv PART`)) {
const channel = parser.formatPART(data)
if(channel) {
if(this.channels.includes(channel)) {
this.channels.pop(channel)
if (channel) {
if (this.channelManager.part(channel)) {
this.emit('part', channel)
}
this.emit('part', channel)
}
}
})
}

checkForError(event) {
checkForError (event) {
/* Login Authentication Failed */
if(event.includes('Login authentication failed')) {
if (event.includes('Login authentication failed')) {
this.irc.emit('error', {
message: 'Login authentication failed'
})
}
/* Auth formatting */
if(event.includes('Improperly formatted auth')) {
if (event.includes('Improperly formatted auth')) {
this.irc.emit('error', {
message: 'Improperly formatted auth'
})
}
/* Notice about blocked messages */
if(event.includes('Your message was not sent because you are sending messages too quickly')) {
if (event.includes('Your message was not sent because you are sending messages too quickly')) {
this.irc.emit('error', {
message: 'Your message was not sent because you are sending messages too quickly'
})
}
}

writeIrcMessage(text) {
this.irc.write(text + "\r\n")
writeIrcMessage (text) {
this.irc.write(text + '\r\n')
}

join(channel) {
join (channel) {
channel = parser.formatCHANNEL(channel)
this.writeIrcMessage(`JOIN ${channel}`)
}

part(channel) {
if(!channel && this.channels.length > 0) {
channel = this.channels[0]
part (channel) {
if (!channel && this.channels().length > 0) {
channel = this.channels()[0]
}
channel = parser.formatCHANNEL(channel)
this.writeIrcMessage(`PART ${channel}`)
}

say(message, channel, callback ) {
if(!channel) {
channel = this.channels[0]
say (message, channel = '', callback=(_)=>_) {
channel = parser.formatCHANNEL(channel)

if (this.channels().indexOf(channel) === -1) {
return callback(Error(`You do not seem to be part of this channel: ${channel}`))
}
if(message.length >= 500) {
this.cb(callback, {
sent: false,
message: 'Exceeded PRIVMSG character limit (500)'
})
} else {
this.writeIrcMessage('PRIVMSG ' + channel + ' :' + message)

if (message.length >= 500) {
return callback(Error(`Exceeded PRIVMSG character limit (500)`))
}

this.channelManager.handleSay(message, channel)
}

timeout(username, channel, duration=600, reason='') {
if(!channel) {
channel = this.channels[0]
}
timeout (username, channel, duration = 600, reason = '') {
this.say(`/timeout ${username} ${duration} ${reason}`, channel)
}

ban(username, channel, reason='') {
if(!channel) {
channel = this.channels[0]
}
ban (username, channel, reason = '') {
this.say(`/ban ${username} ${reason}`, channel)
}

close() {
close () {
this.irc.destroy()
this.channelManager.destroy()
this.emit('close')
}

cb(callback, obj) {
if(callback) {
obj.ts = new Date()
callback(obj)
}
channels () {
return this.channelManager.channels()
}

}

module.exports = TwitchBot
62 changes: 62 additions & 0 deletions lib/channel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const CircularBuffer = require('circular-buffer')

const Channel = function (name, sendMessageFunc) {
if (typeof sendMessageFunc !== 'function') { throw new Error('sendMessageFunc parameter not a function.') }

this.name = name
this.tracker = 0 // keep track of the messages sent per evaluation interval
this.messageQueue = [] // keep track of messages that could not be sent yet
this.sendMessageFunc = sendMessageFunc
this.setSelfStatus()
}

Channel.prototype.setSelfStatus = function (status) {
switch (status) {
case 'MOD':
this.setMessageLimit(100, 30)
break
default:
this.setMessageLimit(20, 30)
break
}
}

Channel.prototype.setMessageLimit = function (messageCount, seconds) {
this.limit = messageCount // the maximum limit for messages in limitTimePeriod seconds
this.limitTimePeriod = seconds // in seconds, the time period that allows a maximum of this.limit messages
this.buffer = new CircularBuffer(this.limitTimePeriod) // keep track of messages sent in last timeUpperBound seconds in a circular buffer
}

Channel.prototype.calculateFreeMessageSlots = function () {
return this.limit - (this.buffer.toarray().reduce((sum, bufferValue) => sum + bufferValue, 0) + this.tracker)
}

Channel.prototype.sendMessage = function (message) {
if (this.messageQueue.length === 0) {
if (this.calculateFreeMessageSlots() > 0) {
this.sendMessageFunc(`PRIVMSG ${this.name} :${message}`)
this.tracker++
} else {
this.messageQueue.push(message)
}
} else {
this.messageQueue.push(message)
}
}

// every interval this function tracks the messages sent in the circular buffer
Channel.prototype.trackInterval = function () {
this.buffer.push(this.tracker)
this.tracker = 0

if (this.messageQueue.length > 0) {
const freeMessageSlots = Math.min(this.calculateFreeMessageSlots(), this.messageQueue.length)
for (let i = 0; i < freeMessageSlots; i++) {
this.sendMessageFunc(`PRIVMSG ${this.name} :${this.messageQueue[0]}`)
this.messageQueue.shift()
this.tracker++
}
}
}

module.exports = Channel
Loading