├── .gitignore ├── src ├── reaction_message.coffee └── discord.coffee ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | -------------------------------------------------------------------------------- /src/reaction_message.coffee: -------------------------------------------------------------------------------- 1 | {Message} = require.main.require 'hubot' 2 | 3 | class ReactionMessage extends Message 4 | # Represents a message generated by an emoji reaction event 5 | # - this was copied from the hubot-slack api and should function similarly 6 | # 7 | # type - A String indicating 'reaction_added' or 'reaction_removed' 8 | # user - A User instance that reacted to the item. 9 | # reaction - A String identifying the emoji reaction. 10 | # item_user - A String indicating the user that posted the item. 11 | # item - An Object identifying the target message, file, or comment item. 12 | # event_ts - A String of the reaction event timestamp. 13 | constructor: (@type, @user, @reaction, @item_user, @item, @event_ts) -> 14 | super @user 15 | @type = @type.replace('reaction_', '') 16 | 17 | module.exports = ReactionMessage 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubot-discord", 3 | "version": "2.1.0", 4 | "description": "Hubot adapter for discord", 5 | "main": "src/discord.coffee", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "dependencies": { 10 | "discord.js": "^11.2.1", 11 | "request": "^2.79.0", 12 | "parent-require": "^1.0.0" 13 | }, 14 | "peerDependencies": { 15 | "hubot": ">=2.0" 16 | }, 17 | "devDependencies": { 18 | "coffee-script": ">=1.2.0" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/thetimpanist/hubot-discord" 23 | }, 24 | "author": "Chuck Newport", 25 | "contributors": [ 26 | { 27 | "name": "Matt Voboril", 28 | "email": "matej@voboril.org", 29 | "url": "http://morningstar-wf.com" 30 | } 31 | ], 32 | "engines": { 33 | "node": ">=8.0.0" 34 | }, 35 | "license": "ISC" 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A [Hubot](https://github.com/github/hubot) adapter for [Discord](https://discordapp.com/) 2 | 3 | You should report any issues or submit any pull requests to the 4 | [Discord adapter](https://github.com/thetimpanist/hubot-discord) repository. 5 | 6 | ## Installation instructions 7 | 8 | npm install -g yo generator-hubot hubot-discord 9 | mkdir mybot 10 | cd mybot 11 | yo hubot 12 | 13 | ## Configuring variables on *nix 14 | You will need to create a Discord bot account for your hubot and then invite the bot 15 | to the channels you wish it to be present in. 16 | This bot account is created following the instructions below the table. 17 | 18 | % export HUBOT_DISCORD_TOKEN="..." 19 | % export HUBOT_MAX_MESSAGE_LENGTH="2000" 20 | 21 | Environment Variable | Description | Example 22 | --- | --- | --- 23 | `HUBOT_DISCORD_TOKEN` | bot token for your oauth hubot | `MMMMMMMM` 24 | `HUBOT_DISCORD_STATUS_MSG` | Status message to set for "currently playing game" | `/help for help` 25 | 26 | The OAuth token can be created for an existing bot by navigating to [here, the discord developer application dashboard](https://discordapp.com/developers/applications/me) and creating a new application. 27 | After creating the application, you will need to create a bot application and show then copy the bot token. 28 | 29 | ## Launching your hubot 30 | 31 | cd /path/to/mybot 32 | ./bin/hubot -a discord 33 | 34 | ## Communicating with hubot 35 | The default behavior of the bot is to respond to its account name in Discord 36 | 37 | botname help 38 | -------------------------------------------------------------------------------- /src/discord.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Adapter for Hubot to communicate on Discord 3 | # 4 | # Commands: 5 | # None 6 | # 7 | # Configuration: 8 | # HUBOT_DISCORD_TOKEN - authentication token for bot 9 | # HUBOT_DISCORD_STATUS_MSG - Status message to set for "currently playing game" 10 | # 11 | # Notes: 12 | # 13 | try 14 | {Robot, Response, Adapter, EnterMessage, LeaveMessage, TopicMessage, TextMessage, User} = require 'hubot' 15 | catch 16 | prequire = require( 'parent-require' ) 17 | {Robot, Response, Adapter, EnterMessage, LeaveMessage, TopicMessage, TextMessage, User} = prequire 'hubot' 18 | 19 | Discord = require "discord.js" 20 | TextChannel = Discord.TextChannel 21 | ReactionMessage = require "./reaction_message" 22 | 23 | #Settings 24 | currentlyPlaying = process.env.HUBOT_DISCORD_STATUS_MSG || '' 25 | 26 | Robot::react = (matcher, options, callback) -> 27 | # this function taken from the hubot-slack api 28 | matchReaction = (msg) -> msg instanceof ReactionMessage 29 | 30 | if arguments.length == 1 31 | return @listen matchReaction, matcher 32 | 33 | else if matcher instanceof Function 34 | matchReaction = (msg) -> msg instanceof ReactionMessage && matcher(msg) 35 | 36 | else 37 | callback = options 38 | options = matcher 39 | 40 | @listen matchReaction, options, callback 41 | 42 | 43 | Response::react = () -> 44 | strings = [].slice.call(arguments) 45 | this.runWithMiddleware.apply(this, ['react', {plaintext: true}].concat(strings)) 46 | 47 | class DiscordBot extends Adapter 48 | constructor: (robot)-> 49 | super 50 | @rooms = {} 51 | if not process.env.HUBOT_DISCORD_TOKEN? 52 | @robot.logger.error "Error: Environment variable named `HUBOT_DISCORD_TOKEN` required" 53 | return 54 | 55 | run: -> 56 | @options = 57 | token: process.env.HUBOT_DISCORD_TOKEN 58 | 59 | @client = new Discord.Client {autoReconnect: true, fetch_all_members: true, api_request_method: 'burst', ws: {compress: yes, large_threshold: 1000}} 60 | @robot.client = @client 61 | @client.on 'ready', @.ready 62 | @client.on 'message', @.message 63 | @client.on 'guildMemberAdd', @.enter 64 | @client.on 'guildMemberRemove', @.leave 65 | @client.on 'disconnected', @.disconnected 66 | @client.on 'error', (error) => 67 | @robot.logger.error "The client encountered an error: #{error}" 68 | @client.on 'messageReactionAdd', (message, user) => 69 | @.message_reaction('reaction_added', message, user) 70 | @client.on 'messageReactionRemove', (message, user) => 71 | @.message_reaction('reaction_removed', message, user) 72 | 73 | @client.login(@options.token).catch(@robot.logger.error) 74 | 75 | _map_user: (discord_user, channel_id) -> 76 | user = @robot.brain.userForId discord_user.id 77 | user.room = channel_id 78 | user.name = discord_user.username 79 | user.discriminator = discord_user.discriminator 80 | user.id = discord_user.id 81 | 82 | return user 83 | 84 | _format_incoming_message: (message) -> 85 | @rooms[message.channel.id]?= message.channel 86 | text = message.content ? message.cleanContent 87 | 88 | # If content starts by mentioning me `<@!1234567890>`, rewrite to `@myname` so Hubot understands it 89 | matches = text.match new RegExp( "^<@!#{@client.user.id}>" ) 90 | if matches 91 | text = "#{@robot.name} #{text.substr(matches[0].length)}" 92 | 93 | if (message?.channel instanceof Discord.DMChannel) 94 | text = "#{@robot.name}: #{text}" if not text.match new RegExp( "^@?#{@robot.name}" ) 95 | 96 | return text 97 | 98 | _has_permission: (channel, user) => 99 | isText = channel != null && channel.type == 'text' 100 | permissions = isText && channel.permissionsFor(user) 101 | return if isText then (permissions != null && permissions.hasPermission("SEND_MESSAGES")) else channel.type != 'text' 102 | 103 | _send_success_callback: (adapter, channel, message) => 104 | adapter.robot.logger.debug "SUCCESS! Message sent to: #{channel.id}" 105 | 106 | _send_fail_callback: (adapter, channel, message, error) => 107 | adapter.robot.logger.debug "ERROR! Message not sent: #{message}\r\n#{err}" 108 | # check owner flag and prevent loops 109 | if(process.env.HUBOT_OWNER and channel.id != process.env.HUBOT_OWNER) 110 | sendMessage process.env.HUBOT_OWNER, "Couldn't send message to #{channel.name} (#{channel}) in #{channel.guild.name}, contact #{channel.guild.owner} to check permissions" 111 | 112 | _get_channel: (channelId) => 113 | if @rooms[channelId]? 114 | channel = @rooms[channelId] 115 | else 116 | channels = @client.channels.filter (channel) -> channel.id == channelId 117 | if channels.first()? 118 | channel = channels.first() 119 | else 120 | channel = @client.users.get(channelId) 121 | return channel 122 | 123 | ready: => 124 | @robot.logger.info "Logged in: #{@client.user.username}##{@client.user.discriminator}" 125 | @robot.name = @client.user.username 126 | @robot.logger.info "Robot Name: #{@robot.name}" 127 | @emit "connected" 128 | 129 | #post-connect actions 130 | @rooms[channel.id] = channel for channel in @client.channels 131 | @client.user.setActivity(currentlyPlaying) 132 | .then(@robot.logger.debug("Status set to #{currentlyPlaying}")) 133 | .catch(@robot.logger.error) 134 | 135 | enter: (member) => 136 | user = member 137 | @robot.logger.debug "#{user} Joined" 138 | @receive new EnterMessage( user ) 139 | 140 | leave: (member) => 141 | user = member 142 | @robot.logger.debug "#{user} Left" 143 | @receive new LeaveMessage( user ) 144 | 145 | message: (message) => 146 | # ignore messages from myself 147 | return if message.author.id == @client.user.id 148 | 149 | user = @_map_user message.author, message.channel.id 150 | text = @_format_incoming_message(message) 151 | 152 | @robot.logger.debug text 153 | @receive new TextMessage( user, text, message.id ) 154 | 155 | message_reaction: (reaction_type, message, user) => 156 | # ignore reactions from myself 157 | return if user.id == @client.user.id 158 | 159 | reactor = @_map_user user, message.message.channel.id 160 | author = @_map_user message.message.author, message.message.channel.id 161 | text = @_format_incoming_message message.message 162 | 163 | text_message = new TextMessage(reactor, text, message.message.id) 164 | reaction = message._emoji.name 165 | if message._emoji.id? 166 | reaction += ":#{message._emoji.id}" 167 | @receive new ReactionMessage(reaction_type, reactor, reaction, author, 168 | text_message, message.createdTimestamp) 169 | 170 | disconnected: => 171 | @robot.logger.info "#{@robot.name} Disconnected, will auto reconnect soon..." 172 | 173 | send: (envelope, messages...) -> 174 | for message in messages 175 | @sendMessage envelope.room, message 176 | 177 | reply: (envelope, messages...) -> 178 | for message in messages 179 | @sendMessage envelope.room, "<@#{envelope.user.id}> #{message}" 180 | 181 | sendMessage: (channelId, message) -> 182 | 183 | #Padded blank space before messages to comply with https://github.com/meew0/discord-bot-best-practices 184 | zSWC = "\u200B" 185 | message = zSWC+message 186 | 187 | channel = @._get_channel(channelId) 188 | that = @ 189 | 190 | # check permissions 191 | if(channel and (!(channel instanceof TextChannel) or @_has_permission(channel, @robot?.client?.user))) 192 | channel.send(message, {split: true}) 193 | .then (msg) -> 194 | that._send_success_callback that, channel, message, msg 195 | .catch (error) -> 196 | that._send_fail_callback that, channel, message, error 197 | else 198 | @._send_fail_callback @, channel, message, "Invalid Channel" 199 | 200 | react: (envelope, reactions...) -> 201 | robot = @robot 202 | channel = @._get_channel(envelope.room) 203 | that = @ 204 | 205 | messageId = if envelope.message instanceof ReactionMessage \ 206 | then envelope.message.item.id 207 | else envelope.message.id 208 | 209 | if(channel and (!(channel instanceof TextChannel) or @_has_permission(channel, @robot?.client?.user))) 210 | for reaction in reactions 211 | @robot.logger.info reaction 212 | channel.fetchMessage(messageId) 213 | .then (message) -> 214 | message.react(reaction) 215 | .then (msg) -> 216 | that._send_success_callback that, channel, message, msg 217 | .catch (error) -> 218 | that._send_fail_callback that, channel, message, error 219 | .catch (error) -> 220 | that._send_fail_callback that, channel, reaction, error 221 | else 222 | @._send_fail_callback @, channel, message, "Invalid Channel" 223 | 224 | 225 | channelDelete: (channel, client) -> 226 | roomId = channel.id 227 | user = new User client.user.id 228 | user.room = roomId 229 | user.name = client.user.username 230 | user.discriminator = client.user.discriminator 231 | user.id = client.user.id 232 | @robot.logger.info "#{user.name}##{user.discriminator} leaving #{roomId} after a channel delete" 233 | @receive new LeaveMessage user, null, null 234 | 235 | guildDelete: (guild, client) -> 236 | serverId = guild.id 237 | roomIds = (channel.id for channel in guild.channels) 238 | for room of rooms 239 | user = new User client.user.id 240 | user.room = room.id 241 | user.name = client.user.username 242 | user.discriminator = client.user.discriminator 243 | user.id = client.user.id 244 | @robot.logger.info "#{user.name}##{user.discriminator} leaving #{roomId} after a guild delete" 245 | @receive new LeaveMessage(user, null, null) 246 | 247 | 248 | exports.use = (robot) -> 249 | new DiscordBot robot 250 | --------------------------------------------------------------------------------