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,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()