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,479 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # WeeChat source via Relay protocol (binary)
5
+ # Connects to a running WeeChat instance's relay to read IRC/Slack buffers
6
+
7
+ require 'socket'
8
+ require 'digest'
9
+ require 'json'
10
+ require 'time'
11
+ require 'zlib'
12
+ require_relative 'base'
13
+
14
+ module Heathrow
15
+ module Sources
16
+ class Weechat < Base
17
+ CONFIG_DIR = File.join(Dir.home, '.heathrow', 'cookies')
18
+ CONFIG_FILE = File.join(CONFIG_DIR, 'weechat.json')
19
+
20
+ def initialize(name_or_source, config = nil, db = nil)
21
+ if name_or_source.is_a?(Hash)
22
+ source = name_or_source
23
+ config = source['config']
24
+ config = JSON.parse(config) if config.is_a?(String)
25
+ super(source['name'] || 'WeeChat', config, db)
26
+ else
27
+ super(name_or_source, config, db)
28
+ end
29
+ @host = config['host'] || 'localhost'
30
+ @port = (config['port'] || 8001).to_i
31
+ @password = config['password'] || ''
32
+ @buffer_filter = config['buffer_filter'] # e.g., 'irc.*,python.slack.*'
33
+ @lines_per_buffer = (config['lines_per_buffer'] || 50).to_i
34
+ end
35
+
36
+ def sync(source_id)
37
+ sock = connect_and_auth
38
+ return 0 unless sock
39
+
40
+ count = 0
41
+ begin
42
+ # Request buffer list
43
+ sock.write("(listbuffers) hdata buffer:gui_buffers(*) number,full_name,short_name,title,type,local_variables\n")
44
+ buffers_msg = read_and_parse(sock)
45
+ buffer_list = extract_hdata(buffers_msg)
46
+ return 0 if buffer_list.empty?
47
+
48
+ buffer_list.each do |buf|
49
+ full_name = buf['full_name'].to_s
50
+ # Skip non-message buffers
51
+ next if full_name == 'core.weechat'
52
+ next if full_name =~ /^(relay\.|fset\.|script\.|irc\.server\.)/
53
+
54
+ # Apply buffer filter
55
+ if @buffer_filter
56
+ patterns = @buffer_filter.split(',').map(&:strip)
57
+ next unless patterns.any? { |p| File.fnmatch(p, full_name) }
58
+ end
59
+
60
+ ptr = buf['__path']&.first
61
+ next unless ptr
62
+
63
+ # Fetch recent lines (hdata path: buffer:PTR/own_lines/last_line/data)
64
+ sock.write("(lines_#{ptr}) hdata buffer:#{ptr}/own_lines/last_line(-#{@lines_per_buffer})/data date,prefix,message,tags_array,displayed,highlight,notify_level\n")
65
+ lines_msg = read_and_parse(sock)
66
+ next unless lines_msg
67
+
68
+ lines = extract_hdata(lines_msg)
69
+ lines.each do |line|
70
+ next unless line['displayed'].to_i == 1
71
+ count += process_line(source_id, buf, line)
72
+ end
73
+ end
74
+ ensure
75
+ sock.write("quit\n") rescue nil
76
+ sock.close rescue nil
77
+ end
78
+
79
+ count
80
+ rescue Errno::ECONNREFUSED
81
+ STDERR.puts "WeeChat relay not available at #{@host}:#{@port}" if ENV['DEBUG']
82
+ 0
83
+ rescue => e
84
+ STDERR.puts "WeeChat error: #{e.message}" if ENV['DEBUG']
85
+ STDERR.puts e.backtrace.first(3).join("\n") if ENV['DEBUG']
86
+ 0
87
+ end
88
+
89
+ def fetch
90
+ return [] unless enabled?
91
+ source = @db.get_source_by_name(@name)
92
+ return [] unless source
93
+ sync(source['id'])
94
+ update_last_fetch
95
+ []
96
+ end
97
+
98
+ def test_connection
99
+ sock = connect_and_auth
100
+ return { success: false, message: "Auth failed at #{@host}:#{@port}" } unless sock
101
+
102
+ sock.write("(version) info version\n")
103
+ msg = read_and_parse(sock)
104
+ version = extract_info(msg)
105
+
106
+ sock.write("(buflist) hdata buffer:gui_buffers(*) full_name\n")
107
+ buf_msg = read_and_parse(sock)
108
+ buffers = extract_hdata(buf_msg)
109
+
110
+ sock.write("quit\n") rescue nil
111
+ sock.close rescue nil
112
+
113
+ { success: true, message: "WeeChat #{version} - #{buffers.size} buffers" }
114
+ rescue Errno::ECONNREFUSED
115
+ { success: false, message: "Cannot connect to #{@host}:#{@port}" }
116
+ rescue => e
117
+ { success: false, message: "Error: #{e.message}" }
118
+ end
119
+
120
+ # Send a message to a buffer via the relay
121
+ def send_to_buffer(buffer_name, text)
122
+ sock = connect_and_auth
123
+ return { success: false, message: "Auth failed" } unless sock
124
+
125
+ sock.write("input #{buffer_name} #{text}\n")
126
+ sock.write("quit\n") rescue nil
127
+ sock.close rescue nil
128
+
129
+ { success: true, message: "Sent to #{buffer_name}" }
130
+ rescue => e
131
+ { success: false, message: "Send failed: #{e.message}" }
132
+ end
133
+
134
+ # Standard send_message interface (used by send_composed_message)
135
+ def send_message(to, subject, body)
136
+ send_to_buffer(to, body)
137
+ end
138
+
139
+ private
140
+
141
+ def connect_and_auth
142
+ sock = TCPSocket.new(@host, @port)
143
+ sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
144
+
145
+ # Plain password auth (relay should be behind SSH tunnel or trusted network)
146
+ sock.write("init password=#{@password},compression=off\n")
147
+
148
+ # Verify auth with a ping
149
+ sock.write("(auth_check) ping auth\n")
150
+ response = read_and_parse(sock)
151
+ response ? sock : (sock.close; nil)
152
+ rescue => e
153
+ STDERR.puts "WeeChat connect error: #{e.message}" if ENV['DEBUG']
154
+ nil
155
+ end
156
+
157
+ # ── Binary protocol reader ──
158
+
159
+ def read_message(sock)
160
+ len_bytes = read_exact(sock, 4)
161
+ return nil unless len_bytes
162
+
163
+ length = len_bytes.unpack1('N')
164
+ return nil if length < 5 || length > 10_000_000
165
+
166
+ data = read_exact(sock, length - 4)
167
+ return nil unless data
168
+
169
+ compression = data[0].unpack1('C')
170
+ payload = data[1..]
171
+
172
+ if compression == 1
173
+ payload = Zlib::Inflate.inflate(payload)
174
+ end
175
+
176
+ payload
177
+ end
178
+
179
+ def read_exact(sock, n)
180
+ buf = String.new(encoding: 'ASCII-8BIT')
181
+ while buf.size < n
182
+ chunk = sock.read(n - buf.size)
183
+ return nil unless chunk && !chunk.empty?
184
+ buf << chunk
185
+ end
186
+ buf
187
+ end
188
+
189
+ def read_and_parse(sock)
190
+ payload = read_message(sock)
191
+ return nil unless payload
192
+ parse_message(payload)
193
+ rescue => e
194
+ STDERR.puts "WeeChat parse error: #{e.message}" if ENV['DEBUG']
195
+ nil
196
+ end
197
+
198
+ # ── Binary protocol parser ──
199
+
200
+ def parse_message(data)
201
+ pos = 0
202
+ msg = {}
203
+ msg[:id], pos = read_str(data, pos)
204
+ msg[:objects] = []
205
+
206
+ while pos < data.size - 2
207
+ type = data[pos, 3]
208
+ break unless type && type.size == 3
209
+ pos += 3
210
+ obj, pos = read_object(data, pos, type)
211
+ msg[:objects] << { type: type, value: obj }
212
+ end
213
+
214
+ msg
215
+ end
216
+
217
+ def read_object(data, pos, type)
218
+ case type
219
+ when 'chr' then [data[pos].unpack1('c'), pos + 1]
220
+ when 'int' then read_int(data, pos)
221
+ when 'lon' then read_length_prefixed_ascii(data, pos)
222
+ when 'str', 'buf' then read_str(data, pos)
223
+ when 'ptr' then read_ptr(data, pos)
224
+ when 'tim' then read_length_prefixed_ascii(data, pos)
225
+ when 'htb' then read_hashtable(data, pos)
226
+ when 'hda' then read_hdata(data, pos)
227
+ when 'inf' then read_info(data, pos)
228
+ when 'inl' then read_infolist(data, pos)
229
+ when 'arr' then read_array(data, pos)
230
+ else [nil, data.size]
231
+ end
232
+ end
233
+
234
+ def read_int(data, pos)
235
+ val = data[pos, 4].unpack1('N')
236
+ val = val - 0x100000000 if val > 0x7FFFFFFF
237
+ [val, pos + 4]
238
+ end
239
+
240
+ def read_str(data, pos)
241
+ return [nil, pos + 4] if data.size < pos + 4
242
+ len = data[pos, 4].unpack1('N')
243
+ pos += 4
244
+ return [nil, pos] if len >= 0xFFFFFFF0 # NULL
245
+ return ['', pos] if len == 0
246
+ str = data[pos, len]
247
+ str = str.force_encoding('UTF-8') rescue str
248
+ [str, pos + len]
249
+ end
250
+
251
+ def read_ptr(data, pos)
252
+ len = data[pos].unpack1('C')
253
+ pos += 1
254
+ ["0x#{data[pos, len]}", pos + len]
255
+ end
256
+
257
+ def read_length_prefixed_ascii(data, pos)
258
+ len = data[pos].unpack1('C')
259
+ pos += 1
260
+ [data[pos, len].to_i, pos + len]
261
+ end
262
+
263
+ def read_hashtable(data, pos)
264
+ key_type = data[pos, 3]; pos += 3
265
+ val_type = data[pos, 3]; pos += 3
266
+ count, pos = read_int(data, pos)
267
+
268
+ hash = {}
269
+ count.times do
270
+ key, pos = read_object(data, pos, key_type)
271
+ val, pos = read_object(data, pos, val_type)
272
+ hash[key.to_s] = val
273
+ end
274
+ [hash, pos]
275
+ end
276
+
277
+ def read_hdata(data, pos)
278
+ path, pos = read_str(data, pos)
279
+ keys_str, pos = read_str(data, pos)
280
+ count, pos = read_int(data, pos)
281
+
282
+ path_parts = (path || '').split('/')
283
+ keys = (keys_str || '').split(',').map { |k| k.split(':', 2) }
284
+
285
+ items = []
286
+ count.times do
287
+ item = {}
288
+ ptrs = []
289
+ path_parts.size.times do
290
+ ptr, pos = read_object(data, pos, 'ptr')
291
+ ptrs << ptr
292
+ end
293
+ item['__path'] = ptrs
294
+
295
+ keys.each do |name, ktype|
296
+ val, pos = read_object(data, pos, ktype)
297
+ item[name] = val
298
+ end
299
+ items << item
300
+ end
301
+
302
+ [items, pos]
303
+ end
304
+
305
+ def read_info(data, pos)
306
+ name, pos = read_str(data, pos)
307
+ value, pos = read_str(data, pos)
308
+ [{ name: name, value: value }, pos]
309
+ end
310
+
311
+ def read_infolist(data, pos)
312
+ name, pos = read_str(data, pos)
313
+ count, pos = read_int(data, pos)
314
+
315
+ items = []
316
+ count.times do
317
+ var_count, pos = read_int(data, pos)
318
+ item = {}
319
+ var_count.times do
320
+ var_name, pos = read_str(data, pos)
321
+ var_type = data[pos, 3]; pos += 3
322
+ var_val, pos = read_object(data, pos, var_type)
323
+ item[var_name.to_s] = var_val
324
+ end
325
+ items << item
326
+ end
327
+ [{ name: name, items: items }, pos]
328
+ end
329
+
330
+ def read_array(data, pos)
331
+ elem_type = data[pos, 3]; pos += 3
332
+ count, pos = read_int(data, pos)
333
+
334
+ arr = []
335
+ count.times do
336
+ val, pos = read_object(data, pos, elem_type)
337
+ arr << val
338
+ end
339
+ [arr, pos]
340
+ end
341
+
342
+ # ── Helpers ──
343
+
344
+ def extract_hdata(msg)
345
+ return [] unless msg && msg[:objects]
346
+ msg[:objects].each do |obj|
347
+ return obj[:value] if obj[:type] == 'hda' && obj[:value].is_a?(Array)
348
+ end
349
+ []
350
+ end
351
+
352
+ def extract_info(msg)
353
+ return nil unless msg && msg[:objects]
354
+ msg[:objects].each do |obj|
355
+ return obj[:value][:value] if obj[:type] == 'inf' && obj[:value].is_a?(Hash)
356
+ end
357
+ nil
358
+ end
359
+
360
+ # Strip WeeChat color codes from text
361
+ # Format: \x19 followed by color type char and color value, terminated by \x1C or next \x19
362
+ # Color types: F=foreground, B=background, *=bold, etc.
363
+ # Color values: NN (2-digit) or @NNNNN (extended 5-digit)
364
+ def strip_colors(text)
365
+ return '' unless text
366
+ # Remove \x19 + type char + optional @ + digits (color sequences)
367
+ text.gsub(/\x19[FB*_\/|][~@]?\d{0,5}/, '')
368
+ .gsub(/\x19\x1C/, '') # reset sequence
369
+ .gsub(/\x19[^\x19\x1C]*/, '') # catch remaining color codes
370
+ .gsub(/[\x00-\x1f]/, '') # strip all remaining control chars
371
+ end
372
+
373
+ def buffer_type(buf)
374
+ full_name = buf['full_name'].to_s
375
+ local_vars = buf['local_variables']
376
+ local_vars = {} unless local_vars.is_a?(Hash)
377
+ type = local_vars['type'] || ''
378
+
379
+ if full_name.start_with?('irc.')
380
+ type == 'private' ? :irc_dm : :irc_channel
381
+ elsif full_name =~ /^python\.slack\./
382
+ if type == 'private'
383
+ :slack_dm
384
+ elsif full_name =~ /&/
385
+ :slack_group
386
+ else
387
+ :slack_channel
388
+ end
389
+ else
390
+ :other
391
+ end
392
+ end
393
+
394
+ def process_line(source_id, buf, line)
395
+ prefix = strip_colors(line['prefix'] || '')
396
+ message = strip_colors(line['message'] || '')
397
+ timestamp = line['date'].to_i
398
+ tags = line['tags_array'] || []
399
+
400
+ # Skip system messages (joins, parts, quits, mode changes)
401
+ return 0 if tags.any? { |t| t =~ /irc_join|irc_part|irc_quit|irc_nick|irc_mode|irc_numeric/ }
402
+ return 0 if tags.include?('no_log')
403
+ # Must have a nick tag (real messages) or a non-empty prefix
404
+ return 0 unless tags.any? { |t| t.start_with?('nick_') } || prefix =~ /\w/
405
+ return 0 if message.strip.empty?
406
+
407
+ nick = tags.find { |t| t.start_with?('nick_') }&.sub('nick_', '') || prefix
408
+ full_name = buf['full_name'].to_s
409
+ short_name = buf['short_name'] || full_name.split('.').last
410
+
411
+ ext_id = "weechat_#{Digest::MD5.hexdigest("#{full_name}_#{timestamp}_#{nick}_#{message[0..80]}")}"
412
+
413
+ btype = buffer_type(buf)
414
+ is_dm = (btype == :irc_dm || btype == :slack_dm)
415
+
416
+ platform = case btype
417
+ when :irc_dm, :irc_channel then 'IRC'
418
+ when :slack_dm, :slack_channel, :slack_group then 'Slack'
419
+ else 'WeeChat'
420
+ end
421
+
422
+ channel_name = case btype
423
+ when :irc_channel
424
+ net = full_name.split('.')[1] || 'irc'
425
+ "#{net}/#{short_name}"
426
+ when :irc_dm
427
+ nick
428
+ when :slack_channel, :slack_group
429
+ parts = full_name.split('.')
430
+ ws = parts[2] || 'slack'
431
+ chan = parts[3..].join('.') rescue short_name
432
+ "#{ws}/#{chan}"
433
+ when :slack_dm
434
+ parts = full_name.split('.')
435
+ parts[3..].join('.') rescue nick
436
+ else
437
+ short_name
438
+ end
439
+
440
+ data = {
441
+ source_id: source_id,
442
+ external_id: ext_id,
443
+ sender: nick,
444
+ sender_name: nick,
445
+ recipients: [channel_name],
446
+ subject: message.gsub(/\n/, ' ')[0..200],
447
+ content: message,
448
+ html_content: nil,
449
+ timestamp: timestamp,
450
+ received_at: Time.now.to_i,
451
+ read: false,
452
+ starred: false,
453
+ archived: false,
454
+ labels: [platform],
455
+ attachments: nil,
456
+ metadata: {
457
+ buffer: full_name,
458
+ buffer_short: short_name,
459
+ buffer_type: btype.to_s,
460
+ channel_name: channel_name,
461
+ nick: nick,
462
+ is_dm: is_dm,
463
+ platform: platform.downcase,
464
+ highlight: (line['highlight'].to_i == 1),
465
+ tags: tags
466
+ },
467
+ raw_data: { buffer: full_name, nick: nick }
468
+ }
469
+
470
+ begin
471
+ @db.insert_message(data)
472
+ 1
473
+ rescue SQLite3::ConstraintException
474
+ 0
475
+ end
476
+ end
477
+ end
478
+ end
479
+ end