heathrow 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +58 -0
  3. data/README.md +205 -0
  4. data/bin/heathrow +42 -0
  5. data/bin/heathrowd +283 -0
  6. data/docs/ARCHITECTURE.md +1172 -0
  7. data/docs/DATABASE_SCHEMA.md +685 -0
  8. data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
  9. data/docs/DISCORD_SETUP.md +142 -0
  10. data/docs/GMAIL_OAUTH_SETUP.md +120 -0
  11. data/docs/PLUGIN_SYSTEM.md +1370 -0
  12. data/docs/PROJECT_PLAN.md +1022 -0
  13. data/docs/README.md +417 -0
  14. data/docs/REDDIT_SETUP.md +174 -0
  15. data/docs/REPLY_FORWARD.md +182 -0
  16. data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
  17. data/heathrow.gemspec +34 -0
  18. data/heathrowd.service +21 -0
  19. data/img/heathrow.svg +95 -0
  20. data/img/rss_threaded.png +0 -0
  21. data/img/sources.png +0 -0
  22. data/lib/heathrow/address_book.rb +42 -0
  23. data/lib/heathrow/config.rb +332 -0
  24. data/lib/heathrow/database.rb +731 -0
  25. data/lib/heathrow/database_new.rb +392 -0
  26. data/lib/heathrow/event_bus.rb +175 -0
  27. data/lib/heathrow/logger.rb +122 -0
  28. data/lib/heathrow/message.rb +176 -0
  29. data/lib/heathrow/message_composer.rb +399 -0
  30. data/lib/heathrow/message_organizer.rb +774 -0
  31. data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
  32. data/lib/heathrow/notmuch.rb +45 -0
  33. data/lib/heathrow/oauth2_smtp.rb +254 -0
  34. data/lib/heathrow/plugin/base.rb +212 -0
  35. data/lib/heathrow/plugin_manager.rb +141 -0
  36. data/lib/heathrow/poller.rb +93 -0
  37. data/lib/heathrow/smtp_sender.rb +204 -0
  38. data/lib/heathrow/source.rb +39 -0
  39. data/lib/heathrow/sources/base.rb +74 -0
  40. data/lib/heathrow/sources/discord.rb +357 -0
  41. data/lib/heathrow/sources/gmail.rb +294 -0
  42. data/lib/heathrow/sources/imap.rb +198 -0
  43. data/lib/heathrow/sources/instagram.rb +307 -0
  44. data/lib/heathrow/sources/instagram_fetch.py +101 -0
  45. data/lib/heathrow/sources/instagram_send.py +55 -0
  46. data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
  47. data/lib/heathrow/sources/maildir.rb +606 -0
  48. data/lib/heathrow/sources/messenger.rb +212 -0
  49. data/lib/heathrow/sources/messenger_fetch.js +297 -0
  50. data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
  51. data/lib/heathrow/sources/messenger_send.js +32 -0
  52. data/lib/heathrow/sources/messenger_send.py +100 -0
  53. data/lib/heathrow/sources/reddit.rb +461 -0
  54. data/lib/heathrow/sources/rss.rb +299 -0
  55. data/lib/heathrow/sources/slack.rb +375 -0
  56. data/lib/heathrow/sources/source_manager.rb +328 -0
  57. data/lib/heathrow/sources/telegram.rb +498 -0
  58. data/lib/heathrow/sources/webpage.rb +207 -0
  59. data/lib/heathrow/sources/weechat.rb +479 -0
  60. data/lib/heathrow/sources/whatsapp.rb +474 -0
  61. data/lib/heathrow/ui/application.rb +8098 -0
  62. data/lib/heathrow/ui/navigation.rb +8 -0
  63. data/lib/heathrow/ui/panes.rb +8 -0
  64. data/lib/heathrow/ui/source_wizard.rb +567 -0
  65. data/lib/heathrow/ui/threaded_view.rb +780 -0
  66. data/lib/heathrow/ui/views.rb +8 -0
  67. data/lib/heathrow/version.rb +3 -0
  68. data/lib/heathrow/wizards/discord_wizard.rb +193 -0
  69. data/lib/heathrow/wizards/slack_wizard.rb +140 -0
  70. data/lib/heathrow.rb +55 -0
  71. metadata +147 -0
@@ -0,0 +1,74 @@
1
+ module Heathrow
2
+ module Sources
3
+ class Base
4
+ attr_reader :name, :type, :config, :db
5
+
6
+ def initialize(name, config, db)
7
+ @name = name
8
+ @config = config
9
+ @db = db
10
+ @type = self.class.name.split('::').last.downcase
11
+ end
12
+
13
+ def fetch
14
+ raise NotImplementedError, "#{self.class} must implement #fetch"
15
+ end
16
+
17
+ def poll_interval
18
+ @config['poll_interval'] || 300
19
+ end
20
+
21
+ def enabled?
22
+ @config['enabled'] != false
23
+ end
24
+
25
+ def last_fetch
26
+ @config['last_fetch']
27
+ end
28
+
29
+ def update_last_fetch(time = Time.now)
30
+ @config['last_fetch'] = time.to_i
31
+ save_config
32
+ end
33
+
34
+ def save_config
35
+ source = @db.get_source_by_name(@name)
36
+ if source
37
+ sid = source['id'] || source[:id]
38
+ @db.update_source(sid, config: @config.to_json) if sid
39
+ end
40
+ end
41
+
42
+ protected
43
+
44
+ def store_message(external_id, title, content, timestamp = Time.now, metadata = {})
45
+ source = @db.get_source_by_name(@name)
46
+ return unless source
47
+
48
+ # Check if message already exists
49
+ existing = @db.get_messages(
50
+ source_id: source['id']
51
+ ).find { |m| m['external_id'] == external_id }
52
+
53
+ return if existing
54
+
55
+ # Store new message - insert_message expects an array in specific order
56
+ message_data = [
57
+ source['id'], # source_id
58
+ source['type'], # source_type
59
+ external_id, # external_id
60
+ metadata[:author], # sender
61
+ nil, # recipient
62
+ title, # subject
63
+ content, # content
64
+ metadata.to_json, # raw_data
65
+ nil, # attachments
66
+ timestamp, # timestamp
67
+ 0 # is_read
68
+ ]
69
+
70
+ @db.insert_message(message_data)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'fileutils'
7
+ require 'time'
8
+
9
+ module Heathrow
10
+ module Sources
11
+ class Discord
12
+ API_BASE = 'https://discord.com/api/v10'
13
+
14
+ def initialize(source)
15
+ @source = source
16
+ end
17
+
18
+ def name
19
+ 'Discord'
20
+ end
21
+
22
+ def description
23
+ 'Fetch messages from Discord servers and DMs'
24
+ end
25
+
26
+ def fetch_messages
27
+ token = @source.config['token']
28
+ is_bot = @source.config['is_bot'] || false
29
+ channels = @source.config['channels'] || []
30
+ guilds = @source.config['guilds'] || []
31
+ fetch_limit = @source.config['fetch_limit'] || 50
32
+
33
+ messages = []
34
+
35
+ # Get state file to track last message IDs per channel
36
+ heathrow_home = File.expand_path('~/.heathrow')
37
+ state_file = File.join(heathrow_home, 'state', "discord_#{@source.id}.json")
38
+ FileUtils.mkdir_p(File.dirname(state_file))
39
+
40
+ last_messages = if File.exist?(state_file)
41
+ JSON.parse(File.read(state_file))
42
+ else
43
+ {}
44
+ end
45
+
46
+ begin
47
+ # If specific channels are provided, fetch from those
48
+ if channels && !channels.empty?
49
+ channels_list = channels.is_a?(String) ? channels.split(',').map(&:strip) : channels
50
+ channels_list.each do |channel_id|
51
+ # Get channel info to determine if it's a guild channel or DM
52
+ channel_info = fetch_channel_info(token, channel_id, is_bot)
53
+
54
+ if channel_info
55
+ guild_id = channel_info['guild_id']
56
+ channel_name = channel_info['name'] || channel_id
57
+
58
+ # If it's a guild channel, get guild info
59
+ if guild_id
60
+ guild_info = fetch_guild_info(token, guild_id, is_bot)
61
+ guild_name = guild_info ? guild_info['name'] : "Server"
62
+ channel_messages = fetch_channel_messages(token, channel_id, last_messages[channel_id], fetch_limit, is_bot, guild_name, channel_name, guild_id)
63
+ else
64
+ # It's a DM channel
65
+ channel_messages = fetch_channel_messages(token, channel_id, last_messages[channel_id], fetch_limit, is_bot)
66
+ end
67
+ else
68
+ # Fallback if we can't get channel info
69
+ channel_messages = fetch_channel_messages(token, channel_id, last_messages[channel_id], fetch_limit, is_bot)
70
+ end
71
+
72
+ messages.concat(channel_messages)
73
+
74
+ # Update last message ID for this channel
75
+ if channel_messages.any?
76
+ last_messages[channel_id] = channel_messages.first[:external_id].split('_').last
77
+ end
78
+ end
79
+ end
80
+
81
+ # If guilds are provided, fetch all channels from those guilds
82
+ if guilds && !guilds.empty?
83
+ guilds_list = guilds.is_a?(String) ? guilds.split(',').map(&:strip) : guilds
84
+ guilds_list.each do |guild_id|
85
+ # Get guild info for proper naming
86
+ guild_info = fetch_guild_info(token, guild_id, is_bot)
87
+ guild_name = guild_info ? guild_info['name'] : "Guild #{guild_id}"
88
+
89
+ guild_channels = fetch_guild_channels(token, guild_id, is_bot)
90
+ guild_channels.each do |channel|
91
+ next unless channel['type'] == 0 # Only text channels
92
+
93
+ channel_id = channel['id']
94
+ channel_messages = fetch_channel_messages(token, channel_id, last_messages[channel_id], fetch_limit, is_bot, guild_name, channel['name'], guild_id)
95
+ messages.concat(channel_messages)
96
+
97
+ # Update last message ID for this channel
98
+ if channel_messages.any?
99
+ last_messages[channel_id] = channel_messages.first[:external_id].split('_').last
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ # If neither channels nor guilds specified, try to get DMs
106
+ if channels.empty? && guilds.empty?
107
+ dm_channels = fetch_dm_channels(token, is_bot)
108
+ dm_channels.each do |channel|
109
+ channel_id = channel['id']
110
+ channel_messages = fetch_channel_messages(token, channel_id, last_messages[channel_id], fetch_limit, is_bot)
111
+ messages.concat(channel_messages)
112
+
113
+ # Update last message ID for this channel
114
+ if channel_messages.any?
115
+ last_messages[channel_id] = channel_messages.first[:external_id].split('_').last
116
+ end
117
+ end
118
+ end
119
+
120
+ # Save state
121
+ File.write(state_file, JSON.generate(last_messages))
122
+
123
+ rescue => e
124
+ return [{
125
+ source_id: @source.id,
126
+ source_type: 'discord',
127
+ sender: 'Discord',
128
+ subject: 'Error',
129
+ content: "Failed to fetch messages: #{e.message}",
130
+ timestamp: Time.now.to_s,
131
+ is_read: 0
132
+ }]
133
+ end
134
+
135
+ messages
136
+ end
137
+
138
+ def test_connection
139
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
140
+ token = config['token']
141
+ is_bot = config['is_bot'] != false
142
+
143
+ begin
144
+ # Try to get user info
145
+ uri = URI("#{API_BASE}/users/@me")
146
+ request = Net::HTTP::Get.new(uri)
147
+ request['Authorization'] = is_bot ? "Bot #{token}" : token
148
+
149
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
150
+ http.request(request)
151
+ end
152
+
153
+ if response.is_a?(Net::HTTPSuccess)
154
+ user_data = JSON.parse(response.body)
155
+ username = user_data['username']
156
+ discriminator = user_data['discriminator']
157
+ { success: true, message: "Connected as #{username}##{discriminator}" }
158
+ else
159
+ { success: false, message: "Failed to connect: #{response.code} #{response.message}" }
160
+ end
161
+ rescue => e
162
+ { success: false, message: "Connection test failed: #{e.message}" }
163
+ end
164
+ end
165
+
166
+ def can_reply?
167
+ true
168
+ end
169
+
170
+ def send_message(to, subject, body, in_reply_to = nil)
171
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
172
+ token = config['token']
173
+ is_bot = config['is_bot'] != false
174
+
175
+ # Debug log
176
+ File.open('/tmp/heathrow_debug.log', 'a') do |f|
177
+ f.puts "\n=== DISCORD SEND MESSAGE #{Time.now} ==="
178
+ f.puts "To: #{to.inspect}"
179
+ f.puts "Subject: #{subject.inspect}"
180
+ f.puts "Body length: #{body.length}"
181
+ f.puts "In reply to: #{in_reply_to.inspect}"
182
+ end
183
+
184
+ # Parse the recipient - could be channel ID or username
185
+ channel_id = if to =~ /^\d+$/
186
+ to # Already a channel ID
187
+ else
188
+ # Try to extract channel ID from a formatted string like "#general (123456789)"
189
+ if to =~ /\((\d+)\)/
190
+ $1
191
+ else
192
+ return { success: false, message: "Discord: Need channel ID, got '#{to}'. Check the To: field in editor." }
193
+ end
194
+ end
195
+
196
+ # Build the message
197
+ message_data = {
198
+ content: body
199
+ }
200
+
201
+ # If replying, add reference
202
+ if in_reply_to
203
+ message_data[:message_reference] = {
204
+ message_id: in_reply_to
205
+ }
206
+ end
207
+
208
+ # Send the message
209
+ uri = URI("#{API_BASE}/channels/#{channel_id}/messages")
210
+ request = Net::HTTP::Post.new(uri)
211
+ request['Authorization'] = is_bot ? "Bot #{token}" : token
212
+ request['Content-Type'] = 'application/json'
213
+ request.body = message_data.to_json
214
+
215
+ begin
216
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
217
+ http.request(request)
218
+ end
219
+
220
+ if response.is_a?(Net::HTTPSuccess)
221
+ { success: true, message: "Message sent to Discord" }
222
+ else
223
+ error_data = JSON.parse(response.body) rescue {}
224
+ { success: false, message: "Failed to send: #{error_data['message'] || response.message}" }
225
+ end
226
+ rescue => e
227
+ { success: false, message: "Send failed: #{e.message}" }
228
+ end
229
+ end
230
+
231
+ private
232
+
233
+ def fetch_channel_messages(token, channel_id, last_message_id, limit, is_bot, guild_name = nil, channel_name = nil, guild_id = nil)
234
+ messages = []
235
+
236
+ uri = URI("#{API_BASE}/channels/#{channel_id}/messages")
237
+ params = { limit: [limit, 100].min }
238
+ params[:after] = last_message_id if last_message_id
239
+ uri.query = URI.encode_www_form(params)
240
+
241
+ response = make_request(uri, token, is_bot)
242
+ return messages unless response.is_a?(Net::HTTPSuccess)
243
+
244
+ channel_data = JSON.parse(response.body)
245
+ channel_data.reverse.each do |msg| # Reverse to get oldest first
246
+ # Skip system messages
247
+ next if msg['type'] != 0 && msg['type'] != 19 # 0 = normal, 19 = reply
248
+
249
+ author = msg['author']
250
+ sender = author['username']
251
+ sender += " (BOT)" if author['bot']
252
+
253
+ # Format channel display name
254
+ display_channel = channel_name || msg['channel_name'] || channel_id
255
+
256
+ # Determine if this is a DM based on whether we have guild info
257
+ is_dm = guild_name.nil? || guild_name.empty?
258
+
259
+ # Format content
260
+ content = msg['content']
261
+
262
+ # Add attachment info
263
+ if msg['attachments'] && !msg['attachments'].empty?
264
+ attachments = msg['attachments'].map { |a| a['filename'] }.join(', ')
265
+ content += "\n[Attachments: #{attachments}]"
266
+ end
267
+
268
+ # Add embed info
269
+ if msg['embeds'] && !msg['embeds'].empty?
270
+ content += "\n[Contains #{msg['embeds'].length} embed(s)]"
271
+ end
272
+
273
+ # Format recipient based on whether it's a guild channel or DM
274
+ recipient_display = if guild_name && !is_dm
275
+ "#{guild_name} ##{display_channel}" # Server #channel format with space
276
+ elsif is_dm
277
+ "DM" # Mark as direct message
278
+ else
279
+ display_channel
280
+ end
281
+
282
+ # Add guild_id to the raw message data if we have it
283
+ msg_with_guild = msg.dup
284
+ msg_with_guild['guild_id'] = guild_id if guild_id
285
+
286
+ message = {
287
+ source_id: @source.id,
288
+ source_type: 'discord',
289
+ external_id: "discord_#{channel_id}_#{msg['id']}",
290
+ sender: sender,
291
+ recipient: recipient_display,
292
+ subject: recipient_display,
293
+ content: content,
294
+ raw_data: msg_with_guild.to_json,
295
+ attachments: msg['attachments'] ? msg['attachments'].to_json : nil,
296
+ timestamp: Time.parse(msg['timestamp']),
297
+ is_read: 0,
298
+ metadata: {
299
+ guild_name: guild_name,
300
+ channel_name: display_channel,
301
+ channel_id: channel_id,
302
+ is_dm: is_dm
303
+ }.to_json
304
+ }
305
+
306
+ messages << message
307
+ end
308
+
309
+ messages
310
+ end
311
+
312
+ def fetch_channel_info(token, channel_id, is_bot)
313
+ uri = URI("#{API_BASE}/channels/#{channel_id}")
314
+ response = make_request(uri, token, is_bot)
315
+ return nil unless response.is_a?(Net::HTTPSuccess)
316
+
317
+ JSON.parse(response.body)
318
+ end
319
+
320
+ def fetch_guild_info(token, guild_id, is_bot)
321
+ uri = URI("#{API_BASE}/guilds/#{guild_id}")
322
+ response = make_request(uri, token, is_bot)
323
+ return nil unless response.is_a?(Net::HTTPSuccess)
324
+
325
+ JSON.parse(response.body)
326
+ end
327
+
328
+ def fetch_guild_channels(token, guild_id, is_bot)
329
+ uri = URI("#{API_BASE}/guilds/#{guild_id}/channels")
330
+ response = make_request(uri, token, is_bot)
331
+ return [] unless response.is_a?(Net::HTTPSuccess)
332
+
333
+ JSON.parse(response.body)
334
+ end
335
+
336
+ def fetch_dm_channels(token, is_bot)
337
+ uri = URI("#{API_BASE}/users/@me/channels")
338
+ response = make_request(uri, token, is_bot)
339
+ return [] unless response.is_a?(Net::HTTPSuccess)
340
+
341
+ JSON.parse(response.body)
342
+ end
343
+
344
+ def make_request(uri, token, is_bot)
345
+ http = Net::HTTP.new(uri.host, uri.port)
346
+ http.use_ssl = true
347
+
348
+ request = Net::HTTP::Get.new(uri)
349
+ auth_header = is_bot ? "Bot #{token}" : token
350
+ request['Authorization'] = auth_header
351
+ request['User-Agent'] = 'Heathrow/1.0'
352
+
353
+ http.request(request)
354
+ end
355
+ end
356
+ end
357
+ end