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,100 @@
1
+ #!/usr/bin/env python3
2
+ """Send a Messenger DM via Firefox Marionette.
3
+
4
+ Connects to Firefox, finds the Messenger tab, navigates to the thread,
5
+ types the message and presses Enter.
6
+
7
+ Usage: messenger_send.py <thread_id> <message>
8
+ Outputs JSON: { "success": true/false, "message": "..." }
9
+ """
10
+
11
+ import json
12
+ import sys
13
+ import time
14
+
15
+
16
+ def output(success, message):
17
+ print(json.dumps({"success": success, "message": message}))
18
+ sys.exit(0)
19
+
20
+
21
+ def main():
22
+ if len(sys.argv) < 3:
23
+ output(False, "Usage: messenger_send.py <thread_id> <message>")
24
+
25
+ thread_id = sys.argv[1]
26
+ message = sys.argv[2].strip()
27
+
28
+ if not thread_id or not message:
29
+ output(False, "Thread ID and message are required")
30
+
31
+ try:
32
+ from marionette_driver.marionette import Marionette
33
+ from marionette_driver.keys import Keys
34
+ except ImportError:
35
+ output(False, "marionette_driver not installed")
36
+
37
+ client = None
38
+ try:
39
+ client = Marionette(host='127.0.0.1', port=2828)
40
+ client.start_session()
41
+
42
+ # Find Messenger tab
43
+ msng_handle = None
44
+ for h in client.window_handles:
45
+ client.switch_to_window(h)
46
+ if 'messenger.com' in client.get_url():
47
+ msng_handle = h
48
+ break
49
+
50
+ if not msng_handle:
51
+ output(False, "No Messenger tab found in Firefox")
52
+
53
+ # Navigate to thread
54
+ client.navigate(f"https://www.messenger.com/t/{thread_id}")
55
+
56
+ # Wait for message input to appear
57
+ for _ in range(20):
58
+ found = client.execute_script("""
59
+ var el = document.querySelector('[role="textbox"][contenteditable="true"]');
60
+ return el ? true : false;
61
+ """)
62
+ if found:
63
+ break
64
+ time.sleep(0.5)
65
+ else:
66
+ output(False, "Could not find message input")
67
+
68
+ # Focus the input and set text via DOM, then press Enter
69
+ from marionette_driver.by import By
70
+ client.execute_script("""
71
+ var el = document.querySelector('[role="textbox"][contenteditable="true"]');
72
+ el.focus();
73
+ """)
74
+ time.sleep(0.2)
75
+
76
+ # Use clipboard to paste the message (avoids character interpretation issues)
77
+ import subprocess
78
+ proc = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE)
79
+ proc.communicate(message.encode())
80
+
81
+ el = client.find_element(By.CSS_SELECTOR, '[role="textbox"][contenteditable="true"]')
82
+ # Ctrl+V to paste, then Enter to send
83
+ el.send_keys(Keys.CONTROL + "v")
84
+ time.sleep(0.3)
85
+ el.send_keys(Keys.ENTER)
86
+
87
+ output(True, "Message sent via Messenger")
88
+
89
+ except Exception as e:
90
+ output(False, f"Messenger send error: {e}")
91
+ finally:
92
+ if client:
93
+ try:
94
+ client.delete_session()
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ if __name__ == '__main__':
100
+ main()
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+ require 'base64'
8
+ require 'time'
9
+
10
+ module Heathrow
11
+ module Sources
12
+ class Reddit
13
+ attr_reader :source, :last_fetch_time
14
+
15
+ def initialize(source)
16
+ @source = source
17
+ @config = source.config.is_a?(String) ? JSON.parse(source.config) : source.config
18
+ @last_fetch_time = Time.now
19
+ @access_token = nil
20
+ @token_expires_at = nil
21
+ end
22
+
23
+ def fetch_messages
24
+ messages = []
25
+
26
+ begin
27
+ # Get access token if needed
28
+ ensure_access_token
29
+
30
+ # Determine what to fetch based on mode
31
+ mode = @config['mode'] || 'subreddit'
32
+
33
+ case mode
34
+ when 'subreddit'
35
+ messages = fetch_subreddit_posts
36
+ when 'messages'
37
+ messages = fetch_private_messages
38
+ else
39
+ puts "Unknown Reddit mode: #{mode}" if ENV['DEBUG']
40
+ end
41
+
42
+ rescue => e
43
+ puts "Reddit fetch error: #{e.message}" if ENV['DEBUG']
44
+ puts e.backtrace.join("\n") if ENV['DEBUG']
45
+ end
46
+
47
+ messages
48
+ end
49
+
50
+ def test_connection
51
+ begin
52
+ ensure_access_token
53
+
54
+ # Test basic API access
55
+ data = make_api_request('/api/v1/me')
56
+
57
+ if data
58
+ if data['name']
59
+ { success: true, message: "Connected as u/#{data['name']}" }
60
+ else
61
+ { success: true, message: "Connected with read-only access" }
62
+ end
63
+ else
64
+ { success: false, message: "Failed to connect to Reddit API" }
65
+ end
66
+ rescue => e
67
+ { success: false, message: "Connection test failed: #{e.message}" }
68
+ end
69
+ end
70
+
71
+ def can_reply?
72
+ # Can reply to PMs if we have username/password auth
73
+ @config['username'] && @config['password']
74
+ end
75
+
76
+ def send_message(to, subject, body, in_reply_to = nil)
77
+ unless can_reply?
78
+ return { success: false, message: "Reddit requires username/password authentication to send messages" }
79
+ end
80
+
81
+ begin
82
+ ensure_access_token
83
+
84
+ if in_reply_to
85
+ # Reply to an existing message or comment
86
+ send_reply(in_reply_to, body)
87
+ else
88
+ # Send a new private message
89
+ send_private_message(to, subject, body)
90
+ end
91
+ rescue => e
92
+ { success: false, message: "Failed to send: #{e.message}" }
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def send_private_message(to, subject, body)
99
+ # Remove u/ prefix if present
100
+ recipient = to.sub(/^u\//, '')
101
+
102
+ uri = URI('https://oauth.reddit.com/api/compose')
103
+
104
+ http = Net::HTTP.new(uri.host, uri.port)
105
+ http.use_ssl = true
106
+
107
+ request = Net::HTTP::Post.new(uri)
108
+ request['Authorization'] = "Bearer #{@access_token}"
109
+ request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
110
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
111
+
112
+ request.set_form_data(
113
+ 'api_type' => 'json',
114
+ 'to' => recipient,
115
+ 'subject' => subject,
116
+ 'text' => body
117
+ )
118
+
119
+ response = http.request(request)
120
+
121
+ if response.is_a?(Net::HTTPSuccess)
122
+ data = JSON.parse(response.body)
123
+
124
+ if data['json'] && data['json']['errors'] && !data['json']['errors'].empty?
125
+ errors = data['json']['errors'].map { |e| e[1] }.join(', ')
126
+ { success: false, message: "Reddit API error: #{errors}" }
127
+ else
128
+ { success: true, message: "Message sent to u/#{recipient}" }
129
+ end
130
+ else
131
+ { success: false, message: "HTTP error: #{response.code} #{response.message}" }
132
+ end
133
+ end
134
+
135
+ def send_reply(thing_id, body)
136
+ # thing_id is the fullname of the thing to reply to (e.g., t1_xxx for comment, t4_xxx for PM)
137
+ uri = URI('https://oauth.reddit.com/api/comment')
138
+
139
+ http = Net::HTTP.new(uri.host, uri.port)
140
+ http.use_ssl = true
141
+
142
+ request = Net::HTTP::Post.new(uri)
143
+ request['Authorization'] = "Bearer #{@access_token}"
144
+ request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
145
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
146
+
147
+ request.set_form_data(
148
+ 'api_type' => 'json',
149
+ 'thing_id' => thing_id,
150
+ 'text' => body
151
+ )
152
+
153
+ response = http.request(request)
154
+
155
+ if response.is_a?(Net::HTTPSuccess)
156
+ data = JSON.parse(response.body)
157
+
158
+ if data['json'] && data['json']['errors'] && !data['json']['errors'].empty?
159
+ errors = data['json']['errors'].map { |e| e[1] }.join(', ')
160
+ { success: false, message: "Reddit API error: #{errors}" }
161
+ else
162
+ { success: true, message: "Reply sent" }
163
+ end
164
+ else
165
+ { success: false, message: "HTTP error: #{response.code} #{response.message}" }
166
+ end
167
+ end
168
+
169
+ def ensure_access_token
170
+ # Check if we need a new token
171
+ if @access_token.nil? || @token_expires_at.nil? || Time.now >= @token_expires_at
172
+ get_access_token
173
+ end
174
+ end
175
+
176
+ def get_access_token
177
+ # Reddit OAuth2 token endpoint
178
+ uri = URI('https://www.reddit.com/api/v1/access_token')
179
+
180
+ # Prepare the request
181
+ http = Net::HTTP.new(uri.host, uri.port)
182
+ http.use_ssl = true
183
+
184
+ request = Net::HTTP::Post.new(uri)
185
+
186
+ # Basic auth with client_id:client_secret
187
+ client_id = @config['client_id']
188
+ client_secret = @config['client_secret']
189
+ auth = Base64.strict_encode64("#{client_id}:#{client_secret}")
190
+ request['Authorization'] = "Basic #{auth}"
191
+ request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
192
+
193
+ # Different grant types based on whether we have user credentials
194
+ if @config['username'] && @config['password']
195
+ # Script app with username/password
196
+ request.set_form_data(
197
+ 'grant_type' => 'password',
198
+ 'username' => @config['username'],
199
+ 'password' => @config['password']
200
+ )
201
+ else
202
+ # Read-only access
203
+ request.set_form_data(
204
+ 'grant_type' => 'client_credentials'
205
+ )
206
+ end
207
+
208
+ response = http.request(request)
209
+
210
+ if response.is_a?(Net::HTTPSuccess)
211
+ token_data = JSON.parse(response.body)
212
+ @access_token = token_data['access_token']
213
+ # Token expires in 'expires_in' seconds, refresh 5 minutes before
214
+ @token_expires_at = Time.now + token_data['expires_in'] - 300
215
+ puts "Got Reddit access token, expires at #{@token_expires_at}" if ENV['DEBUG']
216
+ else
217
+ raise "Failed to get Reddit access token: #{response.code} #{response.body}"
218
+ end
219
+ end
220
+
221
+ def make_api_request(endpoint, params = {})
222
+ uri = URI("https://oauth.reddit.com#{endpoint}")
223
+ uri.query = URI.encode_www_form(params) unless params.empty?
224
+
225
+ http = Net::HTTP.new(uri.host, uri.port)
226
+ http.use_ssl = true
227
+
228
+ request = Net::HTTP::Get.new(uri)
229
+ request['Authorization'] = "Bearer #{@access_token}"
230
+ request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
231
+
232
+ response = http.request(request)
233
+
234
+ if response.is_a?(Net::HTTPSuccess)
235
+ JSON.parse(response.body)
236
+ else
237
+ puts "Reddit API error: #{response.code} #{response.body}" if ENV['DEBUG']
238
+ nil
239
+ end
240
+ end
241
+
242
+ def fetch_subreddit_posts
243
+ messages = []
244
+ subreddits = @config['subreddits'] || 'programming'
245
+ subreddits = subreddits.split(',').map(&:strip) if subreddits.is_a?(String)
246
+
247
+ limit = @config['fetch_limit'] || 25
248
+ include_comments = @config['include_comments'] || false
249
+
250
+ subreddits.each do |subreddit|
251
+ # Fetch hot posts from subreddit
252
+ data = make_api_request("/r/#{subreddit}/hot", { limit: limit })
253
+
254
+ next unless data && data['data'] && data['data']['children']
255
+
256
+ data['data']['children'].each do |post_wrapper|
257
+ post = post_wrapper['data']
258
+
259
+ # Skip if we've seen this before (basic deduplication)
260
+ external_id = "reddit_post_#{post['id']}"
261
+
262
+ # Convert post to message format
263
+ message = {
264
+ source_id: @source.id,
265
+ source_type: 'reddit',
266
+ external_id: external_id,
267
+ sender: post['author'] || '[deleted]',
268
+ recipient: "r/#{subreddit}",
269
+ subject: post['title'],
270
+ content: format_post_content(post),
271
+ raw_data: post.to_json,
272
+ attachments: extract_attachments(post),
273
+ timestamp: Time.at(post['created_utc']).iso8601,
274
+ is_read: 0
275
+ }
276
+
277
+ messages << message
278
+
279
+ # Optionally fetch top comments
280
+ if include_comments && post['num_comments'] > 0
281
+ fetch_post_comments(post['id'], subreddit, post['title'], limit: 5).each do |comment|
282
+ messages << comment
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ messages
289
+ end
290
+
291
+ def fetch_private_messages
292
+ messages = []
293
+
294
+ # Fetch inbox messages (requires authentication with username/password)
295
+ unless @config['username'] && @config['password']
296
+ puts "Reddit private messages require username/password authentication" if ENV['DEBUG']
297
+ return messages
298
+ end
299
+
300
+ # Fetch unread messages
301
+ data = make_api_request('/message/unread', { limit: 100 })
302
+
303
+ if data && data['data'] && data['data']['children']
304
+ data['data']['children'].each do |msg_wrapper|
305
+ msg = msg_wrapper['data']
306
+
307
+ external_id = "reddit_msg_#{msg['id']}"
308
+
309
+ message = {
310
+ source_id: @source.id,
311
+ source_type: 'reddit',
312
+ external_id: external_id,
313
+ sender: msg['author'] || '[deleted]',
314
+ recipient: @config['username'],
315
+ subject: msg['subject'] || 'Reddit Message',
316
+ content: msg['body'] || '',
317
+ raw_data: msg.to_json,
318
+ attachments: nil,
319
+ timestamp: Time.at(msg['created_utc']).iso8601,
320
+ is_read: msg['new'] ? 0 : 1
321
+ }
322
+
323
+ messages << message
324
+ end
325
+ end
326
+
327
+ # Also fetch recent messages (not just unread)
328
+ data = make_api_request('/message/inbox', { limit: 50 })
329
+
330
+ if data && data['data'] && data['data']['children']
331
+ data['data']['children'].each do |msg_wrapper|
332
+ msg = msg_wrapper['data']
333
+
334
+ external_id = "reddit_msg_#{msg['id']}"
335
+
336
+ # Skip if we already have this message
337
+ next if messages.any? { |m| m[:external_id] == external_id }
338
+
339
+ message = {
340
+ source_id: @source.id,
341
+ source_type: 'reddit',
342
+ external_id: external_id,
343
+ sender: msg['author'] || '[deleted]',
344
+ recipient: @config['username'],
345
+ subject: msg['subject'] || 'Reddit Message',
346
+ content: msg['body'] || '',
347
+ raw_data: msg.to_json,
348
+ attachments: nil,
349
+ timestamp: Time.at(msg['created_utc']).iso8601,
350
+ is_read: msg['new'] ? 0 : 1
351
+ }
352
+
353
+ messages << message
354
+ end
355
+ end
356
+
357
+ messages
358
+ end
359
+
360
+ def fetch_post_comments(post_id, subreddit, post_title, limit: 5)
361
+ comments = []
362
+
363
+ # Fetch comments for a specific post
364
+ data = make_api_request("/r/#{subreddit}/comments/#{post_id}", { limit: limit })
365
+
366
+ return comments unless data && data.is_a?(Array) && data[1]
367
+
368
+ # Comments are in the second element
369
+ comment_data = data[1]
370
+
371
+ if comment_data['data'] && comment_data['data']['children']
372
+ comment_data['data']['children'].each do |comment_wrapper|
373
+ next unless comment_wrapper['kind'] == 't1' # t1 = comment
374
+
375
+ comment = comment_wrapper['data']
376
+ next unless comment['author'] # Skip deleted comments
377
+
378
+ external_id = "reddit_comment_#{comment['id']}"
379
+
380
+ message = {
381
+ source_id: @source.id,
382
+ source_type: 'reddit',
383
+ external_id: external_id,
384
+ sender: comment['author'],
385
+ recipient: "r/#{subreddit}",
386
+ subject: "Re: #{post_title[0..50]}",
387
+ content: comment['body'] || '',
388
+ raw_data: comment.to_json,
389
+ attachments: nil,
390
+ timestamp: Time.at(comment['created_utc']).iso8601,
391
+ is_read: 0
392
+ }
393
+
394
+ comments << message
395
+ end
396
+ end
397
+
398
+ comments
399
+ end
400
+
401
+ def format_post_content(post)
402
+ content = []
403
+
404
+ # Add self text if present
405
+ if post['selftext'] && !post['selftext'].empty?
406
+ content << post['selftext']
407
+ end
408
+
409
+ # Add URL if it's a link post
410
+ if post['url'] && post['url'] != post['permalink']
411
+ content << "\nLink: #{post['url']}"
412
+ end
413
+
414
+ # Add metadata
415
+ content << "\nScore: #{post['score']} | Comments: #{post['num_comments']}"
416
+ content << "Permalink: https://reddit.com#{post['permalink']}"
417
+
418
+ # Add flair if present
419
+ if post['link_flair_text']
420
+ content << "Flair: #{post['link_flair_text']}"
421
+ end
422
+
423
+ content.join("\n")
424
+ end
425
+
426
+ def extract_attachments(post)
427
+ attachments = []
428
+
429
+ # Check for image/video content
430
+ if post['url']
431
+ url = post['url']
432
+
433
+ # Direct image links
434
+ if url =~ /\.(jpg|jpeg|png|gif|webp)$/i
435
+ attachments << { type: 'image', url: url }
436
+ end
437
+
438
+ # Reddit gallery
439
+ if post['is_gallery'] && post['media_metadata']
440
+ post['media_metadata'].each do |_id, media|
441
+ if media['s'] && media['s']['u']
442
+ # Convert preview URL to full URL
443
+ full_url = media['s']['u'].gsub('preview.redd.it', 'i.redd.it')
444
+ .gsub(/\?.*$/, '')
445
+ attachments << { type: 'image', url: full_url }
446
+ end
447
+ end
448
+ end
449
+
450
+ # Reddit video
451
+ if post['is_video'] && post['media'] && post['media']['reddit_video']
452
+ video_url = post['media']['reddit_video']['fallback_url']
453
+ attachments << { type: 'video', url: video_url }
454
+ end
455
+ end
456
+
457
+ attachments.empty? ? nil : attachments.to_json
458
+ end
459
+ end
460
+ end
461
+ end