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.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- 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
|
+
}));
|