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,474 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # WhatsApp source using WAHA (WhatsApp HTTP API)
5
+ # Requires: docker run -p 3000:3000 devlikeapro/waha
6
+
7
+ require 'net/http'
8
+ require 'json'
9
+ require 'uri'
10
+ require 'base64'
11
+ require 'time'
12
+
13
+ module Heathrow
14
+ module Sources
15
+ class Whatsapp
16
+ attr_reader :source, :last_fetch_time
17
+
18
+ DEFAULT_API_URL = 'http://localhost:3000'
19
+ DEFAULT_SESSION = 'default'
20
+
21
+ def initialize(source)
22
+ @source = source
23
+ @config = source.config.is_a?(String) ? JSON.parse(source.config) : source.config
24
+ @last_fetch_time = Time.now
25
+ @session = @config['session'] || DEFAULT_SESSION
26
+ @api_url = @config['api_url'] || DEFAULT_API_URL
27
+ end
28
+
29
+ def fetch_messages
30
+ messages = []
31
+
32
+ begin
33
+ unless authenticated?
34
+ puts "WhatsApp not authenticated. Run setup first." if ENV['DEBUG']
35
+ return messages
36
+ end
37
+
38
+ # Get list of chats
39
+ chats = fetch_chats
40
+ return messages if chats.empty?
41
+
42
+ # Fetch recent messages from each chat
43
+ limit = @config['fetch_limit'] || 20
44
+ chats.each do |chat|
45
+ chat_id = chat['id']
46
+ chat_messages = fetch_chat_messages(chat_id, limit)
47
+
48
+ chat_messages.each do |msg|
49
+ message = convert_to_heathrow_message(msg, chat)
50
+ messages << message if message
51
+ end
52
+ end
53
+
54
+ @last_fetch_time = Time.now
55
+
56
+ rescue => e
57
+ puts "WhatsApp fetch error: #{e.message}" if ENV['DEBUG']
58
+ puts e.backtrace.join("\n") if ENV['DEBUG']
59
+ end
60
+
61
+ messages
62
+ end
63
+
64
+ def test_connection
65
+ begin
66
+ # Check session status
67
+ uri = URI("#{@api_url}/api/sessions/#{@session}")
68
+ response = Net::HTTP.get_response(uri)
69
+
70
+ if response.is_a?(Net::HTTPSuccess)
71
+ data = JSON.parse(response.body)
72
+ status = data['status']
73
+
74
+ case status
75
+ when 'WORKING'
76
+ me = data.dig('me', 'id') || 'Unknown'
77
+ phone = me.split('@').first
78
+ { success: true, message: "Connected as +#{phone}" }
79
+ when 'SCAN_QR_CODE'
80
+ { success: false, message: "Session needs QR code scan. Run setup." }
81
+ when 'STARTING'
82
+ { success: false, message: "Session is starting..." }
83
+ when 'STOPPED'
84
+ { success: false, message: "Session stopped. Run setup to start." }
85
+ else
86
+ { success: false, message: "Session status: #{status}" }
87
+ end
88
+ elsif response.code == '404'
89
+ { success: false, message: "Session '#{@session}' not found. Run setup." }
90
+ else
91
+ { success: false, message: "API error: #{response.code}" }
92
+ end
93
+
94
+ rescue Errno::ECONNREFUSED
95
+ { success: false, message: "WAHA not running. Start with: docker run -p 3000:3000 devlikeapro/waha" }
96
+ rescue => e
97
+ { success: false, message: "Connection failed: #{e.message}" }
98
+ end
99
+ end
100
+
101
+ def can_reply?
102
+ authenticated?
103
+ end
104
+
105
+ def send_message(to, subject, body, in_reply_to = nil)
106
+ unless can_reply?
107
+ return { success: false, message: "WhatsApp not authenticated" }
108
+ end
109
+
110
+ begin
111
+ chat_id = format_chat_id(to)
112
+
113
+ payload = {
114
+ session: @session,
115
+ chatId: chat_id,
116
+ text: body
117
+ }
118
+
119
+ # Add reply context if replying
120
+ payload[:reply_to] = in_reply_to if in_reply_to
121
+
122
+ uri = URI("#{@api_url}/api/sendText")
123
+ response = post_json(uri, payload)
124
+
125
+ if response.is_a?(Net::HTTPSuccess)
126
+ { success: true, message: "Message sent to #{to}" }
127
+ else
128
+ error = parse_error(response)
129
+ { success: false, message: "Failed to send: #{error}" }
130
+ end
131
+
132
+ rescue Errno::ECONNREFUSED
133
+ { success: false, message: "WAHA not running" }
134
+ rescue => e
135
+ { success: false, message: "Send failed: #{e.message}" }
136
+ end
137
+ end
138
+
139
+ def send_media(to, file_path, caption = nil)
140
+ unless can_reply?
141
+ return { success: false, message: "WhatsApp not authenticated" }
142
+ end
143
+
144
+ begin
145
+ chat_id = format_chat_id(to)
146
+ mime_type = detect_mime_type(file_path)
147
+
148
+ # Determine endpoint based on media type
149
+ endpoint = case mime_type
150
+ when /^image/ then '/api/sendImage'
151
+ when /^video/ then '/api/sendVideo'
152
+ when /^audio/ then '/api/sendVoice'
153
+ else '/api/sendFile'
154
+ end
155
+
156
+ # Read and encode file
157
+ file_data = Base64.strict_encode64(File.binread(file_path))
158
+
159
+ payload = {
160
+ session: @session,
161
+ chatId: chat_id,
162
+ file: {
163
+ mimetype: mime_type,
164
+ filename: File.basename(file_path),
165
+ data: file_data
166
+ }
167
+ }
168
+ payload[:caption] = caption if caption
169
+
170
+ uri = URI("#{@api_url}#{endpoint}")
171
+ response = post_json(uri, payload)
172
+
173
+ if response.is_a?(Net::HTTPSuccess)
174
+ { success: true, message: "Media sent to #{to}" }
175
+ else
176
+ error = parse_error(response)
177
+ { success: false, message: "Failed to send media: #{error}" }
178
+ end
179
+
180
+ rescue => e
181
+ { success: false, message: "Send media failed: #{e.message}" }
182
+ end
183
+ end
184
+
185
+ def authenticate
186
+ begin
187
+ # Create session if it doesn't exist
188
+ unless session_exists?
189
+ puts "Creating WhatsApp session '#{@session}'..."
190
+ create_session
191
+ end
192
+
193
+ # Start session if stopped
194
+ status = get_session_status
195
+ if status == 'STOPPED'
196
+ start_session
197
+ sleep(2)
198
+ end
199
+
200
+ # Get QR code and wait for scan
201
+ authenticate_with_qr_code
202
+
203
+ rescue => e
204
+ puts "Authentication error: #{e.message}"
205
+ false
206
+ end
207
+ end
208
+
209
+ def post_configure
210
+ if authenticated?
211
+ puts "WhatsApp already authenticated!"
212
+ return true
213
+ end
214
+
215
+ puts "\nWhatsApp requires authentication via QR code."
216
+ print "Would you like to authenticate now? (y/n): "
217
+ response = gets.chomp.downcase
218
+
219
+ if response == 'y'
220
+ authenticate
221
+ else
222
+ puts "You can authenticate later by running: ruby setup_whatsapp.rb"
223
+ true
224
+ end
225
+ end
226
+
227
+ private
228
+
229
+ def authenticated?
230
+ status = get_session_status
231
+ status == 'WORKING'
232
+ end
233
+
234
+ def session_exists?
235
+ uri = URI("#{@api_url}/api/sessions/#{@session}")
236
+ response = Net::HTTP.get_response(uri)
237
+ response.is_a?(Net::HTTPSuccess)
238
+ rescue
239
+ false
240
+ end
241
+
242
+ def get_session_status
243
+ uri = URI("#{@api_url}/api/sessions/#{@session}")
244
+ response = Net::HTTP.get_response(uri)
245
+
246
+ if response.is_a?(Net::HTTPSuccess)
247
+ data = JSON.parse(response.body)
248
+ data['status']
249
+ else
250
+ nil
251
+ end
252
+ rescue
253
+ nil
254
+ end
255
+
256
+ def create_session
257
+ uri = URI("#{@api_url}/api/sessions")
258
+ payload = { name: @session }
259
+ post_json(uri, payload)
260
+ end
261
+
262
+ def start_session
263
+ uri = URI("#{@api_url}/api/sessions/#{@session}/start")
264
+ post_json(uri, {})
265
+ end
266
+
267
+ def authenticate_with_qr_code
268
+ puts "\n=== WhatsApp QR Code Authentication ==="
269
+ puts "1. Open WhatsApp on your phone"
270
+ puts "2. Go to Settings > Linked Devices"
271
+ puts "3. Tap 'Link a Device'"
272
+ puts "4. Scan the QR code\n\n"
273
+
274
+ max_attempts = 60
275
+ attempt = 0
276
+ last_qr = nil
277
+
278
+ while attempt < max_attempts
279
+ status = get_session_status
280
+
281
+ case status
282
+ when 'WORKING'
283
+ puts "\n[OK] Successfully authenticated!"
284
+ return true
285
+ when 'SCAN_QR_CODE'
286
+ qr = fetch_qr_code
287
+ if qr && qr != last_qr
288
+ display_qr_terminal(qr)
289
+ last_qr = qr
290
+ end
291
+ when 'STARTING'
292
+ print "."
293
+ end
294
+
295
+ sleep(2)
296
+ attempt += 1
297
+ end
298
+
299
+ puts "\nAuthentication timeout. Please try again."
300
+ false
301
+ end
302
+
303
+ def fetch_qr_code
304
+ uri = URI("#{@api_url}/api/#{@session}/auth/qr?format=raw")
305
+ response = Net::HTTP.get_response(uri)
306
+
307
+ if response.is_a?(Net::HTTPSuccess)
308
+ data = JSON.parse(response.body)
309
+ data['value']
310
+ else
311
+ nil
312
+ end
313
+ rescue
314
+ nil
315
+ end
316
+
317
+ def display_qr_terminal(qr_string)
318
+ # Use qrencode if available, otherwise print raw
319
+ begin
320
+ require 'rqrcode'
321
+ qr = RQRCode::QRCode.new(qr_string)
322
+ puts qr.as_ansi(
323
+ light: "\e[47m", dark: "\e[40m",
324
+ fill_character: ' ',
325
+ quiet_zone_size: 1
326
+ )
327
+ rescue LoadError
328
+ # Fallback: try system qrencode
329
+ result = `echo '#{qr_string}' | qrencode -t ANSIUTF8 2>/dev/null`
330
+ if $?.success?
331
+ puts result
332
+ else
333
+ puts "QR Data: #{qr_string}"
334
+ puts "\nInstall 'rqrcode' gem or 'qrencode' for visual QR code"
335
+ end
336
+ end
337
+ end
338
+
339
+ def fetch_chats
340
+ uri = URI("#{@api_url}/api/#{@session}/chats?limit=50")
341
+ response = Net::HTTP.get_response(uri)
342
+
343
+ if response.is_a?(Net::HTTPSuccess)
344
+ JSON.parse(response.body)
345
+ else
346
+ []
347
+ end
348
+ rescue
349
+ []
350
+ end
351
+
352
+ def fetch_chat_messages(chat_id, limit = 20)
353
+ encoded_chat_id = URI.encode_www_form_component(chat_id)
354
+ uri = URI("#{@api_url}/api/#{@session}/chats/#{encoded_chat_id}/messages?limit=#{limit}")
355
+ response = Net::HTTP.get_response(uri)
356
+
357
+ if response.is_a?(Net::HTTPSuccess)
358
+ JSON.parse(response.body)
359
+ else
360
+ []
361
+ end
362
+ rescue
363
+ []
364
+ end
365
+
366
+ def convert_to_heathrow_message(msg, chat)
367
+ sender_id = msg['from'] || msg['_data']&.dig('from')
368
+ sender = format_phone_number(sender_id)
369
+
370
+ # Get chat name
371
+ chat_name = chat['name'] || chat['id']&.split('@')&.first || 'Unknown'
372
+
373
+ content = msg['body'] || ''
374
+ subject = content[0..50]
375
+ subject += "..." if content.length > 50
376
+
377
+ # Handle timestamps
378
+ timestamp = if msg['timestamp']
379
+ Time.at(msg['timestamp']).iso8601
380
+ else
381
+ Time.now.iso8601
382
+ end
383
+
384
+ {
385
+ source_id: @source.id,
386
+ source_type: 'whatsapp',
387
+ external_id: "whatsapp_#{msg['id']}",
388
+ sender: sender,
389
+ recipient: chat_name,
390
+ subject: subject,
391
+ content: content,
392
+ raw_data: msg.to_json,
393
+ attachments: extract_attachments(msg),
394
+ timestamp: timestamp,
395
+ is_read: msg['ack'].to_i >= 1 ? 1 : 0
396
+ }
397
+ end
398
+
399
+ def format_phone_number(jid)
400
+ return '[Unknown]' unless jid
401
+
402
+ phone = jid.split('@').first
403
+ if jid.include?('@c.us') || jid.include?('@s.whatsapp.net')
404
+ "+#{phone}"
405
+ elsif jid.include?('@g.us')
406
+ phone # Group ID
407
+ else
408
+ jid
409
+ end
410
+ end
411
+
412
+ def format_chat_id(to)
413
+ # Clean phone number
414
+ phone = to.gsub(/[^\d+]/, '').sub(/^\+/, '')
415
+
416
+ if phone.include?('-')
417
+ "#{phone}@g.us"
418
+ else
419
+ "#{phone}@c.us"
420
+ end
421
+ end
422
+
423
+ def extract_attachments(msg)
424
+ attachments = []
425
+
426
+ if msg['hasMedia']
427
+ attachments << {
428
+ type: msg['type'],
429
+ url: msg.dig('media', 'url'),
430
+ mime_type: msg['mimetype'],
431
+ filename: msg['filename']
432
+ }
433
+ end
434
+
435
+ attachments.empty? ? nil : attachments.to_json
436
+ end
437
+
438
+ def post_json(uri, payload)
439
+ request = Net::HTTP::Post.new(uri)
440
+ request['Content-Type'] = 'application/json'
441
+ request.body = payload.to_json
442
+
443
+ Net::HTTP.start(uri.hostname, uri.port) do |http|
444
+ http.request(request)
445
+ end
446
+ end
447
+
448
+ def parse_error(response)
449
+ data = JSON.parse(response.body)
450
+ data['message'] || data['error'] || response.message
451
+ rescue
452
+ response.message
453
+ end
454
+
455
+ def detect_mime_type(file_path)
456
+ ext = File.extname(file_path).downcase
457
+
458
+ case ext
459
+ when '.jpg', '.jpeg' then 'image/jpeg'
460
+ when '.png' then 'image/png'
461
+ when '.gif' then 'image/gif'
462
+ when '.webp' then 'image/webp'
463
+ when '.mp4' then 'video/mp4'
464
+ when '.mp3' then 'audio/mpeg'
465
+ when '.ogg' then 'audio/ogg'
466
+ when '.pdf' then 'application/pdf'
467
+ when '.doc' then 'application/msword'
468
+ when '.docx' then 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
469
+ else 'application/octet-stream'
470
+ end
471
+ end
472
+ end
473
+ end
474
+ end