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,212 @@
1
+ require 'digest'
2
+ require 'shellwords'
3
+ require 'json'
4
+ require 'time'
5
+ require_relative 'base'
6
+
7
+ module Heathrow
8
+ module Sources
9
+ class Messenger < Base
10
+ COOKIE_DIR = File.join(Dir.home, '.heathrow', 'cookies')
11
+ COOKIE_FILE = File.join(COOKIE_DIR, 'messenger.json')
12
+ FETCH_SCRIPT = File.join(__dir__, 'messenger_fetch_marionette.py')
13
+
14
+ # Required cookies for authentication
15
+ REQUIRED_COOKIES = %w[c_user xs]
16
+ OPTIONAL_COOKIES = %w[datr fr]
17
+
18
+ def initialize(name, config, db)
19
+ super
20
+ @cookies = config['cookies'] || load_cookies
21
+ end
22
+
23
+ def sync(source_id)
24
+ return 0 unless valid_cookies?
25
+
26
+ begin
27
+ data = fetch_via_playwright
28
+ return 0 unless data
29
+ threads = data['threads'] || []
30
+ return 0 if threads.empty?
31
+
32
+ count = 0
33
+ threads.each do |thread|
34
+ count += process_thread(source_id, thread)
35
+ end
36
+ count
37
+ rescue => e
38
+ STDERR.puts "Messenger error: #{e.message}" if ENV['DEBUG']
39
+ 0
40
+ end
41
+ end
42
+
43
+ def fetch
44
+ return [] unless enabled?
45
+ source = @db.get_source_by_name(@name)
46
+ return [] unless source
47
+ sync(source['id'])
48
+ update_last_fetch
49
+ []
50
+ end
51
+
52
+ def valid_cookies?
53
+ return false unless @cookies.is_a?(Hash)
54
+ REQUIRED_COOKIES.all? { |k| @cookies[k] && !@cookies[k].empty? }
55
+ end
56
+
57
+ def self.extract_firefox_cookies
58
+ profile_dirs = Dir.glob(File.join(Dir.home, '.mozilla', 'firefox', '*'))
59
+ cookies_db = profile_dirs.map { |d| File.join(d, 'cookies.sqlite') }
60
+ .find { |f| File.exist?(f) }
61
+ return nil unless cookies_db
62
+
63
+ tmp = "/tmp/heathrow_ff_cookies_#{$$}.sqlite"
64
+ system("cp #{Shellwords.escape(cookies_db)} #{Shellwords.escape(tmp)} 2>/dev/null")
65
+ return nil unless File.exist?(tmp)
66
+
67
+ cookies = {}
68
+ begin
69
+ require 'sqlite3'
70
+ db = SQLite3::Database.new(tmp)
71
+ db.results_as_hash = true
72
+
73
+ (REQUIRED_COOKIES + OPTIONAL_COOKIES).each do |name|
74
+ row = db.get_first_row(
75
+ "SELECT value FROM moz_cookies WHERE name = ? AND (host LIKE '%messenger.com' OR host LIKE '%facebook.com') ORDER BY expiry DESC LIMIT 1",
76
+ [name]
77
+ )
78
+ cookies[name] = row['value'] if row
79
+ end
80
+
81
+ db.close
82
+ ensure
83
+ File.delete(tmp) if File.exist?(tmp)
84
+ end
85
+
86
+ cookies
87
+ end
88
+
89
+ def save_cookies(cookies = nil)
90
+ cookies ||= @cookies
91
+ Dir.mkdir(COOKIE_DIR) unless Dir.exist?(COOKIE_DIR)
92
+ File.write(COOKIE_FILE, cookies.to_json)
93
+ File.chmod(0600, COOKIE_FILE)
94
+ @cookies = cookies
95
+ end
96
+
97
+ SEND_SCRIPT = File.join(__dir__, 'messenger_send.py')
98
+
99
+ def send_message(thread_id, _subject, body)
100
+ return { success: false, message: "No thread ID" } unless thread_id
101
+ return { success: false, message: "Empty message" } if body.nil? || body.strip.empty?
102
+
103
+ result = `python3 #{Shellwords.escape(SEND_SCRIPT)} #{Shellwords.escape(thread_id)} #{Shellwords.escape(body.strip)} 2>/dev/null`
104
+
105
+ data = JSON.parse(result) rescue nil
106
+ if data
107
+ data.transform_keys!(&:to_sym)
108
+ data
109
+ else
110
+ { success: false, message: "Messenger send: no response from script" }
111
+ end
112
+ rescue => e
113
+ { success: false, message: "Messenger send error: #{e.message}" }
114
+ end
115
+
116
+ private
117
+
118
+ def load_cookies
119
+ return {} unless File.exist?(COOKIE_FILE)
120
+ JSON.parse(File.read(COOKIE_FILE))
121
+ rescue
122
+ {}
123
+ end
124
+
125
+ def fetch_via_playwright
126
+ # Use Marionette (real Firefox tab) since Meta blocks headless browsers
127
+ result = `python3 #{Shellwords.escape(FETCH_SCRIPT)} 2>/dev/null`
128
+ return nil if result.nil? || result.strip.empty?
129
+
130
+ data = JSON.parse(result)
131
+ if data['error']
132
+ STDERR.puts "Messenger fetch error: #{data['error']}" if ENV['DEBUG']
133
+ save_session_error(data['error']) if data['error'] == 'login_required'
134
+ return nil
135
+ end
136
+ data
137
+ rescue JSON::ParserError => e
138
+ STDERR.puts "Messenger JSON parse error: #{e.message}" if ENV['DEBUG']
139
+ nil
140
+ end
141
+
142
+ def process_thread(source_id, thread)
143
+ return 0 unless thread['id'] && thread['id'].to_s.match?(/^\d+$/)
144
+
145
+ thread_name = thread['name'] || 'Unknown'
146
+ return 0 if thread_name.empty?
147
+
148
+ messages = thread['messages'] || []
149
+
150
+ # Fallback: old format with just a snippet
151
+ if messages.empty?
152
+ snippet = thread['snippet'] || ''
153
+ snippet = '' if snippet =~ /^Messages and calls are secured|^End-to-end encrypted/i
154
+ return 0 if snippet.empty? && !thread['unread']
155
+ messages = [{ 'id' => "last_#{thread['id']}", 'sender' => thread_name,
156
+ 'text' => snippet, 'timestamp' => Time.now.to_i }]
157
+ end
158
+
159
+ count = 0
160
+ messages.each do |msg|
161
+ text = msg['text'] || ''
162
+ text = '' if text =~ /^Messages and calls are secured|^End-to-end encrypted/i
163
+ next if text.empty?
164
+
165
+ msg_id = msg['id'] || "#{thread['id']}_#{msg['timestamp']}"
166
+ ext_id = "msng_#{thread['id']}_#{msg_id}"
167
+ sender = msg['sender'] || thread_name
168
+ timestamp = (msg['timestamp'] || Time.now.to_i).to_i
169
+
170
+ data = {
171
+ source_id: source_id,
172
+ external_id: ext_id,
173
+ thread_id: thread['id'].to_s,
174
+ sender: sender,
175
+ sender_name: sender,
176
+ recipients: [thread_name],
177
+ subject: thread_name,
178
+ content: text,
179
+ html_content: nil,
180
+ timestamp: timestamp,
181
+ received_at: Time.now.to_i,
182
+ read: msg == messages.first && thread['unread'] ? false : true,
183
+ starred: false,
184
+ archived: false,
185
+ labels: ['Messenger'],
186
+ attachments: nil,
187
+ metadata: {
188
+ thread_id: thread['id'],
189
+ message_id: msg_id,
190
+ platform: 'messenger'
191
+ },
192
+ raw_data: { thread_id: thread['id'], name: thread_name }
193
+ }
194
+
195
+ begin
196
+ @db.insert_message(data)
197
+ count += 1
198
+ rescue SQLite3::ConstraintException
199
+ # Already exists
200
+ end
201
+ end
202
+ count
203
+ end
204
+
205
+ def save_session_error(message)
206
+ error_file = File.join(COOKIE_DIR, 'messenger_error.txt')
207
+ Dir.mkdir(COOKIE_DIR) unless Dir.exist?(COOKIE_DIR)
208
+ File.write(error_file, "#{Time.now.iso8601}: #{message}\n", mode: 'a')
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ // Fetch Messenger inbox threads + message history using Playwright headless browser
3
+ // Called by messenger.rb, outputs JSON to stdout
4
+ // Facebook uses E2EE so we must scrape the rendered DOM, not GraphQL
5
+
6
+ const { firefox } = require('playwright');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const { execSync } = require('child_process');
10
+
11
+ const COOKIE_FILE = path.join(process.env.HOME, '.heathrow', 'cookies', 'messenger.json');
12
+ const TIMEOUT = 30000;
13
+ const MAX_THREADS = 10; // How many threads to fetch history for
14
+ const MSG_PER_THREAD = 10; // Messages to extract per thread
15
+ const DEBUG = process.argv.includes('--debug');
16
+
17
+ // Extract fresh cookies from Firefox's cookies.sqlite
18
+ function refreshCookiesFromFirefox() {
19
+ try {
20
+ const profilesDir = path.join(process.env.HOME, '.mozilla', 'firefox');
21
+ const profiles = fs.readdirSync(profilesDir);
22
+ let cookiesDb = null;
23
+ for (const p of profiles) {
24
+ const candidate = path.join(profilesDir, p, 'cookies.sqlite');
25
+ if (fs.existsSync(candidate)) { cookiesDb = candidate; break; }
26
+ }
27
+ if (!cookiesDb) return null;
28
+
29
+ const tmp = `/tmp/heathrow_msng_fetch_${process.pid}.sqlite`;
30
+ fs.copyFileSync(cookiesDb, tmp);
31
+
32
+ const names = ['c_user', 'xs', 'datr', 'fr', 'sb', 'wd'];
33
+ const cookies = {};
34
+
35
+ for (const name of names) {
36
+ try {
37
+ const result = execSync(
38
+ `sqlite3 "${tmp}" "SELECT value FROM moz_cookies WHERE name = '${name}' AND (host LIKE '%messenger.com' OR host LIKE '%facebook.com') ORDER BY expiry DESC LIMIT 1"`,
39
+ { encoding: 'utf8', timeout: 5000 }
40
+ ).trim();
41
+ if (result) cookies[name] = decodeURIComponent(result);
42
+ } catch (e) {}
43
+ }
44
+
45
+ try { fs.unlinkSync(tmp); } catch (e) {}
46
+
47
+ if (cookies.c_user && cookies.xs) {
48
+ const dir = path.dirname(COOKIE_FILE);
49
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
50
+ fs.writeFileSync(COOKIE_FILE, JSON.stringify(cookies));
51
+ fs.chmodSync(COOKIE_FILE, 0o600);
52
+ return cookies;
53
+ }
54
+ return null;
55
+ } catch (e) {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ async function fetchMessengerThreads() {
61
+ let browser;
62
+ try {
63
+ // Always refresh cookies from Firefox
64
+ let cookies = refreshCookiesFromFirefox();
65
+ if (!cookies) {
66
+ if (fs.existsSync(COOKIE_FILE)) {
67
+ cookies = JSON.parse(fs.readFileSync(COOKIE_FILE, 'utf8'));
68
+ } else {
69
+ cookies = {};
70
+ }
71
+ }
72
+ if (!cookies.c_user || !cookies.xs) {
73
+ console.log(JSON.stringify({ error: 'missing_cookies', threads: [] }));
74
+ return;
75
+ }
76
+
77
+ const makeCookies = (domain) => Object.entries(cookies).map(([name, value]) => ({
78
+ name, value: String(value), domain, path: '/',
79
+ httpOnly: true, secure: true, sameSite: 'None'
80
+ }));
81
+
82
+ browser = await firefox.launch({ headless: true });
83
+ const context = await browser.newContext({
84
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
85
+ viewport: { width: 1280, height: 720 }
86
+ });
87
+
88
+ await context.addCookies([...makeCookies('.messenger.com'), ...makeCookies('.facebook.com')]);
89
+ const page = await context.newPage();
90
+
91
+ await page.goto('https://www.messenger.com/t/', {
92
+ waitUntil: 'domcontentloaded', timeout: TIMEOUT
93
+ });
94
+
95
+ // Wait for thread list
96
+ try {
97
+ await page.waitForSelector('a[href*="/t/"]', { timeout: 15000 });
98
+ } catch (e) {
99
+ await page.waitForTimeout(5000);
100
+ }
101
+
102
+ // Dismiss PIN dialog
103
+ try {
104
+ const pinInput = await page.$('input[type="text"], input[type="tel"], input[aria-label*="PIN"]');
105
+ if (pinInput) {
106
+ for (const digit of '314159') {
107
+ await page.keyboard.press(digit);
108
+ await page.waitForTimeout(100);
109
+ }
110
+ await page.waitForTimeout(3000);
111
+ }
112
+ } catch (e) {}
113
+
114
+ // Dismiss modals
115
+ try {
116
+ const closeBtn = await page.$('[aria-label="Close"], [aria-label="Dismiss"]');
117
+ if (closeBtn) await closeBtn.click();
118
+ await page.waitForTimeout(1000);
119
+ } catch (e) {}
120
+
121
+ await page.waitForTimeout(2000);
122
+
123
+ if (page.url().includes('login') || page.url().includes('checkpoint')) {
124
+ console.log(JSON.stringify({ error: 'login_required', threads: [] }));
125
+ return;
126
+ }
127
+
128
+ // Get thread list from sidebar DOM
129
+ const threadList = await page.evaluate(() => {
130
+ const skipNames = /^(Media & files|Privacy & support|Marketplace|Message Requests|Archived Chats|Communities|Chats)$/i;
131
+ const skipSnippets = /^(Messages and calls are secured|End-to-end encrypted|Active \d|Active now|You're now connected|Say hi to your new)/i;
132
+ const statusPatterns = /^(Active now|Active \d+[hm] ago)$/i;
133
+ const results = [];
134
+ const links = document.querySelectorAll('a[href*="/t/"]');
135
+ const seen = new Set();
136
+
137
+ for (const link of links) {
138
+ const match = link.href.match(/\/t\/(\d+)/);
139
+ if (!match || seen.has(match[1])) continue;
140
+ seen.add(match[1]);
141
+
142
+ const row = link.closest('[role="row"], [role="listitem"]') || link.parentElement;
143
+ if (!row) continue;
144
+
145
+ const spans = Array.from(row.querySelectorAll('span'))
146
+ .map(s => s.textContent.trim())
147
+ .filter(t => t.length > 0 && t.length < 200);
148
+ if (spans.length === 0) continue;
149
+
150
+ let name = null;
151
+ for (const s of spans) {
152
+ if (statusPatterns.test(s) || skipNames.test(s) || s.includes(' unread')) continue;
153
+ name = s;
154
+ break;
155
+ }
156
+ if (!name || skipNames.test(name)) continue;
157
+
158
+ const unread = row.innerHTML.includes('Unread') ||
159
+ row.querySelector('[aria-label*="unread"]') !== null;
160
+
161
+ results.push({ id: match[1], name, unread });
162
+ }
163
+ return results;
164
+ });
165
+
166
+ if (DEBUG) {
167
+ console.error(`Found ${threadList.length} threads in sidebar`);
168
+ }
169
+
170
+ // Visit each thread and extract messages from the rendered conversation
171
+ const threads = [];
172
+ const toVisit = threadList.slice(0, MAX_THREADS);
173
+
174
+ for (const thread of toVisit) {
175
+ try {
176
+ await page.goto(`https://www.messenger.com/t/${thread.id}`, {
177
+ waitUntil: 'domcontentloaded', timeout: 15000
178
+ });
179
+
180
+ // Wait for messages to render
181
+ try {
182
+ await page.waitForSelector('[role="row"]', { timeout: 8000 });
183
+ } catch (e) {
184
+ await page.waitForTimeout(3000);
185
+ }
186
+ await page.waitForTimeout(1500);
187
+
188
+ // Extract messages from the conversation view
189
+ const messages = await page.evaluate(({threadName, msgLimit}) => {
190
+ const msgs = [];
191
+ const skipText = /^(Messages and calls are secured|New messages and calls are secured|End-to-end encrypted|You're now connected|Say hi to|You (created|named) the group|Only people in this chat can)/i;
192
+ const datePattern = /^(January|February|March|April|May|June|July|August|September|October|November|December|\d{1,2}\/\d{1,2}|Yesterday|Today|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/i;
193
+
194
+ // Messages are in [role="row"] inside [role="main"]
195
+ const main = document.querySelector('[role="main"]');
196
+ if (!main) return [];
197
+
198
+ const rows = main.querySelectorAll('[role="row"]');
199
+ let currentSender = threadName;
200
+
201
+ for (const row of rows) {
202
+ const fullText = row.textContent.trim();
203
+
204
+ // Skip date separator rows
205
+ if (datePattern.test(fullText) && fullText.length < 60) continue;
206
+ // Skip E2E banner
207
+ if (skipText.test(fullText)) continue;
208
+ // Skip empty or header rows
209
+ if (!fullText || fullText.length < 2) continue;
210
+
211
+ // Get all [dir="auto"] spans in this row for sender + message parsing
212
+ const spans = Array.from(row.querySelectorAll('[dir="auto"]'))
213
+ .map(el => el.textContent.trim())
214
+ .filter(t => t.length > 0);
215
+
216
+ if (spans.length === 0) continue;
217
+
218
+ // Detect sender: "You sent" or a short name before the message
219
+ let sender = currentSender;
220
+ let messageText = '';
221
+
222
+ if (spans[0] === 'You sent' || fullText.startsWith('You sent')) {
223
+ sender = 'You';
224
+ // Message text is in subsequent spans (skip "You sent" and "Enter")
225
+ messageText = spans.filter(s => s !== 'You sent' && s !== 'Enter')
226
+ .find(s => s.length > 1 && !datePattern.test(s) && !skipText.test(s)) || '';
227
+ } else {
228
+ // First span might be sender name (short, no spaces typically for first name)
229
+ // Message text is duplicated in spans (Messenger renders it twice)
230
+ const firstSpan = spans[0];
231
+ const restSpans = spans.slice(1).filter(s => s !== 'Enter');
232
+
233
+ if (firstSpan.length < 30 && restSpans.length > 0 && firstSpan !== restSpans[0]) {
234
+ // First span is likely sender name
235
+ sender = firstSpan;
236
+ currentSender = sender;
237
+ messageText = restSpans[0] || '';
238
+ } else if (restSpans.length > 0) {
239
+ messageText = firstSpan;
240
+ } else {
241
+ messageText = firstSpan;
242
+ }
243
+ }
244
+
245
+ // Clean up: Messenger often duplicates the message text
246
+ if (!messageText || messageText === 'Enter' || skipText.test(messageText)) continue;
247
+ if (datePattern.test(messageText) && messageText.length < 60) continue;
248
+
249
+ msgs.push({ text: messageText, sender });
250
+ }
251
+
252
+ // Newest last in DOM, reverse for newest-first
253
+ const seen = new Set();
254
+ return msgs.reverse().filter(m => {
255
+ if (seen.has(m.text)) return false;
256
+ seen.add(m.text);
257
+ return true;
258
+ }).slice(0, msgLimit);
259
+ }, {threadName: thread.name, msgLimit: MSG_PER_THREAD});
260
+
261
+ if (DEBUG) {
262
+ console.error(`Thread ${thread.name}: ${messages.length} messages`);
263
+ }
264
+
265
+ // Assign sequential timestamps (we don't have real ones from DOM)
266
+ const now = Date.now() / 1000;
267
+ const msgsWithTs = messages.map((m, i) => ({
268
+ id: `${thread.id}_${i}`,
269
+ sender: m.sender,
270
+ text: m.text,
271
+ timestamp: Math.floor(now - (messages.length - 1 - i) * 60) // 1 min apart
272
+ }));
273
+
274
+ threads.push({
275
+ id: thread.id,
276
+ name: thread.name,
277
+ unread: thread.unread,
278
+ messages: msgsWithTs
279
+ });
280
+
281
+ } catch (e) {
282
+ if (DEBUG) console.error(`Failed thread ${thread.id}: ${e.message}`);
283
+ // Still include the thread with no messages
284
+ threads.push({ id: thread.id, name: thread.name, unread: thread.unread, messages: [] });
285
+ }
286
+ }
287
+
288
+ console.log(JSON.stringify({ threads, source: 'dom' }));
289
+
290
+ } catch (err) {
291
+ console.log(JSON.stringify({ error: err.message, threads: [] }));
292
+ } finally {
293
+ if (browser) await browser.close();
294
+ }
295
+ }
296
+
297
+ fetchMessengerThreads();
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch Messenger DM threads and messages via Firefox Marionette.
3
+
4
+ Connects to a running Firefox instance on Marionette port 2828,
5
+ finds the Messenger tab, scrapes thread list and message history
6
+ from the DOM. Outputs JSON to stdout, debug info to stderr.
7
+
8
+ Requires: marionette_driver (pip install marionette_driver)
9
+ """
10
+
11
+ import json
12
+ import sys
13
+
14
+ MAX_THREADS = 20
15
+ DEBUG = '--debug' in sys.argv
16
+
17
+
18
+ def debug(msg):
19
+ if DEBUG:
20
+ print(f"[marionette] {msg}", file=sys.stderr)
21
+
22
+
23
+ def output_error(message):
24
+ print(json.dumps({"error": message, "threads": []}))
25
+ sys.exit(0)
26
+
27
+
28
+ def find_messenger_tab(client):
29
+ """Find the tab with messenger.com open."""
30
+ handles = client.window_handles
31
+ debug(f"Found {len(handles)} tabs")
32
+
33
+ for handle in handles:
34
+ client.switch_to_window(handle)
35
+ url = client.get_url()
36
+ debug(f" Tab: {url[:80]}")
37
+ if 'messenger.com' in url:
38
+ debug(f"Found Messenger tab: {url}")
39
+ return handle
40
+
41
+ return None
42
+
43
+
44
+ SCRAPE_THREADS_JS = """
45
+ const skipNames = /^(Media & files|Privacy & support|Marketplace|Message Requests|Archived Chats|Communities|Chats)$/i;
46
+ const statusPatterns = /^(Active now|Active \\d+[hm] ago)$/i;
47
+ const results = [];
48
+ const links = document.querySelectorAll('a[href*="/t/"]');
49
+ const seen = new Set();
50
+
51
+ for (const link of links) {
52
+ const match = link.href.match(/\\/t\\/(\\d+)/);
53
+ if (!match || seen.has(match[1])) continue;
54
+ seen.add(match[1]);
55
+
56
+ const row = link.closest('[role="row"], [role="listitem"]') || link.parentElement;
57
+ if (!row) continue;
58
+
59
+ const spans = Array.from(row.querySelectorAll('span'))
60
+ .map(s => s.textContent.trim())
61
+ .filter(t => t.length > 0 && t.length < 200);
62
+ if (spans.length === 0) continue;
63
+
64
+ let name = null;
65
+ let snippet = '';
66
+ for (const s of spans) {
67
+ if (statusPatterns.test(s) || skipNames.test(s) || s.includes(' unread')) continue;
68
+ if (!name) { name = s; continue; }
69
+ if (!snippet && s !== name && s.length > 1) { snippet = s; }
70
+ }
71
+ if (!name || skipNames.test(name)) continue;
72
+
73
+ const unread = row.innerHTML.includes('Unread') ||
74
+ row.querySelector('[aria-label*="unread"]') !== null;
75
+
76
+ results.push({id: match[1], name: name, unread: unread, snippet: snippet});
77
+ }
78
+ return results;
79
+ """
80
+
81
+
82
+
83
+ def main():
84
+ try:
85
+ from marionette_driver.marionette import Marionette
86
+ except ImportError:
87
+ output_error("marionette_driver not installed (pip install marionette_driver)")
88
+
89
+ client = None
90
+ try:
91
+ client = Marionette(host='localhost', port=2828)
92
+ client.start_session()
93
+ debug("Connected to Marionette")
94
+
95
+ handle = find_messenger_tab(client)
96
+ if not handle:
97
+ output_error("No Messenger tab found in Firefox")
98
+
99
+ # Scrape thread list + snippets from the sidebar (no navigation needed)
100
+ debug("Scraping thread list from sidebar...")
101
+ thread_list = client.execute_script(SCRAPE_THREADS_JS)
102
+
103
+ if not thread_list:
104
+ debug("No threads found in sidebar")
105
+ print(json.dumps({"threads": [], "source": "marionette"}))
106
+ return
107
+
108
+ debug(f"Found {len(thread_list)} threads in sidebar")
109
+
110
+ threads = []
111
+ for thread in thread_list[:MAX_THREADS]:
112
+ snippet = thread.get('snippet', '')
113
+ debug(f" {thread['name']}: snippet={snippet[:50] if snippet else '(none)'}, unread={thread.get('unread')}")
114
+ threads.append({
115
+ "id": thread['id'],
116
+ "name": thread['name'],
117
+ "unread": thread.get('unread', False),
118
+ "snippet": snippet,
119
+ "messages": []
120
+ })
121
+
122
+ print(json.dumps({"threads": threads, "source": "marionette"}))
123
+
124
+ except ConnectionRefusedError:
125
+ output_error("Cannot connect to Firefox Marionette on port 2828. Start Firefox with --marionette.")
126
+ except Exception as e:
127
+ output_error(str(e))
128
+ finally:
129
+ if client:
130
+ try:
131
+ client.delete_session()
132
+ debug("Session deleted")
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ if __name__ == '__main__':
138
+ main()
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // Send a Messenger DM by opening the thread in the user's browser.
3
+ // Since Meta blocks all non-browser API access, this opens the DM thread
4
+ // in the real browser with the message pre-copied to clipboard.
5
+ //
6
+ // Usage: messenger_send.js <thread_id> <message>
7
+ // Outputs JSON: { success: true/false, message: "..." }
8
+
9
+ const { execSync, spawn } = require('child_process');
10
+
11
+ const threadId = process.argv[2];
12
+ const message = process.argv[3];
13
+
14
+ if (!threadId || !message) {
15
+ console.log(JSON.stringify({ success: false, message: 'Usage: messenger_send.js <thread_id> <message>' }));
16
+ process.exit(0);
17
+ }
18
+
19
+ const url = `https://www.messenger.com/t/${threadId}`;
20
+
21
+ // Copy message to clipboard
22
+ try {
23
+ execSync('xclip -selection clipboard', { input: message.trim(), timeout: 3000 });
24
+ } catch (e) {}
25
+
26
+ // Open in browser
27
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
28
+
29
+ console.log(JSON.stringify({
30
+ success: true,
31
+ message: 'Opened Messenger in browser. Message copied to clipboard (Ctrl+V to paste).'
32
+ }));