├── Procfile ├── README.md ├── app.json ├── package.json ├── plugins ├── filter │ ├── emoji.js │ ├── removeformatting.js │ └── smartquotes.js └── filters.js └── slack-twitter.js /Procfile: -------------------------------------------------------------------------------- 1 | node: node slack-twitter.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ==== 3 | 4 | 1. Create two channels in Slack. 5 | 1. Name the channel for your timeline something obvious like 'twitter_timeline'. 6 | 2. Name the channel for posting to Twitter something obvious like 'twitter_post'. 7 | 2. Get a Slack bot account. Visit https://my.slack.com/services/new and under "DIY Integrations & Customizations" click the "Add" button. Once you create this bot (name it something obvious like "twitter_bot") you will get an API token. 8 | 2. Invite the bot to the channels you created in step #1. To do that, go to each channel and, in the top 9 | drop-down for the channel, click "Invite others to this channel..." and select your bot created in 10 | step #2. 11 | 3. Associate a Twitter account with your Slack team. This is necessary so that Slack will unfurl the tweets, that is, it will show tweet text (and images that people tweet) from this account's timeline. Visit https://my.slack.com/services/twitter and then the "Authentications" tab to set this up. 12 | 4. Login to the Twitter website Application Management website at https://apps.twitter.com/ with the Twitter account that you want to post tweets to from Slack. (It can be a different Twitter account than the one in step #3.) Click "Create New App". Make sure to give it read and write permissions. 13 | 5. Set your environment variables. See the section below. 14 | 6. Start the bot! Use [PM2](https://github.com/Unitech/pm2) or [forever](https://github.com/foreverjs/forever) or something that will daemonize the bot. 15 | 16 | Or you could deploy it to Heroku by clicking the button: 17 | 18 | [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/sillygwailo/Slack-Twitter) 19 | 20 | **You still need to follow steps #1-6 in the above instructions before clicking the button.** 21 | 22 | Warning: All messages under 140 characters will get posted to Twitter. Only use a channel designated to post tweets from. 23 | 24 | Environment Variables 25 | ==== 26 | 27 | This bot uses environment variables to store Slack and Twitter tokens. If you want to run this software locally, use the following as a starting point. 28 | 29 | export SLACK_POST_CHANNEL=yourSlackChannelforPostingtoTwitterHere 30 | export SLACK_TIMELINE_CHANNEL=yourSlackChannelforReadingtheTwitterTimeline 31 | export SLACK_TOKEN=yourSlackTokenHere 32 | export TWITTER_CONSUMER_KEY=yourTwitterConsumerKeyHere 33 | export TWITTER_CONSUMER_SECRET=yourTwitterConsumerSecretHere 34 | export TWITTER_ACCESS_TOKEN_KEY=yourTwitterAccessTokenKeyHere 35 | export TWITTER_ACCESS_TOKEN_SECRET=yourTwitterAccessTokenSecretHere 36 | 37 | If you're deploying to Heroku, press the Deploy to Heroku button above and you can punch the tokens in the form. If you're deploying from the command line, use the following: 38 | 39 | heroku config:set SLACK_POST_CHANNEL=yourSlackChannelforPostingtoTwitterHere 40 | heroku config:set SLACK_TIMELINE_CHANNEL=yourSlackChannelforReadingtheTwitterTimeline 41 | heroku config:set SLACK_TOKEN=yourSlackTokenHere 42 | heroku config:set TWITTER_CONSUMER_KEY=yourTwitterConsumerKeyHere 43 | heroku config:set TWITTER_CONSUMER_SECRET=yourTwitterConsumerSecretHere 44 | heroku config:set TWITTER_ACCESS_TOKEN_KEY=yourTwitterAccessTokenKeyHere 45 | heroku config:set TWITTER_ACCESS_TOKEN_SECRET=yourTwitterAccessTokenSecretHere 46 | 47 | Usage 48 | ==== 49 | 50 | 1. Posting messages to the 'twitter_post' channel will evaluate whether the tweet meets Twitter's definition of 140 characters. That means that URLs are compressed down (and possibly up?) to be either 23 or 24 characters in length. You don't have to short URLs. (You don't have to in general either, not even for analytics, since http://analytics.twitter.com/ handles that now.) 51 | 2. Faving and unfaving. Starring a tweet in Slack will fave that tweet on Twitter. Unstarring it will unfave it. 52 | 3. Retweets in a timeline are handled by taking the URL of the retweeted tweet and saying who it was retweeted by. 53 | 4. No replies or retweeting is currently possible in the 'twitter_post' channel. Use a client for that. In the Slack mobile app, you can specify which Twitter app to use. If you know what you're doing with Automator on Mac OS X, you can add [a service to open a URL in Tweetbot](https://github.com/sillygwailo/Open-URL-in-Tweetbot.workflow) 54 | 55 | Known Issues 56 | ==== 57 | 58 | * A memory leak. You may have to manually restart the Node.js bot if tweets stop appearing or tweets no longer get sent from your Slack channel. 59 | * Starring a retweet does not currently work. 60 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Slack-Twitter", 3 | "description": "Node.js middleware to create a reading and writing Twitter client out of Slack.", 4 | "keywords": [ 5 | "Twitter", 6 | "Slack" 7 | ], 8 | "website": "http://notes.justagwailo.com/twitter/slack", 9 | "repository": "https://github.com/sillygwailo/Slack-Twitter", 10 | "env": { 11 | "TWITTER_ACCESS_TOKEN_KEY": { 12 | "description": "Twitter access token key, available from http://apps.twitter.com/" 13 | }, 14 | "TWITTER_ACCESS_TOKEN_SECRET": { 15 | "description": "Twitter access token secret, available from http://apps.twitter.com/" 16 | }, 17 | "TWITTER_CONSUMER_KEY": { 18 | "description": "Twitter consumer key, available from http://apps.twitter.com/" 19 | }, 20 | "TWITTER_CONSUMER_SECRET": { 21 | "description": "Twitter consumer secret, available from http://apps.twitter.com/" 22 | }, 23 | "SLACK_TOKEN": { 24 | "description": "Token for the bot, available from https://my.slack.com/services/new and under 'DIY Integrations & Customizations' click the 'Add' button." 25 | }, 26 | "SLACK_POST_CHANNEL": { 27 | "description": "Channel in Slack to post tweets from." 28 | }, 29 | "SLACK_TIMELINE_CHANNEL": { 30 | "description": "Channel in Slack to post tweets to." 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Slack-Twitter", 3 | "version": "0.0.5", 4 | "description": "A server to post tweets from your timline to a designated Slack channel.", 5 | "author": "Richard Eriksson ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/sillygwailo/Slack-Twitter.git" 9 | }, 10 | "dependencies": { 11 | "@slack/client": "4.0.0", 12 | "emoji-data": "0.2.0", 13 | "twit": "2.2.9", 14 | "twitter-text": "1.14.7" 15 | }, 16 | "scripts": { 17 | "start": "node slack-twitter.js" 18 | }, 19 | "engines": { 20 | "node": "8.2.1", 21 | "npm": "5.3.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /plugins/filter/emoji.js: -------------------------------------------------------------------------------- 1 | var filters = require(__dirname + '/../filters.js'); 2 | var Emoji = require('emoji-data'); 3 | 4 | var filter = { 5 | name: 'emoji', 6 | description: 'Replace emoji codes with the Unicode emoji.', 7 | execute: function(text) { 8 | text = text.replace(/(:\w+:)/g, (function(_this) { 9 | return function(match, emoji_code) { 10 | code = match.replace(/:/g, ''); 11 | emoji = Emoji.find_by_short_name(code)[0]; 12 | return Emoji.EmojiChar._unified_to_char(emoji.unified); 13 | } 14 | })(this)); 15 | return text; 16 | } 17 | }; 18 | 19 | filters.addFilter(filter); 20 | -------------------------------------------------------------------------------- /plugins/filter/removeformatting.js: -------------------------------------------------------------------------------- 1 | var filters = require(__dirname + '/../filters.js'); 2 | 3 | filter = { 4 | name: 'removeformatting', 5 | description: 'Use Slack\'s own alorithm to remove formatting from a message', 6 | execute: function(text) { 7 | // function from https://raw.githubusercontent.com/slackhq/hubot-slack/master/src/slack.coffee compiled to JavaScript 8 | // this, among other things, removes angle brackets from URLs that the Slack API passes over to this client 9 | text = text.replace(/<([@#!])?([^>|]+)(?:\|([^>]+))?>/g, (function(_this) { 10 | return function(m, type, link, label) { 11 | var channel, user; 12 | switch (type) { 13 | case '@': 14 | if (label) { 15 | return label; 16 | } 17 | user = Cl.getUserByID(link); 18 | if (user) { 19 | return "@" + user.name; 20 | } 21 | break; 22 | case '#': 23 | if (label) { 24 | return label; 25 | } 26 | channel = Cl.getChannelByID(link); 27 | if (channel) { 28 | return "\#" + channel.name; 29 | } 30 | break; 31 | case '!': 32 | if (link === 'channel' || link === 'group' || link === 'everyone') { 33 | return "@" + link; 34 | } 35 | break; 36 | default: 37 | link = link.replace(/^mailto:/, ''); 38 | if (label && -1 === link.indexOf(label)) { 39 | return "" + label + " (" + link + ")"; 40 | } else { 41 | return link; 42 | } 43 | } 44 | }; 45 | })(this));; 46 | return text; 47 | } 48 | } 49 | 50 | filters.addFilter(filter); -------------------------------------------------------------------------------- /plugins/filter/smartquotes.js: -------------------------------------------------------------------------------- 1 | var filters = require(__dirname + '/../filters.js'); 2 | 3 | var filter = { 4 | name: 'smartquotes', 5 | description: 'Replace quotes with smartquotes.', 6 | execute: function(text) { 7 | // Smart Quotes: http://www.leancrew.com/all-this/2010/11/smart-quotes-in-javascript/ 8 | text = text.replace(/(^|[-\u2014\s(\["])'/g, "$1\u2018"); // opening singles 9 | text = text.replace(/'/g, "\u2019"); // closing singles & apostrophes 10 | text = text.replace(/(^|[-\u2014/\[(\u2018\s])"/g, "$1\u201c"); // opening doubles 11 | text = text.replace(/"/g, "\u201d"); // closing doubles 12 | text = text.replace(/--/g, "\u2014"); // em-dashes 13 | return text; 14 | } 15 | }; 16 | 17 | filters.addFilter(filter); -------------------------------------------------------------------------------- /plugins/filters.js: -------------------------------------------------------------------------------- 1 | exports.filters = []; 2 | 3 | exports.addFilter = function(filter) { 4 | exports.filters.push(filter); 5 | } 6 | -------------------------------------------------------------------------------- /slack-twitter.js: -------------------------------------------------------------------------------- 1 | var S = require('@slack/client'); 2 | var RtmClient = S.RtmClient; 3 | var WebClient = S.WebClient; 4 | var CLIENT_EVENTS = S.CLIENT_EVENTS; 5 | 6 | var Twit = require('twit'); 7 | var TwitterText = require('twitter-text'); 8 | var U = require('url'); 9 | 10 | var twitterOptions = { 11 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 12 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 13 | access_token: process.env.TWITTER_ACCESS_TOKEN_KEY, 14 | access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET 15 | } 16 | 17 | var T = new Twit(twitterOptions); 18 | 19 | var slackOptions = { 20 | token: process.env.SLACK_TOKEN, 21 | autoReconnect: true, 22 | autoMark: true, 23 | post_channel: process.env.SLACK_POST_CHANNEL, 24 | timeline_channel: process.env.SLACK_TIMELINE_CHANNEL 25 | } 26 | 27 | var fs = require('fs'); 28 | var MemoryDataStore = S.MemoryDataStore; 29 | var rtm = new RtmClient(slackOptions.token, { 30 | logLevel: 'error', 31 | dataStore: new MemoryDataStore() 32 | } 33 | ); 34 | 35 | var web = new WebClient(slackOptions.token); 36 | 37 | rtm.login(); 38 | rtm.on('open', function() { 39 | 40 | }); 41 | rtm.on('close', function () { 42 | console.log('Connection closed, retrying...'); 43 | rtm.reconnect(); 44 | }); 45 | 46 | stream = T.stream('user'); 47 | 48 | stream.on('tweet', function(tweet) { 49 | var channel = rtm.dataStore.getChannelByName(slackOptions.timeline_channel); 50 | if (typeof(tweet.retweeted_status) != 'undefined') { 51 | web.chat.postMessage(channel.id, 'https://twitter.com/' + tweet.retweeted_status.user.screen_name + '/status/' + tweet.retweeted_status.id_str + ' RT by https://twitter.com/' + tweet.user.screen_name, function(err, res) { 52 | if (err) { 53 | console.log('Error:', err); 54 | } else { 55 | console.log('Message sent: ', res); 56 | } 57 | }); 58 | if (typeof(tweet.retweeted_status.quoted_status_id_str) != 'undefined') { 59 | T.get('/statuses/show/' + tweet.retweeted_status.quoted_status_id_str, {}, function(error, quoted_tweet, response) { 60 | var channel = rtm.dataStore.getChannelByName(slackOptions.timeline_channel); 61 | web.chat.postMessage(channel.id, 'Quoted tweet inside RT: https://twitter.com/' + quoted_tweet.user.screen_name + '/status/' + quoted_tweet.id_str, function(err, res) { 62 | if (err) { 63 | console.log('Error:', err); 64 | } else { 65 | console.log('Message sent: ', res); 66 | } 67 | }); 68 | }); 69 | } 70 | } 71 | else { 72 | if (typeof(tweet.user) != 'undefined') { 73 | web.chat.postMessage(channel.id, 'https://twitter.com/' + tweet.user.screen_name + '/status/' + tweet.id_str, 74 | function(err, res) { 75 | if (err) { 76 | console.log('Error:', err); 77 | } else { 78 | console.log('Message sent: ', res); 79 | } 80 | }); 81 | if (typeof(tweet.quoted_status) != 'undefined') { 82 | web.chat.postMessage(channel.id, 'Quoted tweet: https://twitter.com/' + tweet.quoted_status.user.screen_name + '/status/' + tweet.quoted_status.id_str, function(err, res) { 83 | if (err) { 84 | console.log('Error:', err); 85 | } else { 86 | console.log('Message sent: ', res); 87 | } 88 | }); 89 | } 90 | } 91 | } 92 | channel = null; 93 | }); 94 | 95 | stream.on('error', function(error) { 96 | console.log('Twitter stream error: ' + error); 97 | }); 98 | 99 | rtm.on('star_added', function(event) { 100 | if (event.item.type == 'message') { 101 | path = U.parse(event.item.message.attachments[0].author_link).path.split('/'); 102 | T.post('favorites/create', { id: path[3] }, function(error, data, response) { 103 | if (error) { 104 | console.log('Error faving tweet: ' + error); 105 | } 106 | }); 107 | path = null; 108 | } 109 | }); 110 | rtm.on('star_removed', function(event) { 111 | if (event.item.type == 'message') { 112 | path = U.parse(event.item.message.attachments[0].author_link).path.split('/'); 113 | T.post('favorites/destroy', { id: path[3] }, function(error, data, response) { 114 | if (error) { 115 | console.log('Error unfaving tweet: ' + error); 116 | } 117 | }); 118 | path = null; 119 | } 120 | }); 121 | rtm.on('message', function(message) { 122 | the_channel = rtm.dataStore.getChannelByName(slackOptions.post_channel); 123 | if (message.channel == the_channel.id && (message.subtype != 'message_changed' && message.subtype != 'bot_message' && message.subtype != 'channel_join')) { 124 | fs.readdir(__dirname + '/plugins/filter', function (error, files) { 125 | text = message.text; 126 | files.forEach(function (file) { 127 | require(__dirname + '/plugins/filter/' + file); 128 | }); 129 | filters = require(__dirname + '/plugins/filters.js').filters; 130 | filters.forEach(function(filter) { 131 | text = filter.execute(text); 132 | }); 133 | if (TwitterText.getTweetLength(text) <= 140) { 134 | T.post('statuses/update', { status: text }, function(error, data, response) { 135 | if (error) { 136 | console.log('Posting tweet error: ' + error); 137 | } 138 | }); 139 | } 140 | else { 141 | channel = rtm.dataStore.getChannelById(message.channel); 142 | web.chat.postMessage(channel.id, "The tweet was too long! Character count: " + TwitterText.getTweetLength(message.text), function(err, res) { 143 | if (err) { 144 | console.log('Error:', err); 145 | } else { 146 | console.log('Message sent: ', res); 147 | } 148 | }); 149 | channel = null; 150 | } // If message longer than 140 character 151 | }); 152 | } // Message type. 153 | }); 154 | --------------------------------------------------------------------------------