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,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Send a Messenger DM via Firefox Marionette.
|
|
3
|
+
|
|
4
|
+
Connects to Firefox, finds the Messenger tab, navigates to the thread,
|
|
5
|
+
types the message and presses Enter.
|
|
6
|
+
|
|
7
|
+
Usage: messenger_send.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: messenger_send.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 Messenger tab
|
|
43
|
+
msng_handle = None
|
|
44
|
+
for h in client.window_handles:
|
|
45
|
+
client.switch_to_window(h)
|
|
46
|
+
if 'messenger.com' in client.get_url():
|
|
47
|
+
msng_handle = h
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if not msng_handle:
|
|
51
|
+
output(False, "No Messenger tab found in Firefox")
|
|
52
|
+
|
|
53
|
+
# Navigate to thread
|
|
54
|
+
client.navigate(f"https://www.messenger.com/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('[role="textbox"][contenteditable="true"]');
|
|
60
|
+
return el ? true : false;
|
|
61
|
+
""")
|
|
62
|
+
if found:
|
|
63
|
+
break
|
|
64
|
+
time.sleep(0.5)
|
|
65
|
+
else:
|
|
66
|
+
output(False, "Could not find message input")
|
|
67
|
+
|
|
68
|
+
# Focus the input and set text via DOM, then press Enter
|
|
69
|
+
from marionette_driver.by import By
|
|
70
|
+
client.execute_script("""
|
|
71
|
+
var el = document.querySelector('[role="textbox"][contenteditable="true"]');
|
|
72
|
+
el.focus();
|
|
73
|
+
""")
|
|
74
|
+
time.sleep(0.2)
|
|
75
|
+
|
|
76
|
+
# Use clipboard to paste the message (avoids character interpretation issues)
|
|
77
|
+
import subprocess
|
|
78
|
+
proc = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE)
|
|
79
|
+
proc.communicate(message.encode())
|
|
80
|
+
|
|
81
|
+
el = client.find_element(By.CSS_SELECTOR, '[role="textbox"][contenteditable="true"]')
|
|
82
|
+
# Ctrl+V to paste, then Enter to send
|
|
83
|
+
el.send_keys(Keys.CONTROL + "v")
|
|
84
|
+
time.sleep(0.3)
|
|
85
|
+
el.send_keys(Keys.ENTER)
|
|
86
|
+
|
|
87
|
+
output(True, "Message sent via Messenger")
|
|
88
|
+
|
|
89
|
+
except Exception as e:
|
|
90
|
+
output(False, f"Messenger send error: {e}")
|
|
91
|
+
finally:
|
|
92
|
+
if client:
|
|
93
|
+
try:
|
|
94
|
+
client.delete_session()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == '__main__':
|
|
100
|
+
main()
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'base64'
|
|
8
|
+
require 'time'
|
|
9
|
+
|
|
10
|
+
module Heathrow
|
|
11
|
+
module Sources
|
|
12
|
+
class Reddit
|
|
13
|
+
attr_reader :source, :last_fetch_time
|
|
14
|
+
|
|
15
|
+
def initialize(source)
|
|
16
|
+
@source = source
|
|
17
|
+
@config = source.config.is_a?(String) ? JSON.parse(source.config) : source.config
|
|
18
|
+
@last_fetch_time = Time.now
|
|
19
|
+
@access_token = nil
|
|
20
|
+
@token_expires_at = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch_messages
|
|
24
|
+
messages = []
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
# Get access token if needed
|
|
28
|
+
ensure_access_token
|
|
29
|
+
|
|
30
|
+
# Determine what to fetch based on mode
|
|
31
|
+
mode = @config['mode'] || 'subreddit'
|
|
32
|
+
|
|
33
|
+
case mode
|
|
34
|
+
when 'subreddit'
|
|
35
|
+
messages = fetch_subreddit_posts
|
|
36
|
+
when 'messages'
|
|
37
|
+
messages = fetch_private_messages
|
|
38
|
+
else
|
|
39
|
+
puts "Unknown Reddit mode: #{mode}" if ENV['DEBUG']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
rescue => e
|
|
43
|
+
puts "Reddit fetch error: #{e.message}" if ENV['DEBUG']
|
|
44
|
+
puts e.backtrace.join("\n") if ENV['DEBUG']
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
messages
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_connection
|
|
51
|
+
begin
|
|
52
|
+
ensure_access_token
|
|
53
|
+
|
|
54
|
+
# Test basic API access
|
|
55
|
+
data = make_api_request('/api/v1/me')
|
|
56
|
+
|
|
57
|
+
if data
|
|
58
|
+
if data['name']
|
|
59
|
+
{ success: true, message: "Connected as u/#{data['name']}" }
|
|
60
|
+
else
|
|
61
|
+
{ success: true, message: "Connected with read-only access" }
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
{ success: false, message: "Failed to connect to Reddit API" }
|
|
65
|
+
end
|
|
66
|
+
rescue => e
|
|
67
|
+
{ success: false, message: "Connection test failed: #{e.message}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def can_reply?
|
|
72
|
+
# Can reply to PMs if we have username/password auth
|
|
73
|
+
@config['username'] && @config['password']
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def send_message(to, subject, body, in_reply_to = nil)
|
|
77
|
+
unless can_reply?
|
|
78
|
+
return { success: false, message: "Reddit requires username/password authentication to send messages" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
ensure_access_token
|
|
83
|
+
|
|
84
|
+
if in_reply_to
|
|
85
|
+
# Reply to an existing message or comment
|
|
86
|
+
send_reply(in_reply_to, body)
|
|
87
|
+
else
|
|
88
|
+
# Send a new private message
|
|
89
|
+
send_private_message(to, subject, body)
|
|
90
|
+
end
|
|
91
|
+
rescue => e
|
|
92
|
+
{ success: false, message: "Failed to send: #{e.message}" }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def send_private_message(to, subject, body)
|
|
99
|
+
# Remove u/ prefix if present
|
|
100
|
+
recipient = to.sub(/^u\//, '')
|
|
101
|
+
|
|
102
|
+
uri = URI('https://oauth.reddit.com/api/compose')
|
|
103
|
+
|
|
104
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
105
|
+
http.use_ssl = true
|
|
106
|
+
|
|
107
|
+
request = Net::HTTP::Post.new(uri)
|
|
108
|
+
request['Authorization'] = "Bearer #{@access_token}"
|
|
109
|
+
request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
|
|
110
|
+
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
111
|
+
|
|
112
|
+
request.set_form_data(
|
|
113
|
+
'api_type' => 'json',
|
|
114
|
+
'to' => recipient,
|
|
115
|
+
'subject' => subject,
|
|
116
|
+
'text' => body
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
response = http.request(request)
|
|
120
|
+
|
|
121
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
122
|
+
data = JSON.parse(response.body)
|
|
123
|
+
|
|
124
|
+
if data['json'] && data['json']['errors'] && !data['json']['errors'].empty?
|
|
125
|
+
errors = data['json']['errors'].map { |e| e[1] }.join(', ')
|
|
126
|
+
{ success: false, message: "Reddit API error: #{errors}" }
|
|
127
|
+
else
|
|
128
|
+
{ success: true, message: "Message sent to u/#{recipient}" }
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
{ success: false, message: "HTTP error: #{response.code} #{response.message}" }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def send_reply(thing_id, body)
|
|
136
|
+
# thing_id is the fullname of the thing to reply to (e.g., t1_xxx for comment, t4_xxx for PM)
|
|
137
|
+
uri = URI('https://oauth.reddit.com/api/comment')
|
|
138
|
+
|
|
139
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
140
|
+
http.use_ssl = true
|
|
141
|
+
|
|
142
|
+
request = Net::HTTP::Post.new(uri)
|
|
143
|
+
request['Authorization'] = "Bearer #{@access_token}"
|
|
144
|
+
request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
|
|
145
|
+
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
146
|
+
|
|
147
|
+
request.set_form_data(
|
|
148
|
+
'api_type' => 'json',
|
|
149
|
+
'thing_id' => thing_id,
|
|
150
|
+
'text' => body
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
response = http.request(request)
|
|
154
|
+
|
|
155
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
156
|
+
data = JSON.parse(response.body)
|
|
157
|
+
|
|
158
|
+
if data['json'] && data['json']['errors'] && !data['json']['errors'].empty?
|
|
159
|
+
errors = data['json']['errors'].map { |e| e[1] }.join(', ')
|
|
160
|
+
{ success: false, message: "Reddit API error: #{errors}" }
|
|
161
|
+
else
|
|
162
|
+
{ success: true, message: "Reply sent" }
|
|
163
|
+
end
|
|
164
|
+
else
|
|
165
|
+
{ success: false, message: "HTTP error: #{response.code} #{response.message}" }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def ensure_access_token
|
|
170
|
+
# Check if we need a new token
|
|
171
|
+
if @access_token.nil? || @token_expires_at.nil? || Time.now >= @token_expires_at
|
|
172
|
+
get_access_token
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def get_access_token
|
|
177
|
+
# Reddit OAuth2 token endpoint
|
|
178
|
+
uri = URI('https://www.reddit.com/api/v1/access_token')
|
|
179
|
+
|
|
180
|
+
# Prepare the request
|
|
181
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
182
|
+
http.use_ssl = true
|
|
183
|
+
|
|
184
|
+
request = Net::HTTP::Post.new(uri)
|
|
185
|
+
|
|
186
|
+
# Basic auth with client_id:client_secret
|
|
187
|
+
client_id = @config['client_id']
|
|
188
|
+
client_secret = @config['client_secret']
|
|
189
|
+
auth = Base64.strict_encode64("#{client_id}:#{client_secret}")
|
|
190
|
+
request['Authorization'] = "Basic #{auth}"
|
|
191
|
+
request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
|
|
192
|
+
|
|
193
|
+
# Different grant types based on whether we have user credentials
|
|
194
|
+
if @config['username'] && @config['password']
|
|
195
|
+
# Script app with username/password
|
|
196
|
+
request.set_form_data(
|
|
197
|
+
'grant_type' => 'password',
|
|
198
|
+
'username' => @config['username'],
|
|
199
|
+
'password' => @config['password']
|
|
200
|
+
)
|
|
201
|
+
else
|
|
202
|
+
# Read-only access
|
|
203
|
+
request.set_form_data(
|
|
204
|
+
'grant_type' => 'client_credentials'
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
response = http.request(request)
|
|
209
|
+
|
|
210
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
211
|
+
token_data = JSON.parse(response.body)
|
|
212
|
+
@access_token = token_data['access_token']
|
|
213
|
+
# Token expires in 'expires_in' seconds, refresh 5 minutes before
|
|
214
|
+
@token_expires_at = Time.now + token_data['expires_in'] - 300
|
|
215
|
+
puts "Got Reddit access token, expires at #{@token_expires_at}" if ENV['DEBUG']
|
|
216
|
+
else
|
|
217
|
+
raise "Failed to get Reddit access token: #{response.code} #{response.body}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def make_api_request(endpoint, params = {})
|
|
222
|
+
uri = URI("https://oauth.reddit.com#{endpoint}")
|
|
223
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
224
|
+
|
|
225
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
226
|
+
http.use_ssl = true
|
|
227
|
+
|
|
228
|
+
request = Net::HTTP::Get.new(uri)
|
|
229
|
+
request['Authorization'] = "Bearer #{@access_token}"
|
|
230
|
+
request['User-Agent'] = @config['user_agent'] || 'Heathrow/1.0'
|
|
231
|
+
|
|
232
|
+
response = http.request(request)
|
|
233
|
+
|
|
234
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
235
|
+
JSON.parse(response.body)
|
|
236
|
+
else
|
|
237
|
+
puts "Reddit API error: #{response.code} #{response.body}" if ENV['DEBUG']
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def fetch_subreddit_posts
|
|
243
|
+
messages = []
|
|
244
|
+
subreddits = @config['subreddits'] || 'programming'
|
|
245
|
+
subreddits = subreddits.split(',').map(&:strip) if subreddits.is_a?(String)
|
|
246
|
+
|
|
247
|
+
limit = @config['fetch_limit'] || 25
|
|
248
|
+
include_comments = @config['include_comments'] || false
|
|
249
|
+
|
|
250
|
+
subreddits.each do |subreddit|
|
|
251
|
+
# Fetch hot posts from subreddit
|
|
252
|
+
data = make_api_request("/r/#{subreddit}/hot", { limit: limit })
|
|
253
|
+
|
|
254
|
+
next unless data && data['data'] && data['data']['children']
|
|
255
|
+
|
|
256
|
+
data['data']['children'].each do |post_wrapper|
|
|
257
|
+
post = post_wrapper['data']
|
|
258
|
+
|
|
259
|
+
# Skip if we've seen this before (basic deduplication)
|
|
260
|
+
external_id = "reddit_post_#{post['id']}"
|
|
261
|
+
|
|
262
|
+
# Convert post to message format
|
|
263
|
+
message = {
|
|
264
|
+
source_id: @source.id,
|
|
265
|
+
source_type: 'reddit',
|
|
266
|
+
external_id: external_id,
|
|
267
|
+
sender: post['author'] || '[deleted]',
|
|
268
|
+
recipient: "r/#{subreddit}",
|
|
269
|
+
subject: post['title'],
|
|
270
|
+
content: format_post_content(post),
|
|
271
|
+
raw_data: post.to_json,
|
|
272
|
+
attachments: extract_attachments(post),
|
|
273
|
+
timestamp: Time.at(post['created_utc']).iso8601,
|
|
274
|
+
is_read: 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
messages << message
|
|
278
|
+
|
|
279
|
+
# Optionally fetch top comments
|
|
280
|
+
if include_comments && post['num_comments'] > 0
|
|
281
|
+
fetch_post_comments(post['id'], subreddit, post['title'], limit: 5).each do |comment|
|
|
282
|
+
messages << comment
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
messages
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def fetch_private_messages
|
|
292
|
+
messages = []
|
|
293
|
+
|
|
294
|
+
# Fetch inbox messages (requires authentication with username/password)
|
|
295
|
+
unless @config['username'] && @config['password']
|
|
296
|
+
puts "Reddit private messages require username/password authentication" if ENV['DEBUG']
|
|
297
|
+
return messages
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Fetch unread messages
|
|
301
|
+
data = make_api_request('/message/unread', { limit: 100 })
|
|
302
|
+
|
|
303
|
+
if data && data['data'] && data['data']['children']
|
|
304
|
+
data['data']['children'].each do |msg_wrapper|
|
|
305
|
+
msg = msg_wrapper['data']
|
|
306
|
+
|
|
307
|
+
external_id = "reddit_msg_#{msg['id']}"
|
|
308
|
+
|
|
309
|
+
message = {
|
|
310
|
+
source_id: @source.id,
|
|
311
|
+
source_type: 'reddit',
|
|
312
|
+
external_id: external_id,
|
|
313
|
+
sender: msg['author'] || '[deleted]',
|
|
314
|
+
recipient: @config['username'],
|
|
315
|
+
subject: msg['subject'] || 'Reddit Message',
|
|
316
|
+
content: msg['body'] || '',
|
|
317
|
+
raw_data: msg.to_json,
|
|
318
|
+
attachments: nil,
|
|
319
|
+
timestamp: Time.at(msg['created_utc']).iso8601,
|
|
320
|
+
is_read: msg['new'] ? 0 : 1
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
messages << message
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Also fetch recent messages (not just unread)
|
|
328
|
+
data = make_api_request('/message/inbox', { limit: 50 })
|
|
329
|
+
|
|
330
|
+
if data && data['data'] && data['data']['children']
|
|
331
|
+
data['data']['children'].each do |msg_wrapper|
|
|
332
|
+
msg = msg_wrapper['data']
|
|
333
|
+
|
|
334
|
+
external_id = "reddit_msg_#{msg['id']}"
|
|
335
|
+
|
|
336
|
+
# Skip if we already have this message
|
|
337
|
+
next if messages.any? { |m| m[:external_id] == external_id }
|
|
338
|
+
|
|
339
|
+
message = {
|
|
340
|
+
source_id: @source.id,
|
|
341
|
+
source_type: 'reddit',
|
|
342
|
+
external_id: external_id,
|
|
343
|
+
sender: msg['author'] || '[deleted]',
|
|
344
|
+
recipient: @config['username'],
|
|
345
|
+
subject: msg['subject'] || 'Reddit Message',
|
|
346
|
+
content: msg['body'] || '',
|
|
347
|
+
raw_data: msg.to_json,
|
|
348
|
+
attachments: nil,
|
|
349
|
+
timestamp: Time.at(msg['created_utc']).iso8601,
|
|
350
|
+
is_read: msg['new'] ? 0 : 1
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
messages << message
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
messages
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def fetch_post_comments(post_id, subreddit, post_title, limit: 5)
|
|
361
|
+
comments = []
|
|
362
|
+
|
|
363
|
+
# Fetch comments for a specific post
|
|
364
|
+
data = make_api_request("/r/#{subreddit}/comments/#{post_id}", { limit: limit })
|
|
365
|
+
|
|
366
|
+
return comments unless data && data.is_a?(Array) && data[1]
|
|
367
|
+
|
|
368
|
+
# Comments are in the second element
|
|
369
|
+
comment_data = data[1]
|
|
370
|
+
|
|
371
|
+
if comment_data['data'] && comment_data['data']['children']
|
|
372
|
+
comment_data['data']['children'].each do |comment_wrapper|
|
|
373
|
+
next unless comment_wrapper['kind'] == 't1' # t1 = comment
|
|
374
|
+
|
|
375
|
+
comment = comment_wrapper['data']
|
|
376
|
+
next unless comment['author'] # Skip deleted comments
|
|
377
|
+
|
|
378
|
+
external_id = "reddit_comment_#{comment['id']}"
|
|
379
|
+
|
|
380
|
+
message = {
|
|
381
|
+
source_id: @source.id,
|
|
382
|
+
source_type: 'reddit',
|
|
383
|
+
external_id: external_id,
|
|
384
|
+
sender: comment['author'],
|
|
385
|
+
recipient: "r/#{subreddit}",
|
|
386
|
+
subject: "Re: #{post_title[0..50]}",
|
|
387
|
+
content: comment['body'] || '',
|
|
388
|
+
raw_data: comment.to_json,
|
|
389
|
+
attachments: nil,
|
|
390
|
+
timestamp: Time.at(comment['created_utc']).iso8601,
|
|
391
|
+
is_read: 0
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
comments << message
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
comments
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def format_post_content(post)
|
|
402
|
+
content = []
|
|
403
|
+
|
|
404
|
+
# Add self text if present
|
|
405
|
+
if post['selftext'] && !post['selftext'].empty?
|
|
406
|
+
content << post['selftext']
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Add URL if it's a link post
|
|
410
|
+
if post['url'] && post['url'] != post['permalink']
|
|
411
|
+
content << "\nLink: #{post['url']}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Add metadata
|
|
415
|
+
content << "\nScore: #{post['score']} | Comments: #{post['num_comments']}"
|
|
416
|
+
content << "Permalink: https://reddit.com#{post['permalink']}"
|
|
417
|
+
|
|
418
|
+
# Add flair if present
|
|
419
|
+
if post['link_flair_text']
|
|
420
|
+
content << "Flair: #{post['link_flair_text']}"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
content.join("\n")
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def extract_attachments(post)
|
|
427
|
+
attachments = []
|
|
428
|
+
|
|
429
|
+
# Check for image/video content
|
|
430
|
+
if post['url']
|
|
431
|
+
url = post['url']
|
|
432
|
+
|
|
433
|
+
# Direct image links
|
|
434
|
+
if url =~ /\.(jpg|jpeg|png|gif|webp)$/i
|
|
435
|
+
attachments << { type: 'image', url: url }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Reddit gallery
|
|
439
|
+
if post['is_gallery'] && post['media_metadata']
|
|
440
|
+
post['media_metadata'].each do |_id, media|
|
|
441
|
+
if media['s'] && media['s']['u']
|
|
442
|
+
# Convert preview URL to full URL
|
|
443
|
+
full_url = media['s']['u'].gsub('preview.redd.it', 'i.redd.it')
|
|
444
|
+
.gsub(/\?.*$/, '')
|
|
445
|
+
attachments << { type: 'image', url: full_url }
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Reddit video
|
|
451
|
+
if post['is_video'] && post['media'] && post['media']['reddit_video']
|
|
452
|
+
video_url = post['media']['reddit_video']['fallback_url']
|
|
453
|
+
attachments << { type: 'video', url: video_url }
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
attachments.empty? ? nil : attachments.to_json
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|