├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── bin ├── fetch.rb └── tweet.rb └── config ├── procmail.rc.sample ├── username.header.erb.sample ├── username.html.erb.sample ├── username.txt.erb.sample └── username.yml.sample /.gitignore: -------------------------------------------------------------------------------- 1 | config/*.erb 2 | config/*.yml 3 | config/*.status 4 | config/*.log 5 | **/.DS_Store 6 | /.bundle/ 7 | /.ruby-version 8 | /.DS_Store 9 | /vendor/ 10 | *.eml 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "twitter" 3 | gem "oj", "~> 1.3.4" 4 | gem "mail", "= 2.5.3" 5 | gem "activesupport" 6 | gem "iconv" -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (3.2.13) 5 | i18n (= 0.6.1) 6 | multi_json (~> 1.0) 7 | faraday (0.8.7) 8 | multipart-post (~> 1.1) 9 | i18n (0.6.1) 10 | iconv (1.0.4) 11 | mail (2.5.3) 12 | i18n (>= 0.4.0) 13 | mime-types (~> 1.16) 14 | treetop (~> 1.4.8) 15 | mime-types (1.23) 16 | multi_json (1.7.6) 17 | multipart-post (1.2.0) 18 | oj (1.3.7) 19 | polyglot (0.3.3) 20 | simple_oauth (0.2.0) 21 | treetop (1.4.14) 22 | polyglot 23 | polyglot (>= 0.3.1) 24 | twitter (4.8.0) 25 | faraday (~> 0.8, < 0.10) 26 | multi_json (~> 1.0) 27 | simple_oauth (~> 0.2) 28 | 29 | PLATFORMS 30 | ruby 31 | 32 | DEPENDENCIES 33 | activesupport 34 | iconv 35 | mail (= 2.5.3) 36 | oj (~> 1.3.4) 37 | twitter 38 | 39 | BUNDLED WITH 40 | 1.10.6 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BURNSIDE 2 | Copyright (C) 2012 Panic, Inc. 3 | All rights reserved. 4 | 5 | Redistribution and use, with or without modification, are permitted 6 | provided that the following conditions are met: 7 | 8 | * The software, and any works derived from the software, may not 9 | be sold without specific prior written permission from Panic Inc. 10 | 11 | * Redistributions must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of Panic Inc nor the names of its contributors 16 | may be used to endorse or promote works derived from this software 17 | without specific prior written permission from Panic Inc. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 22 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PANIC 23 | INC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 24 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 25 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 26 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 27 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 29 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 30 | DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Burnside 2 | ======== 3 | 4 | Panic's Burnside bridges Twitter to email and back. 5 | 6 | It's particularly useful for companies that provide Twitter support. By handling @questions through an e-mail client, support agents can reply to tweets much quicker, answered tweets can be tracked by "Archiving" them or moving them to a subfolder, multiple agents can work out of the same mailbox (via IMAP), and an easily-searchable archive of tweets can be built over time. 7 | 8 | **Burnside is intended for shell-level/e-mail server administrators**, and requires: 9 | 10 | - Procmail (already working with user accounts on an IMAP/SMTP server) 11 | - Cron 12 | - Ruby 13 | - SMTP server that supports "+" character recipient delimiter for sub-addressing 14 | 15 | Other configurations may be possible based on your expertise. 16 | 17 | How it works 18 | ------------ 19 | 20 | ### Receiving 21 | Periodically, the `fetch.rb` script is run and it generates an email for every new @mention. We'll assume you're fetching from a twitter account called `PanicGWTest` and sending mail to `twitter@panicburnside.org`. The generated email will have the following headers 22 | 23 | From: Example User 24 | 25 | We use username+token sub-addressing to provide a unique `from` address. 26 | 27 | To: twitter@panicburnside.org 28 | Message-ID: <1234567890@twitter-super_secret_auth_token.panicburnside.org> 29 | 30 | The `Message-ID` contains the tweet status ID as well as the `auth_token`. 31 | 32 | Subject: Tweet from Example User (@ohhaiexample) 33 | X-Burnside: ignore 34 | 35 | The `X-Burnside: ignore` header prevents our procmail recipe from processing these emails as they come in. This is necessary in the case where your `from` and `to` mailboxes are the same. 36 | 37 | ### Sending 38 | When a reply to a tweet is received by procmail it's parsed by `tweet.rb` and a new tweet is generated. If the new tweet is over 140 characters then the email is bounced. If the `In-Reply-To` header doesn't contain the `auth_token` then the message is bounced. 39 | 40 | The format of the new tweet is 41 | 42 | ```ruby 43 | #{@to} #{@reply_text} #{@sig} 44 | ``` 45 | 46 | `sig` is a space followed by an em-dash followed by the first letter of the sender's name in the `From:` header of the reply. Currently `tweet.rb` splits the reply text using the regex `/(.*)On.*wrote:.*/m` so this will obviously be a problem for non-English users. 47 | 48 | 49 | Dependencies 50 | ------------ 51 | 52 | ```console 53 | sudo gem install bundler 54 | git clone https://github.com/panicinc/burnside 55 | cd burnside 56 | bundle install --deployment 57 | ``` 58 | 59 | Twitter Application 60 | ------------------- 61 | Create a new [Twitter Application](https://dev.twitter.com/apps/new) and get its consumer key and secret. Then, authorize it within your account to make use of it. 62 | 63 | Configuration 64 | ------------- 65 | 66 | Copy the .sample files and rename them. For these instructions we'll assume you make a configuration set named "PanicGWTest". You should end up with the following files: 67 | 68 | - PanicGWTest.header.erb 69 | - PanicGWTest.html.erb 70 | - PanicGWTest.txt.erb 71 | - PanicGWTest.yml 72 | 73 | Fill in the details of the .yml file with the OAuth information you got when you created and authorized your application. 74 | 75 | - The `mail.delivery` sections contains basic IMAP authentication details. 76 | 77 | - `to:` should be the email address that will receive the messages generated by `fetch.rb`. 78 | 79 | - `mailbox` is the mail account/alias that messages will be "from". An agent's reply will be sent here. 80 | 81 | - The `auth_token` field should contain a randomly generated string of any length. An easy way to make one on Mac OS is with 82 | 83 | dd if=/dev/urandom count=4 2>/dev/null | openssl dgst -sha1 84 | 85 | Usage 86 | ----- 87 | 88 | To bridge your tweets to email you'll run bin/fetch.rb as follows 89 | 90 | ./bin/fetch.rb -c config/PanicGWTest.yml 91 | 92 | This task could be run via cron like 93 | 94 | */5 * * * * cd $HOME/burnside; ./bin/fetch.rb -c config/PanicGWTest.yml 95 | 96 | To handle incoming email, you'll need to setup Procmail. There's a sample recipe in the config folder. Alternatively, you could write a script that accesses your IMAP inbox and then pipe messages through `tweet.rb`. Frankly, if you don't already have Procmail setup then this might be a less painful approach. 97 | 98 | Caveats 99 | ------- 100 | 101 | Burnside was written to be used with Apple Mail, so there are a few assumptions about how it formats emails. 102 | 103 | Contributing 104 | ------------ 105 | 106 | Feel free to fork and send us pull requests 107 | 108 | Bug Reporting 109 | ------------- 110 | 111 | Burnside is an unsupported, unofficial Panic product. But, if you can't contribute directly, please file bugs at https://hive.panic.com in the Burnside project. You have to register first, via the [Register](https://hive.panic.com/account/register) link in the upper-right hand corner. 112 | 113 | Extras 114 | ------ 115 | 116 | See [Burnside Mail Plugin](https://github.com/panicinc/burnside-plugin), our plugin for Apple Mail that adds a character count to ensure that your Tweets aren't too long. 117 | -------------------------------------------------------------------------------- /bin/fetch.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | gem 'twitter' 6 | 7 | require 'twitter' 8 | require 'oj' 9 | require 'mail' 10 | require 'optparse' 11 | require 'pp' 12 | require 'date' 13 | require 'uri' 14 | require 'yaml' 15 | require 'erb' 16 | require 'logger' 17 | 18 | options = {} 19 | 20 | #! Options Parsing 21 | optparse = OptionParser.new do |opts| 22 | 23 | opts.banner = "Usage: #{__FILE__} -c CONFIG_FILE [options]" 24 | 25 | opts.separator "" 26 | opts.separator "Required options:" 27 | 28 | options[:config_file] = nil; 29 | opts.on("-c", "--config CONFIG_FILE", 30 | "configuration file to use") do |user| 31 | options[:config_file] = user 32 | end 33 | 34 | opts.separator "" 35 | opts.separator "Common options:" 36 | 37 | options[:verbose] = false 38 | opts.on( '-v', '--verbose', 'Output more information' ) do 39 | options[:verbose] = true 40 | end 41 | 42 | options[:log] = false 43 | opts.on( '-l', '--log', 'Log script progess' ) do 44 | options[:log] = true 45 | end 46 | 47 | options[:dryrun] = false 48 | opts.on( '-n', '--dry-run', "Don't send any email" ) do 49 | options[:dryrun] = true 50 | end 51 | 52 | options[:test] = false 53 | opts.on( '-t', '--test', "Ignore status file and always fetch tweets" ) do 54 | options[:test] = true 55 | end 56 | 57 | opts.on_tail('-h', '--help', 'Display this help') do 58 | puts opts 59 | exit 60 | end 61 | 62 | end 63 | 64 | begin 65 | optparse.parse! 66 | mandatory = [:config_file] 67 | missing = mandatory.select{ |param| options[param].nil? } 68 | if not missing.empty? 69 | $stderr.puts "Missing options: #{missing.join(', ')}" 70 | $stderr.puts 71 | $stderr.puts optparse 72 | exit(1) 73 | end 74 | rescue OptionParser::InvalidOption, OptionParser::MissingArgument 75 | $stderr.puts $!.to_s 76 | $stderr.puts optparse 77 | exit(1) 78 | end 79 | 80 | @config = YAML.load_file(options[:config_file]) 81 | 82 | statusFile = 'config/' + @config['username'] + ".status" 83 | header_templateFile = 'config/' + @config['username'] + ".header.erb" 84 | html_templateFile = 'config/' + @config['username'] + ".html.erb" 85 | text_templateFile = 'config/' + @config['username'] + ".txt.erb" 86 | 87 | if options[:log] 88 | log = Logger.new('config/' + @config['username'] + '.log', 'weekly') 89 | else 90 | log = Logger.new(STDOUT) 91 | end 92 | 93 | log.info "Starting Up" 94 | 95 | lastStatusID = (File.exists?(statusFile) && !options[:test]) ? IO.read(statusFile) : nil 96 | 97 | begin 98 | @client = Twitter::Client.new(@config['oauth']) 99 | @client.user(@config['username']) 100 | rescue Exception => ex 101 | log.error "An error occured while configuring the client: #{ex.message}" 102 | log.error "Bailing Out!" 103 | exit(1) 104 | end 105 | 106 | log.info "Last Status ID of #{@config['username']} is #{lastStatusID}" 107 | 108 | begin 109 | mentions = lastStatusID ? @client.mentions(:since_id => lastStatusID, :count => 200) : @client.mentions(:count => 1) 110 | rescue Exception => ex 111 | log.error "An error occured while fetching the mentions: #{ex.message}" 112 | log.error "Bailing Out!" 113 | exit(1) 114 | end 115 | 116 | if mentions.count == 0 117 | log.info "No mentions found; shutting down" 118 | exit 119 | else 120 | log.info "Found #{mentions.count} mentions" 121 | end 122 | 123 | latestStatusID = lastStatusID 124 | 125 | header_renderer = ERB.new(IO.read(header_templateFile)) 126 | html_renderer = ERB.new(IO.read(html_templateFile)) 127 | text_renderer = ERB.new(IO.read(text_templateFile)) 128 | 129 | mentions.reverse.each do |mention| 130 | 131 | @mention = mention 132 | 133 | @status_url = "https://twitter.com/#{mention.user.screen_name}/status/#{mention.id}" 134 | 135 | mail = Mail.new(header_renderer.result()) 136 | 137 | text_part = Mail::Part.new do 138 | content_type 'text/plain; charset=UTF-8' 139 | body text_renderer.result() 140 | end 141 | 142 | html_part = Mail::Part.new do 143 | body html_renderer.result() 144 | content_type 'text/html; charset=UTF-8' 145 | end 146 | 147 | mail.text_part = text_part 148 | mail.html_part = html_part 149 | 150 | mail.delivery_method @config['mail']['delivery_method'], @config['mail']['delivery_configuration'] 151 | mail.delivery_method :test if (options[:dryrun]) 152 | 153 | log.info "New tweet from #{mention.user.screen_name}: #{mention.id} #{mention.created_at}" 154 | 155 | puts mail.to_s if options[:verbose] 156 | 157 | begin 158 | mail.deliver 159 | latestStatusID = mention.id 160 | rescue Exception => ex 161 | log.error "An error occured during delivery: #{ex.message}" 162 | end 163 | end 164 | File.open(statusFile, 'w') {|f| f.write(latestStatusID) } unless options[:dryrun] 165 | log.info "Shutting Down" 166 | -------------------------------------------------------------------------------- /bin/tweet.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | require "twitter" 6 | require 'optparse' 7 | require 'pp' 8 | require 'oj' 9 | require 'mail' 10 | require 'uri' 11 | require 'erb' 12 | require 'iconv' 13 | require 'active_support' 14 | 15 | class String 16 | def display_length 17 | ActiveSupport::Multibyte::Chars.new(self).normalize(:c).length 18 | end 19 | end 20 | 21 | options = {} 22 | 23 | optparse = OptionParser.new do |opts| 24 | 25 | opts.banner = "Usage: #{__FILE__} -c CONFIG_FILE [options]" 26 | 27 | opts.separator "" 28 | opts.separator "Required options:" 29 | 30 | options[:config_file] = nil; 31 | opts.on("-c", "--config CONFIG_FILE", 32 | "configuration file to use") do |user| 33 | options[:config_file] = user 34 | end 35 | 36 | opts.separator "" 37 | opts.separator "Common options:" 38 | 39 | options[:verbose] = false 40 | opts.on( '-v', '--verbose', 'Output more information' ) do 41 | options[:verbose] = true 42 | end 43 | 44 | options[:dryrun] = false 45 | opts.on( '-n', '--dry-run', "Don't send any tweets" ) do 46 | options[:dryrun] = true 47 | end 48 | 49 | opts.on_tail('-h', '--help', 'Display this help') do 50 | puts opts 51 | exit 52 | end 53 | 54 | end 55 | 56 | begin 57 | optparse.parse! 58 | mandatory = [:config_file] 59 | missing = mandatory.select{ |param| options[param].nil? } 60 | if not missing.empty? 61 | puts "Missing options: #{missing.join(', ')}" 62 | puts 63 | puts optparse 64 | exit 65 | end 66 | rescue OptionParser::InvalidOption, OptionParser::MissingArgument 67 | puts $!.to_s 68 | puts optparse 69 | exit 70 | end 71 | 72 | @config = YAML.load_file(options[:config_file]) 73 | 74 | # Exit if there's nothing on STDIN 75 | exit(1) unless STDIN.fcntl(Fcntl::F_GETFL, 0) == 0 76 | 77 | mail = Mail.new(STDIN.read()) 78 | charset = "utf-8" 79 | 80 | # Capture the twitter handle from the To: header 81 | to_regex = /#{@config['mail']['mailbox']}\+([A-Za-z0-9_]+)@#{@config['mail']['delivery_configuration'][:domain]}/ 82 | 83 | to_line = mail.to.first 84 | matches = to_regex.match(to_line) 85 | 86 | if !matches 87 | $stderr.puts "The To: address (#{to_line}) isn't in the correct format. Make sure the account matches the setting in your config file: #{@config['mail']['mailbox']}" 88 | exit(1) 89 | end 90 | 91 | @to = "@" + matches[1] 92 | 93 | # Form the signature from the first letter of the sender's name 94 | first_char = mail[:from].decoded.chars.first 95 | first_char = mail[:from].decoded.chars[1] if first_char == '"' 96 | 97 | @sig = "—" + first_char 98 | 99 | # Capture the status id of the tweet we're replying to 100 | reply_status_regex = /^<(\d+)@#{@config['mail']['mailbox']}-#{@config['auth_token']}\.#{@config['mail']['delivery_configuration'][:domain]}>/ 101 | 102 | if !(mail[:in_reply_to].decoded =~ reply_status_regex) 103 | $stderr.puts "The In-Reply-To header isn't in the correct format" 104 | exit(1) 105 | end 106 | 107 | reply_status_id = reply_status_regex.match(mail[:in_reply_to].decoded)[1] 108 | 109 | # We need to extract the text part from the message 110 | decoded_body = "" 111 | 112 | if (mail.multipart?) 113 | mail.parts.each do |part| 114 | if part.content_type =~ /plain/ 115 | decoded_body = part.body.decoded 116 | charset = part.content_type_parameters["charset"] 117 | end 118 | end 119 | else 120 | decoded_body = mail.body.decoded 121 | charset = mail.content_type_parameters["charset"] 122 | end 123 | 124 | # Apple Mail sends messages in windows-1252 when there are non-ascii characters present so we need to re-encode to UTF-8 125 | matches = /(.*)On .* wrote:.*/m.match(decoded_body) 126 | 127 | if !matches 128 | $stderr.puts "Couldn't parse the message body" 129 | exit(1) 130 | end 131 | 132 | untrusted_body = matches[1] 133 | 134 | # kill the > that 10.10 mail adds 135 | untrusted_body.chop!.chop! if untrusted_body[-2,2] == "> " 136 | 137 | # kill the extra links that 10.10 mail adds 138 | untrusted_body.gsub!(/\/, '') 139 | 140 | untrusted_body = untrusted_body.strip 141 | 142 | ic = Iconv.new('UTF-8', charset) 143 | 144 | body = ic.iconv(untrusted_body + ' ')[0..-2] 145 | 146 | # Strip out the signature 147 | signature_regex = /(.*)--/m 148 | if signature_regex.match(body) 149 | @reply_text = signature_regex.match(body)[1].strip 150 | else 151 | @reply_text = body 152 | end 153 | 154 | msg = "#{@to} #{@reply_text} #{@sig}" 155 | 156 | puts msg if options[:verbose] 157 | 158 | char_count = msg.display_length 159 | 160 | if char_count > 140 161 | $stderr.puts "Your message is too long: #{char_count} characters" 162 | $stderr.puts msg 163 | $stderr.puts "----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|" 164 | exit(1) 165 | end 166 | 167 | # Update the Twitter status 168 | @client = Twitter::Client.new(@config['oauth']) 169 | @client.user(@config['username']) 170 | 171 | begin 172 | @client.update(msg, {:in_reply_to_status_id => reply_status_id, :trim_user => 1}) unless options[:dryrun] 173 | rescue 174 | $stderr.print "Unable to post twitter update: " + $! 175 | end -------------------------------------------------------------------------------- /config/procmail.rc.sample: -------------------------------------------------------------------------------- 1 | # back up everything. You can disable this once you're sure it's working correctly. 2 | :0 c 3 | backup 4 | 5 | # Catch bounces and put them in the error box 6 | :0 7 | * ^FROM_DAEMON 8 | errors 9 | 10 | :0 11 | * !^X-Burnside: ignore 12 | { 13 | # Pipe the email into tweet.rb 14 | :0 W 15 | * !^FROM_DAEMON 16 | ERROR=|/usr/bin/ruby $HOME/burnside/bin/tweet.rb -c $HOME/burnside/config/username.yml 17 | # If that fails 18 | :0 e 19 | { 20 | # Bounce the message with a data format error and copy the offending message to an errors folder 21 | EXITCODE=65 22 | :0 23 | errors 24 | } 25 | # store successful tweets 26 | :0: 27 | successful 28 | } 29 | -------------------------------------------------------------------------------- /config/username.header.erb.sample: -------------------------------------------------------------------------------- 1 | Date: <%= @mention.created_at %> 2 | From: "<%= @mention.user.name %>" <<%= @config['mail']['mailbox'] %>+<%= @mention.user.screen_name%>@<%= @config['mail']['delivery_configuration'][:domain] %>> 3 | Subject: Tweet from <%= @mention.user.name %> (@<%= @mention.user.screen_name %>) 4 | To: <%= @config['mail']['to'] %> 5 | Message-Id: <<%= @mention.id %>@<%= @config['mail']['mailbox'] %>-<%= @config['auth_token'] %>.<%= @config['mail']['delivery_configuration'][:domain] %>> 6 | X-Burnside: ignore -------------------------------------------------------------------------------- /config/username.html.erb.sample: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

7 | <%= @mention.text %> 8 |

9 |

10 | 11 |

<%= @mention.user.name %>

12 |

<%= @mention.user.screen_name %>

13 | <%= @mention.created_at.ctime %> 14 |

15 | 16 | -------------------------------------------------------------------------------- /config/username.txt.erb.sample: -------------------------------------------------------------------------------- 1 | <%= @mention.text %> 2 | 3 | <%= @mention.created_at %> - <%= @status_url %> 4 | -------------------------------------------------------------------------------- /config/username.yml.sample: -------------------------------------------------------------------------------- 1 | username: tech_support 2 | 3 | oauth: 4 | :consumer_key: CONSUMER_KEY 5 | :consumer_secret: CONSUMER_SECRET 6 | :oauth_token: OAUTH_TOKE 7 | :oauth_token_secret: OAUTH_TOKEN_SECRET 8 | 9 | mail: 10 | delivery_method: :smtp 11 | delivery_configuration: 12 | :address: mail.example.com 13 | :port: 587 14 | :domain: example.com 15 | :user_name: tech_support 16 | :password: tech_pass 17 | :authentication: plain 18 | :enable_starttls_auto: true 19 | to: support@example.com 20 | mailbox: twitter 21 | 22 | # a password that authenticates your email replies to the system 23 | auth_token: AUTH_TOKEN --------------------------------------------------------------------------------