├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── config.example.json ├── emoji.js ├── emoji_pretty.json ├── gateway.js ├── package.json ├── queue.js └── replacements.example.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config.json 3 | replacements.js 4 | data/ 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | By submitting code to this project, you agree to irrevocably release it under the same license as this project. See README.md for more details. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2015 by Aaron Parecki 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Slack IRC Gateway 2 | ================= 3 | 4 | You can use this project to bridge a Slack room with an existing IRC channel. 5 | 6 | When Slack users type a message, this script will sign in to IRC for them, join the 7 | channel, and then relays their messages to IRC on behalf of them. 8 | 9 | 10 | Setup 11 | ----- 12 | 13 | * Choose a Slack channel and configure a new custom app for it 14 | * Enable Event Subscriptions on the app 15 | * Set the Request URL to the location of where you deployed this script. It should be something like http://example.com:8080/gateway/input 16 | * Make sure you deploy the app before setting this URL, as Slack will ping it to test it when you enter the URL 17 | * Watch the logs and copy the "Event API Token" into your config 18 | * Under "Subscribe to events on behalf of users", add the `message.channels` event 19 | * Under "OAuth & Permissions", add the following User Token Scopes 20 | * `channels:history` 21 | * `channels:read` 22 | * `files:read` 23 | * `files:write` 24 | * `groups:read` 25 | * `users:read` 26 | * (Some of these may already be added when you configure `message.channels` in the previous step) 27 | * Copy the OAuth Access Token and add it to the config file in `slack.token`. This is used to do things like look up info about Slack users. 28 | * Create an incoming web hook (legacy, not within this app) to route messages back into slack, and set that as `slack.hook` in the config file. 29 | * Set the hostname and channel for the IRC server you're connecting to in the config file. 30 | 31 | Now run `node gateway.js` which listens on the configured HTTP port and will start connecting to IRC on behalf of your Slack users! 32 | 33 | Messages from IRC will also be sent back to the corresponding channel. 34 | 35 | 36 | Text Replacements 37 | ----------------- 38 | 39 | Slack supports rich text in messages such as including links. If you have any custom text replacements you'd like to do for messages sent from IRC to Slack, such as autolinking keywords, you can add a file `replacements.js` and define a function there to transform text sent from IRC to Slack. See `replacements.example.js` for an example. 40 | 41 | 42 | Emoji 43 | ----- 44 | 45 | Slack uses emoji shortcodes in the API rather than emoji themselves. When new emoji or shortcodes are added, the `emoji_pretty.json` file will need to be updated. This gateway uses a copy of the file that is used by Slack, available at 46 | 47 | https://github.com/iamcal/emoji-data 48 | 49 | https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji_pretty.json 50 | 51 | 52 | License 53 | ------- 54 | 55 | See LICENSE.txt 56 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 8080, 3 | "host": "127.0.0.1", 4 | "irc": { 5 | "hostname": "irc.libera.chat", 6 | "gateway_nick": "SlackGateway", 7 | "options": { 8 | "port": 6697, 9 | "secure": true, 10 | "userName": "SlackGateway", 11 | "password": "************" 12 | } 13 | }, 14 | "channels": [ 15 | { 16 | "slack_id": "C00000000", 17 | "slack_name": "indieweb", 18 | "irc": "#indieweb" 19 | }, 20 | { 21 | "slack_id": "C00000000", 22 | "slack_name": "dev", 23 | "irc": "#indieweb-dev" 24 | }, 25 | { 26 | "slack_id": "C00000000", 27 | "slack_name": "chat", 28 | "irc": "#indieweb-chat" 29 | } 30 | ], 31 | "slack": { 32 | "disconnect_timeout": 129600000, 33 | "event_api_token": "bltzz3FzVx3etR6nWywSsoHR", 34 | "token": "xoxp-xxxx", 35 | "hook": "https://hooks.slack.com/services/XXX/XXX/XXX" 36 | }, 37 | "web": { 38 | "disconnect_timeout": 300000, 39 | "token": "xxxx" 40 | }, 41 | "files": { 42 | "upload_endpoint": "https://example.org/upload-file.php", 43 | "token": "xxxx" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /emoji.js: -------------------------------------------------------------------------------- 1 | const punycode = require('punycode'); 2 | 3 | var emoji_data = require('./emoji_pretty.json'); 4 | 5 | exports.slack_to_unicode = function(text) { 6 | 7 | var emoji_re = /\:([a-zA-Z0-9\-_\+]+)\:(?:\:([a-zA-Z0-9\-_\+]+)\:)?/g; 8 | 9 | var new_text = text; 10 | 11 | // Find all Slack emoji in the message 12 | while(match=emoji_re.exec(text)) { 13 | var ed = emoji_data.find(function(el){ 14 | return el.short_name == match[1]; 15 | }); 16 | if(ed) { 17 | var points = ed.unified.split("-"); 18 | points = points.map(function(p){ return parseInt(p, 16) }); 19 | new_text = new_text.replace(match[0], punycode.ucs2.encode(points)); 20 | } 21 | } 22 | 23 | return new_text; 24 | } 25 | -------------------------------------------------------------------------------- /gateway.js: -------------------------------------------------------------------------------- 1 | const Hapi = require('@hapi/hapi'); 2 | var irc = require('irc'); 3 | var request = require('request'); 4 | var async = require('async'); 5 | const {decode} = require('html-entities'); 6 | const crypto = require("crypto"); 7 | const base64url = require("base64url"); 8 | var emoji = require('./emoji'); 9 | var queue = require('./queue').queue; 10 | 11 | // Provide a mechanism to allow project-specific text replacements 12 | const fs = require('fs'); 13 | var replacements; 14 | try { 15 | if(fs.existsSync("./replacements.js")) { 16 | replacements = require("./replacements"); 17 | } 18 | } catch(err) { 19 | } 20 | 21 | 22 | var config = require(__dirname + '/config.json'); 23 | 24 | var clients = {} 25 | var sessions = {} 26 | var queued = {} 27 | var timers = {} 28 | 29 | var ircToSlack; 30 | var ircUsers = []; 31 | 32 | var server = new Hapi.Server({ 33 | host: config.host, 34 | port: config.port 35 | }); 36 | 37 | server.route({ 38 | method: 'GET', 39 | path: '/', 40 | handler: function (request, h) { 41 | return 'This is a Slack/IRC Gateway. Source code here: https://github.com/aaronpk/Slack-IRC-Gateway'; 42 | } 43 | }); 44 | 45 | server.route({ 46 | method: 'POST', 47 | path: '/gateway/input', 48 | handler: function (req, h) { 49 | 50 | // console.log(req.payload); 51 | 52 | 53 | // Respond to the Slack Events API challenge 54 | if(req.payload.type == "url_verification") { 55 | console.log("Event API Token: "+req.payload.token); 56 | return {challenge: req.payload.challenge}; 57 | } 58 | 59 | // console.log("[SLACK] "+JSON.stringify(req.payload)); 60 | 61 | if(!req.payload.event) { 62 | console.log("No event in payload"); 63 | return 'no event'; 64 | } 65 | 66 | // Ignore everything except regular text messages and "/me" 67 | if(!(req.payload.event.subtype == null || ["me_message", "file_share"].includes(req.payload.event.subtype))) { 68 | console.log("Ignoring event. type: "+req.payload.event.type+" subtype: "+req.payload.event.subtype); 69 | return 'ignoring'; 70 | } 71 | 72 | // Verify the request came from Slack 73 | if(config.slack.event_api_token != req.payload.token) { 74 | return 'unauthorized'; 75 | } 76 | 77 | var event = req.payload.event; 78 | 79 | // console.log(event); 80 | 81 | // Map Slack channels to IRC channels, and ignore messages from channels that don't have a mapping 82 | var irc_channel = false; 83 | var slack_channel = false; 84 | for(var i in config.channels) { 85 | if(event.channel == config.channels[i].slack_id) { 86 | irc_channel = config.channels[i].irc; 87 | slack_channel_name = config.channels[i].slack_name; 88 | } 89 | } 90 | 91 | if(!irc_channel) { 92 | return 'no irc channel'; 93 | } 94 | 95 | // Acknowledge the Slack webhook immediately 96 | // reply('ok: '+event.client_msg_id); 97 | 98 | slack_user_id_to_username(event.user, function(err, username){ 99 | if(err) { 100 | console.log("Error looking up user ID: "+event.user); 101 | } else { 102 | 103 | switch(event.type) { 104 | case "message": 105 | if(event.text) { 106 | // Replace Slack refs with IRC refs 107 | replace_slack_entities(event.text, function(text) { 108 | console.log("INPUT: #"+irc_channel+" "+event.channel+" ["+username+"] ("+event.user+") "+text); 109 | if(event.subtype == "me_message") { 110 | text = "/me "+text; 111 | } 112 | if(event.thread_ts) { 113 | text = "↩️ "+text; 114 | } 115 | process_message(irc_channel, username, event.user, 'slack', text); 116 | }); 117 | } 118 | 119 | // If there are any files in the image, download them and re-upload them to a public URL 120 | if(event.files) { 121 | for(var i in event.files) { 122 | var file = event.files[i]; 123 | 124 | console.log("file info", file); 125 | var file_url = file.url_private_download; 126 | 127 | // Hand off the download URL and slack token to the archiving service 128 | request.post(config.files.upload_endpoint, { 129 | form: { 130 | file: file_url, 131 | slack_channel: slack_channel_name, 132 | slack_token: config.slack.token, 133 | token: config.files.token 134 | } 135 | }, function(err, response, body){ 136 | console.log(err, body); 137 | var data = JSON.parse(body); 138 | if(data.url) { 139 | process_message(irc_channel, username, event.user, 'slack', data.url) 140 | } 141 | }); 142 | 143 | } 144 | } 145 | break; 146 | } 147 | } 148 | }); 149 | 150 | return 'ok: '+event.client_msg_id; 151 | } 152 | }); 153 | 154 | server.route({ 155 | method: 'POST', 156 | path: '/web/input', 157 | handler: function (request, h) { 158 | if(request.payload.token != config.web.token) { 159 | return 'unauthorized'; 160 | } else { 161 | if(sessions[request.payload.session]) { 162 | var session = sessions[request.payload.session]; 163 | 164 | var username = session.username; 165 | var text = request.payload.text; 166 | var channel = request.payload.channel; 167 | 168 | process_message(channel, username, username, 'web', text); 169 | 170 | return {username: username}; 171 | } else { 172 | return {error: 'invalid_session'}; 173 | } 174 | } 175 | } 176 | }); 177 | 178 | // The web IRC gateway will post to here after the user enters their nick 179 | server.route({ 180 | method: 'POST', 181 | path: '/web/join', 182 | handler: function (request, h) { 183 | if(request.payload.token != config.web.token) { 184 | return 'unauthorized'; 185 | } else { 186 | const username = request.payload.user_name; 187 | const user_id_hash = user_hash(username, username); 188 | 189 | if(clients["web:"+user_id_hash] == null) { 190 | connect_to_irc(username, username, user_id_hash, 'web'); 191 | // Reply with a session token that will be required with every message to /web/input 192 | return {"status": "connecting", "session": clients["web:"+user_id_hash].websession}; 193 | } else { 194 | return {"status":"connected", "session": clients["web:"+user_id_hash].websession}; 195 | } 196 | } 197 | } 198 | }); 199 | 200 | server.route({ 201 | method: 'POST', 202 | path: '/web/session', 203 | handler: function (request, h) { 204 | if(!request.payload || request.payload.token != config.web.token) { 205 | return 'unauthorized'; 206 | } else { 207 | if(sessions[request.payload.session]) { 208 | var data = sessions[request.payload.session]; 209 | return {username: data.username}; 210 | } else { 211 | return {}; 212 | } 213 | } 214 | } 215 | }); 216 | 217 | server.start(function () { 218 | console.log('Server running at:', server.info.uri); 219 | }); 220 | 221 | var ircToSlackQueue = []; 222 | 223 | // Create an IRC bot that joins all the channels to route messages to Slack 224 | var options = { 225 | autoConnect: false, 226 | debug: true, 227 | userName: config.irc.gateway_nick, 228 | realName: "IRC to Slack Gateway", 229 | channels: config.channels.map(function(c){ return c.irc; }), 230 | ...config.irc.options 231 | }; 232 | ircToSlack = new irc.Client(config.irc.hostname, config.irc.gateway_nick, options); 233 | ircToSlack.connect(function(){ 234 | console.log("[connecting] Connecting Gateway user to IRC... Channels: "+[config.channels.map(function(c){ return c.irc; })].join()); 235 | }); 236 | ircToSlack.addListener('join', function(channel, nick, message){ 237 | console.log('[join] Successfully joined '+channel); 238 | }); 239 | ircToSlack.addListener('ctcp-privmsg', function(nick, channel, message, event){ 240 | process_irc_to_slack(nick, channel, message.replace(/[\x00-\x1F\x7F-\x9F]/g,'').replace(/^ACTION /,''), 'message', event); 241 | }); 242 | ircToSlack.addListener('message', function(nick, channel, message, event) { 243 | process_irc_to_slack(nick, channel, message, 'ctcp', event); 244 | }); 245 | 246 | var slack_queue = []; 247 | function send_to_slack_from_queue() { 248 | var payload = slack_queue.shift(); 249 | if(payload) { 250 | request.post(config.slack.hook, { 251 | form: { 252 | payload: JSON.stringify(payload) 253 | } 254 | }, function(err,response,body){ 255 | setTimeout(send_to_slack_from_queue, 1000); 256 | }); 257 | } else { 258 | setTimeout(send_to_slack_from_queue, 1000); 259 | } 260 | } 261 | send_to_slack_from_queue(); 262 | 263 | function process_irc_to_slack(nick, channel, message, type, event) { 264 | console.log('[irc] ('+channel+' '+nick+' '+event.user+' '+type+') "'+message+'"'); 265 | 266 | // event.user is the IRC username, which is limited to 10 chars 267 | var client_id = "slack:"+event.user; 268 | // Ignore IRC messages from this Slack gateway 269 | if(clients[client_id] != null) { 270 | return; 271 | } 272 | 273 | // Discord messages come in as " message" sometimes 274 | if(nick == "IWDiscord") { 275 | var r = new RegExp('<([^ ]+)> (.+)'); 276 | if(m = message.match(r)) { 277 | nick = m[1]; 278 | message = m[2]; 279 | } 280 | } 281 | 282 | // Convert IRC text to Slack text 283 | 284 | // Strip IRC control chars 285 | message = message.replace(/\x03\d{1,2}/g, '').replace(/\x03/, ''); 286 | 287 | // Convert mentions of slack usernames "[aaronpk]" to native slack IDs if we know them 288 | message = message.replace(/\[([a-zA-Z0-9_-]+)\]/g, function(matched, nickname, index){ 289 | var slack_user_id = slack_username_to_id(nickname); 290 | if(slack_user_id) { 291 | return '<@'+slack_user_id+'>'; 292 | } else { 293 | return matched; 294 | } 295 | }); 296 | 297 | // Convert mentions of channel names to the slack equivalents <#C000XX0XX> 298 | for(var i in config.channels) { 299 | var rxp = new RegExp('([^a-z\-]|^)'+config.channels[i].irc+'([^a-z\-]|$)', "g"); 300 | message = message.replace(rxp, "$1<#"+config.channels[i].slack_id+">$2") 301 | } 302 | 303 | if(replacements) { 304 | message = replacements.irc_to_slack(message, channel); 305 | } 306 | 307 | // Route the message to the appropriate Slack channel 308 | // Slack API rate limits 1/sec, so add these to a queue which is processed separately 309 | var ch = slack_channel_from_irc_channel(channel); 310 | if(ch) { 311 | var icon_url = false; 312 | var profile; 313 | 314 | if(profile=profile_for_irc_nick(nick)) { 315 | if(profile.photo) { 316 | icon_url = profile.photo[0]; 317 | } 318 | } 319 | 320 | slack_queue.push({ 321 | text: message, 322 | username: nick, 323 | channel: ch, 324 | icon_url: icon_url 325 | }); 326 | } 327 | 328 | } 329 | ircToSlack.addListener('pm', function(from, message){ 330 | ircToSlack.say(from, "The source code of this bot is available here: https://github.com/aaronpk/slack-irc-gateway"); 331 | }); 332 | 333 | 334 | // Load IRC users from file to variable every 5 minutes 335 | function reload_irc_users_from_file() { 336 | if(fs.existsSync("./data/irc-users.json")) { 337 | try { 338 | tmpircUsers = JSON.parse(fs.readFileSync('./data/irc-users.json')); 339 | ircUsers = tmpircUsers; 340 | } catch(e) { 341 | console.log("ERROR LOADING USERS FROM FILE"); 342 | } 343 | } 344 | } 345 | reload_irc_users_from_file(); 346 | setInterval(reload_irc_users_from_file, 60*5); 347 | 348 | function profile_for_irc_nick(nick) { 349 | for(var i in ircUsers) { 350 | if(nick == ircUsers[i].properties.nickname) { 351 | return ircUsers[i].properties; 352 | } 353 | } 354 | return null; 355 | } 356 | 357 | 358 | 359 | function slack_api(method, params, callback) { 360 | params['token'] = config.slack.token; 361 | request.post("https://slack.com/api/"+method, { 362 | form: params 363 | }, function(err,response,body){ 364 | var data = JSON.parse(body); 365 | callback(err, data); 366 | }); 367 | } 368 | 369 | var username_cache = {}; 370 | var userid_cache = {}; 371 | 372 | function slack_user_id_to_username(uid, callback) { 373 | if(username_cache[uid]) { 374 | callback(null, username_cache[uid]); 375 | } else { 376 | slack_api("users.info", {user: uid}, function(err, data){ 377 | if(err) { 378 | console.error("Error getting user info", err); 379 | } else { 380 | if(!data.user) { 381 | console.error("No user data found", data); 382 | return; 383 | } 384 | var name; 385 | if(data.user.profile.display_name_normalized) { 386 | name = data.user.profile.display_name_normalized; 387 | } else { 388 | name = data.user.name; // fallback for users who haven't set a display name 389 | } 390 | // Turn a Slack name into IRC-safe chars. 391 | // Use only alphanumeric and underscore, 392 | // collapse multiple underscores, 393 | // cap at 14 chars (IRC limit is 16, we add [] later) 394 | var username = name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/_+/, '_').substring(0,14); 395 | console.log("Username: "+uid+" => "+username); 396 | username_cache[uid] = username; 397 | userid_cache[username.toLowerCase()] = uid; 398 | callback(err, username); 399 | } 400 | }); 401 | } 402 | } 403 | 404 | function slack_username_to_id(username) { 405 | if(userid_cache[username.toLowerCase()]) { 406 | return userid_cache[username.toLowerCase()]; 407 | } else { 408 | return false; 409 | } 410 | } 411 | 412 | function replace_slack_entities(text, replace_callback) { 413 | text = text.replace(new RegExp('<([a-z]+:[^\\|>]+)\\|([^>]+)>','g'), '$1'); 414 | text = text.replace(new RegExp('<([a-z]+:[^\\|>]+)>','g'), '$1'); 415 | 416 | text = emoji.slack_to_unicode(text); 417 | 418 | if(matches=text.match(/<[@#]([UC][^>\|]+)(?:\|([^\|]*))?>/g)) { 419 | async.map(matches, function(entity, callback){ 420 | var match = entity.match(/<([@#])([UC][^>\|]+)(?:\|([^\|]*))?>/); 421 | //console.log("Processing "+match[2]); 422 | if(match[1] == "@"){ 423 | slack_user_id_to_username(match[2], function(err, username){ 424 | callback(err, {match: entity, replace: "["+username+"]"}); 425 | }); 426 | } else { 427 | // If this channel matches one in the config, convert to the IRC name 428 | var irc_channel = irc_channel_from_slack_channel_id(match[2]); 429 | if(irc_channel) { 430 | callback(null, {match: entity, replace: irc_channel}); 431 | } else { 432 | slack_api("channels.info", {channel: match[2]}, function(err, data){ 433 | if(data.channel) { 434 | var irc_channel = irc_channel_from_slack_channel(data.channel.name); 435 | callback(err, {match: entity, replace: irc_channel}); 436 | } else { 437 | callback(err, {match: entity, replace: ""}); 438 | } 439 | }); 440 | } 441 | } 442 | }, function(err, results) { 443 | //console.log(results); 444 | for(var i in results) { 445 | text = text.replace(results[i].match, results[i].replace); 446 | } 447 | replace_callback(decode(text)); 448 | }); 449 | } else { 450 | replace_callback(decode(text)); 451 | } 452 | } 453 | 454 | function irc_channel_from_slack_channel(name) { 455 | var irc_channel = '#'+name; 456 | for(var i in config.channels) { 457 | if(name == config.channels[i].slack_name) { 458 | irc_channel = config.channels[i].irc; 459 | } 460 | } 461 | return irc_channel; 462 | } 463 | 464 | function irc_channel_from_slack_channel_id(channel_id) { 465 | var irc_channel = null; 466 | for(var i in config.channels) { 467 | if(channel_id == config.channels[i].slack_id) { 468 | irc_channel = config.channels[i].irc; 469 | } 470 | } 471 | return irc_channel; 472 | } 473 | 474 | function slack_channel_from_irc_channel(name) { 475 | var slack_channel = false; 476 | for(var i in config.channels) { 477 | if(name == config.channels[i].irc) { 478 | slack_channel = '#'+config.channels[i].slack_name; 479 | } 480 | } 481 | return slack_channel; 482 | } 483 | 484 | function process_message(channel, username, user_id, method, text) { 485 | var irc_nick; 486 | if(method == 'slack') { 487 | irc_nick = "["+username+"]"; 488 | } else { 489 | irc_nick = username; 490 | } 491 | 492 | const user_id_hash = user_hash(username, user_id); 493 | const client_id = method+":"+user_id_hash; 494 | 495 | // Add the truncated username if the bot ends up with a tilde to check for doubled up messages 496 | const user_id_hash2 = method+":~"+user_id_hash.substring(0,user_id_hash.length-1); 497 | clients[user_id_hash2] = 'alias:'+client_id; 498 | console.log("Adding backup user ID: "+user_id_hash2); 499 | 500 | // Connect and add to the queue 501 | if(clients[client_id] == null) { 502 | if(queued[client_id] == null) { 503 | queued[client_id] = new queue(); 504 | } 505 | queued[client_id].push(channel, text); 506 | 507 | connect_to_irc(username, irc_nick, user_id_hash, method); 508 | } else if(queued[client_id] && queued[client_id].length() > 0) { 509 | // There is already a client and something in the queue, which means 510 | // the bot is currently connecting. Keep adding to the queue 511 | queued[client_id].push(channel, text); 512 | } else { 513 | // Queue is empty, so bot is already connected 514 | var match; 515 | if(match=text.match(/^\/nick (.+)/)) { 516 | clients[client_id].send("NICK", match[1]); 517 | } else if(match=text.match(/^\/me (.+)/)) { 518 | clients[client_id].action(channel, match[1]); 519 | } else if(match=text.match(/^\/quit/)) { 520 | clients[client_id].disconnect('quit'); 521 | } else { 522 | clients[client_id].say(channel, text); 523 | } 524 | 525 | keepalive(method, client_id); 526 | } 527 | } 528 | 529 | function user_hash(username, user_id) { 530 | // Regardless of the username input, we need to use only 10 chars from it to 531 | // stay within the limit of IRC usernames. 532 | // Calculate a SHA256 hash and use the last 10 chars from it. 533 | const base64Digest = crypto.createHash("sha256") 534 | .update(username) 535 | .digest("base64"); 536 | const base64URL = base64url.fromBase64(base64Digest); 537 | const userFirst6 = username.slice(0,6); 538 | const hashLen = 10 - userFirst6.length; 539 | return userFirst6 + base64URL.slice(-1 * hashLen); 540 | } 541 | 542 | 543 | 544 | function keepalive(method, client_id) { 545 | var timeout; 546 | 547 | if(method == 'slack') { 548 | timeout = config.slack.disconnect_timeout; 549 | } else { 550 | timeout = config.web.disconnect_timeout; 551 | } 552 | 553 | clearTimeout(timers[client_id]); 554 | timers[client_id] = setTimeout(function(){ 555 | console.log("user timed out: "+client_id); 556 | if(clients[client_id]) { 557 | clients[client_id].disconnect('timed out'); 558 | } 559 | clients[client_id] = null; 560 | timers[client_id] = null; 561 | }, timeout); 562 | } 563 | 564 | function connect_to_irc(username, irc_nick, user_id_hash, method) { 565 | 566 | console.log("Connecting to IRC for username="+username+" irc_nick="+irc_nick+" user_id_hash="+user_id_hash); 567 | 568 | const clientId = method + ":" + user_id_hash; 569 | const ircClient = new irc.Client(config.irc.hostname, irc_nick, { 570 | autoConnect: false, 571 | debug: false, 572 | userName: user_id_hash, 573 | realName: username+" via "+method+"-irc-gateway", 574 | channels: config.channels.map(function(c){ return c.irc; }) 575 | }); 576 | clients[clientId] = ircClient; 577 | 578 | ircClient.websession = Math.random().toString(36); 579 | sessions[ircClient.websession] = {method: method, username: username}; 580 | 581 | // Set a timer to disconnect the bot after a while 582 | keepalive(method, clientId); 583 | 584 | const nickReclaimListener = (nick, reason, channels, quitMessage) => { 585 | if (nick != irc_nick) return; 586 | 587 | console.log(`[quit] Attemping to change nick to ${irc_nick}`); 588 | ircClient.send("NICK", irc_nick); 589 | 590 | // Assume that it works, or at least limit it to a single attempt 591 | ircClient.removeListener('quit', nickReclaimListener); 592 | } 593 | 594 | ircClient.connect(function(registrationMessage) { 595 | const date = ((new Date()).toString()); 596 | console.log(date+" [connecting] ("+method+"/"+username+") Connecting to IRC... Channels: "+[config.channels.map((c) => c.irc)].join()); 597 | console.log(registrationMessage); 598 | const realNick = registrationMessage.args[0]; 599 | if (username == realNick) return; 600 | console.log(`[connecting] IRC nick was set to "${realNick}", will now listen for part events to reclaim nick "${irc_nick}"`); 601 | 602 | ircClient.addListener('quit', nickReclaimListener); 603 | }); 604 | 605 | ircClient.addListener('join', function(channel, nick, message) { 606 | console.log(`[join] ${nick} joined ${channel} (me: ${ircClient.nick})`); 607 | 608 | // Bail if it's not about us 609 | if (nick != ircClient.nick) return; 610 | 611 | console.log(`[debug] ${irc_nick} successfully joined ${channel}! (joined as ${nick})`); 612 | 613 | // Bail if there are no queued messages for the channel 614 | if (!queued[clientId]) return; 615 | 616 | // Delay to give Loqi time to +v 617 | setTimeout(function() { 618 | let text; 619 | while (text = queued[clientId].pop(channel)) { 620 | ircClient.say(channel, text); 621 | } 622 | }, 500); 623 | }); 624 | 625 | ircClient.addListener('error', function(message) { 626 | console.log("[error] ("+method+"/"+username+") ", message); 627 | }); 628 | 629 | ircClient.addListener('pm', function(from, message) { 630 | if(message == "!nick") { 631 | ircClient.send("NICK", irc_nick); 632 | ircClient.say(from, "[nick] resetting nick to "+irc_nick); 633 | } else { 634 | ircClient.say(from, "[error] Sorry, private messages to users of the "+method+" gateway are not supported."); 635 | } 636 | }) 637 | } 638 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-irc-gateway", 3 | "version": "0.1.0", 4 | "description": "A bridge to allow Slack users to appear as native IRC users", 5 | "keywords": [ 6 | "irc", 7 | "slack" 8 | ], 9 | "author": { 10 | "name": "Aaron Parecki", 11 | "url": "http://aaronparecki.com/" 12 | }, 13 | "license": "Apache-2.0", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/aaronpk/slack-irc-gateway" 17 | }, 18 | "dependencies": { 19 | "@hapi/hapi": "^21.0.0", 20 | "async": "^1.4.0", 21 | "base64url": "^3.0.1", 22 | "html-entities": "2.3.3", 23 | "irc": "*", 24 | "irc-colors": "^1.2.0", 25 | "punycode": "^2.1.0", 26 | "request": "^2.60.0" 27 | }, 28 | "main": "gateway.js" 29 | } -------------------------------------------------------------------------------- /queue.js: -------------------------------------------------------------------------------- 1 | 2 | exports.queue = function() { 3 | this.messages = {}; 4 | 5 | this.push = function(channel, text) { 6 | if(!this.messages[channel]) 7 | this.messages[channel] = []; 8 | 9 | this.messages[channel].push(text); 10 | } 11 | 12 | this.length = function() { 13 | var len = 0; 14 | for(var c in this.messages) { 15 | len += this.messages[c].length; 16 | } 17 | return len; 18 | } 19 | 20 | this.pop = function(channel) { 21 | if(!this.messages[channel]) 22 | return false; 23 | 24 | return this.messages[channel].shift(); 25 | } 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /replacements.example.js: -------------------------------------------------------------------------------- 1 | exports.irc_to_slack = function(message, channel) { 2 | 3 | return message; 4 | } 5 | --------------------------------------------------------------------------------