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,307 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
|
|
7
|
+
module Heathrow
|
|
8
|
+
module Sources
|
|
9
|
+
class Instagram < Base
|
|
10
|
+
COOKIE_DIR = File.join(Dir.home, '.heathrow', 'cookies')
|
|
11
|
+
COOKIE_FILE = File.join(COOKIE_DIR, 'instagram.json')
|
|
12
|
+
INBOX_URL = 'https://www.instagram.com/api/v1/direct_v2/inbox/'
|
|
13
|
+
APP_ID = '936619743392459'
|
|
14
|
+
|
|
15
|
+
REQUIRED_COOKIES = %w[sessionid csrftoken]
|
|
16
|
+
OPTIONAL_COOKIES = %w[ds_user_id ig_did mid]
|
|
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
|
+
inbox = fetch_inbox
|
|
28
|
+
return 0 unless inbox
|
|
29
|
+
|
|
30
|
+
count = 0
|
|
31
|
+
threads = inbox['inbox']&.dig('threads') || []
|
|
32
|
+
threads.each do |thread|
|
|
33
|
+
count += process_thread(source_id, thread)
|
|
34
|
+
end
|
|
35
|
+
count
|
|
36
|
+
rescue SessionExpiredError => e
|
|
37
|
+
STDERR.puts "Instagram session expired: #{e.message}" if ENV['DEBUG']
|
|
38
|
+
save_session_error("Session expired. Please refresh cookies.")
|
|
39
|
+
0
|
|
40
|
+
rescue => e
|
|
41
|
+
STDERR.puts "Instagram error: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if ENV['DEBUG']
|
|
42
|
+
0
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch
|
|
47
|
+
return [] unless enabled?
|
|
48
|
+
source = @db.get_source_by_name(@name)
|
|
49
|
+
return [] unless source
|
|
50
|
+
sync(source['id'])
|
|
51
|
+
update_last_fetch
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def valid_cookies?
|
|
56
|
+
return false unless @cookies.is_a?(Hash)
|
|
57
|
+
REQUIRED_COOKIES.all? { |k| @cookies[k] && !@cookies[k].empty? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def save_cookies(cookies = nil)
|
|
61
|
+
cookies ||= @cookies
|
|
62
|
+
Dir.mkdir(COOKIE_DIR) unless Dir.exist?(COOKIE_DIR)
|
|
63
|
+
File.write(COOKIE_FILE, cookies.to_json)
|
|
64
|
+
File.chmod(0600, COOKIE_FILE)
|
|
65
|
+
@cookies = cookies
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
SEND_SCRIPT = File.join(__dir__, 'instagram_send_marionette.py')
|
|
69
|
+
|
|
70
|
+
def send_message(thread_id, _subject, body)
|
|
71
|
+
return { success: false, message: "No thread ID" } unless thread_id
|
|
72
|
+
return { success: false, message: "Empty message" } if body.nil? || body.strip.empty?
|
|
73
|
+
|
|
74
|
+
result = `python3 #{Shellwords.escape(SEND_SCRIPT)} #{Shellwords.escape(thread_id)} #{Shellwords.escape(body.strip)} 2>/dev/null`
|
|
75
|
+
|
|
76
|
+
data = JSON.parse(result) rescue nil
|
|
77
|
+
if data
|
|
78
|
+
data.transform_keys!(&:to_sym)
|
|
79
|
+
data
|
|
80
|
+
else
|
|
81
|
+
{ success: false, message: "Instagram send: no response from script" }
|
|
82
|
+
end
|
|
83
|
+
rescue => e
|
|
84
|
+
{ success: false, message: "Instagram send error: #{e.message}" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
class SessionExpiredError < StandardError; end
|
|
90
|
+
|
|
91
|
+
def load_cookies
|
|
92
|
+
return {} unless File.exist?(COOKIE_FILE)
|
|
93
|
+
JSON.parse(File.read(COOKIE_FILE))
|
|
94
|
+
rescue
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def cookie_header
|
|
99
|
+
@cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def csrf_token
|
|
103
|
+
@cookies['csrftoken'] || ''
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
FETCH_SCRIPT = File.join(__dir__, 'instagram_fetch.py')
|
|
107
|
+
|
|
108
|
+
def fetch_inbox
|
|
109
|
+
# Use Marionette (real Firefox tab) since Meta blocks API calls from non-browsers
|
|
110
|
+
result = `python3 #{Shellwords.escape(FETCH_SCRIPT)} 2>/dev/null`
|
|
111
|
+
|
|
112
|
+
return nil if result.nil? || result.strip.empty?
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
data = JSON.parse(result)
|
|
116
|
+
if data['error']
|
|
117
|
+
STDERR.puts "Instagram fetch error: #{data['error']}" if ENV['DEBUG']
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
if data['status'] != 'ok'
|
|
121
|
+
raise SessionExpiredError, "API returned status: #{data['status']}"
|
|
122
|
+
end
|
|
123
|
+
data
|
|
124
|
+
rescue JSON::ParserError => e
|
|
125
|
+
STDERR.puts "Instagram JSON parse error: #{e.message}" if ENV['DEBUG']
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def process_thread(source_id, thread)
|
|
131
|
+
return 0 unless thread
|
|
132
|
+
|
|
133
|
+
thread_id = thread['thread_id']
|
|
134
|
+
return 0 unless thread_id
|
|
135
|
+
|
|
136
|
+
# Get thread participants
|
|
137
|
+
users = thread['users'] || []
|
|
138
|
+
other_users = users.map { |u| u['full_name'].to_s.empty? ? u['username'] : u['full_name'] }
|
|
139
|
+
thread_title = thread['thread_title']
|
|
140
|
+
thread_title = other_users.join(', ') if thread_title.nil? || thread_title.empty?
|
|
141
|
+
thread_title = 'Unknown' if thread_title.empty?
|
|
142
|
+
|
|
143
|
+
items = thread['items'] || []
|
|
144
|
+
return 0 if items.empty?
|
|
145
|
+
|
|
146
|
+
count = 0
|
|
147
|
+
items.each do |item|
|
|
148
|
+
next unless item
|
|
149
|
+
|
|
150
|
+
content = extract_message_content(item)
|
|
151
|
+
timestamp = (item['timestamp'].to_i / 1_000_000) rescue Time.now.to_i
|
|
152
|
+
|
|
153
|
+
ext_id = "ig_#{thread_id}_#{item['item_id']}"
|
|
154
|
+
|
|
155
|
+
# Determine sender
|
|
156
|
+
sender_id = item['user_id'].to_s
|
|
157
|
+
sender = users.find { |u| u['pk'].to_s == sender_id }
|
|
158
|
+
sender_name = if sender
|
|
159
|
+
sender['full_name'].to_s.empty? ? sender['username'] : sender['full_name']
|
|
160
|
+
else
|
|
161
|
+
thread_title
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
attachments = extract_attachments(item)
|
|
165
|
+
|
|
166
|
+
data = {
|
|
167
|
+
source_id: source_id,
|
|
168
|
+
external_id: ext_id,
|
|
169
|
+
thread_id: thread_id,
|
|
170
|
+
sender: sender_name,
|
|
171
|
+
sender_name: sender_name,
|
|
172
|
+
recipients: [thread_title],
|
|
173
|
+
subject: thread_title,
|
|
174
|
+
content: content,
|
|
175
|
+
html_content: nil,
|
|
176
|
+
timestamp: timestamp,
|
|
177
|
+
received_at: Time.now.to_i,
|
|
178
|
+
read: item == items.first ? false : true,
|
|
179
|
+
starred: false,
|
|
180
|
+
archived: false,
|
|
181
|
+
labels: ['Instagram'],
|
|
182
|
+
attachments: attachments.empty? ? nil : attachments,
|
|
183
|
+
metadata: {
|
|
184
|
+
thread_id: thread_id,
|
|
185
|
+
item_id: item['item_id'],
|
|
186
|
+
participants: other_users,
|
|
187
|
+
is_group: thread['is_group'],
|
|
188
|
+
platform: 'instagram'
|
|
189
|
+
},
|
|
190
|
+
raw_data: { thread_id: thread_id, sender: sender_name }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
@db.insert_message(data)
|
|
195
|
+
count += 1
|
|
196
|
+
rescue SQLite3::ConstraintException
|
|
197
|
+
# Already exists
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
count
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def extract_message_content(item)
|
|
204
|
+
case item['item_type']
|
|
205
|
+
when 'text'
|
|
206
|
+
item['text'] || ''
|
|
207
|
+
when 'media', 'media_share'
|
|
208
|
+
media = item['media'] || item['media_share'] || {}
|
|
209
|
+
caption = media.dig('caption', 'text') || ''
|
|
210
|
+
"[Media] #{caption}".strip
|
|
211
|
+
when 'raven_media'
|
|
212
|
+
'[Disappearing photo/video]'
|
|
213
|
+
when 'voice_media'
|
|
214
|
+
'[Voice message]'
|
|
215
|
+
when 'animated_media'
|
|
216
|
+
'[GIF]'
|
|
217
|
+
when 'clip'
|
|
218
|
+
clip = item['clip'] || {}
|
|
219
|
+
caption = clip.dig('clip', 'caption', 'text') || ''
|
|
220
|
+
"[Reel] #{caption}".strip
|
|
221
|
+
when 'story_share'
|
|
222
|
+
story = item['story_share'] || {}
|
|
223
|
+
"[Shared story] #{story['title'] || ''}".strip
|
|
224
|
+
when 'link'
|
|
225
|
+
link = item['link'] || {}
|
|
226
|
+
text = link['text'] || ''
|
|
227
|
+
url = link.dig('link_context', 'link_url') || ''
|
|
228
|
+
"#{text} #{url}".strip
|
|
229
|
+
when 'like'
|
|
230
|
+
'❤️'
|
|
231
|
+
when 'reel_share'
|
|
232
|
+
reel = item['reel_share'] || {}
|
|
233
|
+
"[Reel share] #{reel['text'] || ''}".strip
|
|
234
|
+
when 'xma'
|
|
235
|
+
'[Shared content]'
|
|
236
|
+
else
|
|
237
|
+
item['text'] || "[#{item['item_type'] || 'unknown'}]"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def extract_attachments(item)
|
|
242
|
+
attachments = []
|
|
243
|
+
case item['item_type']
|
|
244
|
+
when 'media', 'media_share'
|
|
245
|
+
media = item['media'] || item['media_share'] || {}
|
|
246
|
+
url = best_image_url(media)
|
|
247
|
+
if url
|
|
248
|
+
attachments << { 'url' => url, 'content_type' => media_type(media), 'name' => 'image.jpg' }
|
|
249
|
+
end
|
|
250
|
+
when 'raven_media'
|
|
251
|
+
media = item.dig('visual_media', 'media') || {}
|
|
252
|
+
url = best_image_url(media)
|
|
253
|
+
if url
|
|
254
|
+
attachments << { 'url' => url, 'content_type' => media_type(media), 'name' => 'disappearing.jpg' }
|
|
255
|
+
end
|
|
256
|
+
when 'animated_media'
|
|
257
|
+
images = item.dig('animated_media', 'images') || {}
|
|
258
|
+
url = images.dig('fixed_height', 'url') || images.values.first&.dig('url')
|
|
259
|
+
if url
|
|
260
|
+
attachments << { 'url' => url, 'content_type' => 'image/gif', 'name' => 'animation.gif' }
|
|
261
|
+
end
|
|
262
|
+
when 'clip'
|
|
263
|
+
media = item.dig('clip', 'clip') || {}
|
|
264
|
+
url = best_image_url(media)
|
|
265
|
+
if url
|
|
266
|
+
attachments << { 'url' => url, 'content_type' => 'image/jpeg', 'name' => 'reel_thumbnail.jpg' }
|
|
267
|
+
end
|
|
268
|
+
when 'story_share'
|
|
269
|
+
media = item.dig('story_share', 'media') || {}
|
|
270
|
+
url = best_image_url(media)
|
|
271
|
+
if url
|
|
272
|
+
attachments << { 'url' => url, 'content_type' => 'image/jpeg', 'name' => 'story.jpg' }
|
|
273
|
+
end
|
|
274
|
+
when 'xma'
|
|
275
|
+
# Shared content may have preview images
|
|
276
|
+
xma = (item['xma'] || []).first || {}
|
|
277
|
+
url = xma.dig('preview_url_info', 'url') || xma['header_icon_url']
|
|
278
|
+
if url
|
|
279
|
+
attachments << { 'url' => url, 'content_type' => 'image/jpeg', 'name' => 'shared.jpg' }
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
attachments
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def best_image_url(media)
|
|
286
|
+
# Try candidates: highest quality first
|
|
287
|
+
candidates = media.dig('image_versions2', 'candidates') || []
|
|
288
|
+
best = candidates.max_by { |c| (c['width'] || 0) * (c['height'] || 0) }
|
|
289
|
+
best&.dig('url')
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def media_type(media)
|
|
293
|
+
case media['media_type']
|
|
294
|
+
when 1 then 'image/jpeg'
|
|
295
|
+
when 2 then 'video/mp4'
|
|
296
|
+
else 'image/jpeg'
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def save_session_error(message)
|
|
301
|
+
error_file = File.join(COOKIE_DIR, 'instagram_error.txt')
|
|
302
|
+
Dir.mkdir(COOKIE_DIR) unless Dir.exist?(COOKIE_DIR)
|
|
303
|
+
File.write(error_file, "#{Time.now.iso8601}: #{message}\n", mode: 'a')
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch Instagram DM inbox via Firefox Marionette.
|
|
3
|
+
|
|
4
|
+
Connects to Firefox Marionette on port 2828, finds the Instagram tab,
|
|
5
|
+
and executes a fetch() call to get inbox data. Outputs JSON to stdout.
|
|
6
|
+
Debug info goes to stderr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
client = None
|
|
14
|
+
try:
|
|
15
|
+
from marionette_driver.marionette import Marionette
|
|
16
|
+
|
|
17
|
+
print("Connecting to Marionette...", file=sys.stderr)
|
|
18
|
+
client = Marionette(host='127.0.0.1', port=2828)
|
|
19
|
+
client.start_session()
|
|
20
|
+
|
|
21
|
+
# Find the Instagram tab
|
|
22
|
+
ig_found = False
|
|
23
|
+
for h in client.window_handles:
|
|
24
|
+
client.switch_to_window(h)
|
|
25
|
+
url = client.get_url()
|
|
26
|
+
if 'instagram.com' in url:
|
|
27
|
+
ig_found = True
|
|
28
|
+
print(f"Found Instagram tab: {url}", file=sys.stderr)
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
if not ig_found:
|
|
32
|
+
print("No Instagram tab found in Firefox", file=sys.stderr)
|
|
33
|
+
json.dump({"error": "No Instagram tab found in Firefox", "inbox": {"threads": []}}, sys.stdout)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Fetch inbox data via the Instagram private API
|
|
37
|
+
js = (
|
|
38
|
+
'let resolve = arguments[arguments.length - 1];'
|
|
39
|
+
'fetch("/api/v1/direct_v2/inbox/?limit=20&thread_message_limit=10", {'
|
|
40
|
+
' credentials: "include",'
|
|
41
|
+
' headers: {"x-ig-app-id": "936619743392459", "x-requested-with": "XMLHttpRequest"}'
|
|
42
|
+
'})'
|
|
43
|
+
'.then(function(r) { return r.text(); })'
|
|
44
|
+
'.then(function(text) { resolve(text); })'
|
|
45
|
+
'.catch(function(e) { resolve("ERROR: " + e.message); });'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
print("Fetching inbox...", file=sys.stderr)
|
|
49
|
+
result = client.execute_async_script(js, script_timeout=15000)
|
|
50
|
+
|
|
51
|
+
if result is None:
|
|
52
|
+
print("Marionette returned None", file=sys.stderr)
|
|
53
|
+
json.dump({"error": "No response from Instagram API", "inbox": {"threads": []}}, sys.stdout)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if isinstance(result, str) and result.startswith("ERROR:"):
|
|
57
|
+
print(f"Fetch error: {result}", file=sys.stderr)
|
|
58
|
+
json.dump({"error": result, "inbox": {"threads": []}}, sys.stdout)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Parse the JSON response
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(result)
|
|
64
|
+
except json.JSONDecodeError as e:
|
|
65
|
+
print(f"JSON parse error: {e}", file=sys.stderr)
|
|
66
|
+
print(f"Raw response (first 500 chars): {result[:500]}", file=sys.stderr)
|
|
67
|
+
json.dump({"error": f"JSON parse error: {e}", "inbox": {"threads": []}}, sys.stdout)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Check for API errors
|
|
71
|
+
status = data.get("status", "")
|
|
72
|
+
if status != "ok":
|
|
73
|
+
msg = data.get("message", f"API returned status: {status}")
|
|
74
|
+
if "login" in msg.lower() or "login" in str(data).lower():
|
|
75
|
+
msg = "Instagram login required. Please log in to Instagram in Firefox."
|
|
76
|
+
print(f"API error: {msg}", file=sys.stderr)
|
|
77
|
+
json.dump({"error": msg, "inbox": {"threads": []}}, sys.stdout)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Success: output the full response
|
|
81
|
+
threads = data.get("inbox", {}).get("threads", [])
|
|
82
|
+
print(f"Fetched {len(threads)} threads", file=sys.stderr)
|
|
83
|
+
json.dump(data, sys.stdout)
|
|
84
|
+
|
|
85
|
+
except ImportError:
|
|
86
|
+
json.dump({"error": "marionette_driver not installed (pip install marionette-driver)", "inbox": {"threads": []}}, sys.stdout)
|
|
87
|
+
except ConnectionRefusedError:
|
|
88
|
+
json.dump({"error": "Cannot connect to Firefox Marionette on port 2828. Start Firefox with --marionette.", "inbox": {"threads": []}}, sys.stdout)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"Unexpected error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
91
|
+
json.dump({"error": f"{type(e).__name__}: {e}", "inbox": {"threads": []}}, sys.stdout)
|
|
92
|
+
finally:
|
|
93
|
+
if client is not None:
|
|
94
|
+
try:
|
|
95
|
+
client.delete_session()
|
|
96
|
+
print("Session closed.", file=sys.stderr)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
if __name__ == '__main__':
|
|
101
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Send an Instagram DM by opening the thread in the user's browser.
|
|
3
|
+
|
|
4
|
+
Usage: instagram_send.py <thread_id> <message>
|
|
5
|
+
Outputs JSON: { "success": true/false, "message": "..." }
|
|
6
|
+
|
|
7
|
+
Since Instagram blocks all non-browser API access, this opens the DM thread
|
|
8
|
+
in the user's real browser with the message pre-copied to clipboard.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def output(success, message):
|
|
18
|
+
print(json.dumps({"success": success, "message": message}))
|
|
19
|
+
sys.exit(0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
if len(sys.argv) < 3:
|
|
24
|
+
output(False, "Usage: instagram_send.py <thread_id> <message>")
|
|
25
|
+
|
|
26
|
+
thread_id = sys.argv[1]
|
|
27
|
+
message = sys.argv[2]
|
|
28
|
+
|
|
29
|
+
if not thread_id or not message.strip():
|
|
30
|
+
output(False, "Thread ID and message are required")
|
|
31
|
+
|
|
32
|
+
url = f"https://www.instagram.com/direct/t/{thread_id}/"
|
|
33
|
+
|
|
34
|
+
# Copy message to clipboard
|
|
35
|
+
try:
|
|
36
|
+
proc = subprocess.Popen(
|
|
37
|
+
["xclip", "-selection", "clipboard"],
|
|
38
|
+
stdin=subprocess.PIPE,
|
|
39
|
+
)
|
|
40
|
+
proc.communicate(message.strip().encode())
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Open in browser
|
|
45
|
+
subprocess.Popen(
|
|
46
|
+
["xdg-open", url],
|
|
47
|
+
stdout=subprocess.DEVNULL,
|
|
48
|
+
stderr=subprocess.DEVNULL,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
output(True, "Opened Instagram DM in browser. Message copied to clipboard (Ctrl+V to paste).")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Send an Instagram DM via Firefox Marionette.
|
|
3
|
+
|
|
4
|
+
Connects to Firefox, finds the Instagram tab, navigates to the DM thread,
|
|
5
|
+
types the message and presses Enter.
|
|
6
|
+
|
|
7
|
+
Usage: instagram_send_marionette.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: instagram_send_marionette.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 Instagram tab
|
|
43
|
+
ig_handle = None
|
|
44
|
+
for h in client.window_handles:
|
|
45
|
+
client.switch_to_window(h)
|
|
46
|
+
if 'instagram.com' in client.get_url():
|
|
47
|
+
ig_handle = h
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if not ig_handle:
|
|
51
|
+
output(False, "No Instagram tab found in Firefox")
|
|
52
|
+
|
|
53
|
+
# Navigate to DM thread
|
|
54
|
+
client.navigate(f"https://www.instagram.com/direct/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('textarea[placeholder]')
|
|
60
|
+
|| document.querySelector('[role="textbox"][contenteditable="true"]');
|
|
61
|
+
return el ? el.tagName : null;
|
|
62
|
+
""")
|
|
63
|
+
if found:
|
|
64
|
+
break
|
|
65
|
+
time.sleep(0.5)
|
|
66
|
+
else:
|
|
67
|
+
output(False, "Could not find message input")
|
|
68
|
+
|
|
69
|
+
# Find and interact with the input
|
|
70
|
+
from marionette_driver.by import By
|
|
71
|
+
time.sleep(0.3)
|
|
72
|
+
|
|
73
|
+
# Try textarea first, then contenteditable
|
|
74
|
+
try:
|
|
75
|
+
el = client.find_element(By.CSS_SELECTOR, 'textarea[placeholder]')
|
|
76
|
+
except Exception:
|
|
77
|
+
el = client.find_element(By.CSS_SELECTOR, '[role="textbox"][contenteditable="true"]')
|
|
78
|
+
|
|
79
|
+
el.click()
|
|
80
|
+
time.sleep(0.2)
|
|
81
|
+
|
|
82
|
+
# Use clipboard to paste (avoids character interpretation issues with <, > etc.)
|
|
83
|
+
import subprocess
|
|
84
|
+
proc = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE)
|
|
85
|
+
proc.communicate(message.encode())
|
|
86
|
+
|
|
87
|
+
el.send_keys(Keys.CONTROL + "v")
|
|
88
|
+
time.sleep(0.3)
|
|
89
|
+
el.send_keys(Keys.ENTER)
|
|
90
|
+
|
|
91
|
+
output(True, "Message sent via Instagram")
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
output(False, f"Instagram send error: {e}")
|
|
95
|
+
finally:
|
|
96
|
+
if client:
|
|
97
|
+
try:
|
|
98
|
+
client.delete_session()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == '__main__':
|
|
104
|
+
main()
|