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,299 @@
|
|
|
1
|
+
require 'rss'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
|
|
7
|
+
module Heathrow
|
|
8
|
+
module Sources
|
|
9
|
+
class RSS < Base
|
|
10
|
+
def initialize(name, config, db)
|
|
11
|
+
super
|
|
12
|
+
# feeds can be simple URLs or hashes with {url:, title:, tags:}
|
|
13
|
+
@feeds = config['feeds'] || []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Sync all feeds into the database. Called from application.rb on refresh.
|
|
17
|
+
def sync(source_id)
|
|
18
|
+
count = 0
|
|
19
|
+
changed = false
|
|
20
|
+
@feeds.each do |feed_entry|
|
|
21
|
+
url, title, tags = parse_feed_entry(feed_entry)
|
|
22
|
+
begin
|
|
23
|
+
n = sync_feed(source_id, url, title, tags)
|
|
24
|
+
count += n
|
|
25
|
+
if feed_entry.is_a?(Hash)
|
|
26
|
+
feed_entry['last_status'] = 'ok'
|
|
27
|
+
feed_entry['last_sync'] = Time.now.to_i
|
|
28
|
+
changed = true
|
|
29
|
+
end
|
|
30
|
+
rescue => e
|
|
31
|
+
STDERR.puts "RSS error #{url}: #{e.message}" if ENV['DEBUG']
|
|
32
|
+
if feed_entry.is_a?(Hash)
|
|
33
|
+
feed_entry['last_status'] = "error: #{e.message}"[0..120]
|
|
34
|
+
feed_entry['last_sync'] = Time.now.to_i
|
|
35
|
+
changed = true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
if changed
|
|
40
|
+
@config['feeds'] = @feeds
|
|
41
|
+
save_config
|
|
42
|
+
end
|
|
43
|
+
count
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Legacy interface for source_manager
|
|
47
|
+
def fetch
|
|
48
|
+
return [] unless enabled?
|
|
49
|
+
source = @db.get_source_by_name(@name)
|
|
50
|
+
return [] unless source
|
|
51
|
+
sync(source['id'])
|
|
52
|
+
update_last_fetch
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_feed(url, title: nil, tags: [])
|
|
57
|
+
entry = { 'url' => url, 'title' => title, 'tags' => tags }
|
|
58
|
+
@feeds << entry unless @feeds.any? { |f| feed_url(f) == url }
|
|
59
|
+
@config['feeds'] = @feeds
|
|
60
|
+
save_config
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove_feed(url)
|
|
64
|
+
@feeds.reject! { |f| feed_url(f) == url }
|
|
65
|
+
@config['feeds'] = @feeds
|
|
66
|
+
save_config
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def list_feeds
|
|
70
|
+
@feeds.map { |f| { url: feed_url(f), title: feed_title(f), tags: feed_tags(f) } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_connection(&progress)
|
|
74
|
+
ok = 0
|
|
75
|
+
fail_names = []
|
|
76
|
+
fail_details = []
|
|
77
|
+
@feeds.each_with_index do |f, i|
|
|
78
|
+
url = feed_url(f)
|
|
79
|
+
title = feed_title(f) || url
|
|
80
|
+
progress.call("Testing #{i+1}/#{@feeds.size}: #{title}") if progress
|
|
81
|
+
begin
|
|
82
|
+
data = http_get(url)
|
|
83
|
+
if data && !data.empty?
|
|
84
|
+
ok += 1
|
|
85
|
+
else
|
|
86
|
+
fail_names << title
|
|
87
|
+
fail_details << title
|
|
88
|
+
end
|
|
89
|
+
rescue => e
|
|
90
|
+
fail_names << title
|
|
91
|
+
fail_details << "#{title}: #{e.message[0..50]}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
if fail_names.empty?
|
|
95
|
+
{ success: true, message: "All #{ok} feeds OK" }
|
|
96
|
+
else
|
|
97
|
+
{ success: false, message: "#{ok}/#{@feeds.size} OK. Failed: #{fail_details.join(', ')}", failed_feeds: fail_names }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def parse_feed_entry(entry)
|
|
104
|
+
if entry.is_a?(Hash)
|
|
105
|
+
[entry['url'], entry['title'], entry['tags'] || []]
|
|
106
|
+
else
|
|
107
|
+
[entry.to_s, nil, []]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def feed_url(entry)
|
|
112
|
+
entry.is_a?(Hash) ? entry['url'] : entry.to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def feed_title(entry)
|
|
116
|
+
entry.is_a?(Hash) ? entry['title'] : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def feed_tags(entry)
|
|
120
|
+
entry.is_a?(Hash) ? (entry['tags'] || []) : []
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def sync_feed(source_id, url, custom_title, tags)
|
|
124
|
+
count = 0
|
|
125
|
+
|
|
126
|
+
raw = http_get(url)
|
|
127
|
+
raise "fetch failed (no response)" unless raw && !raw.empty?
|
|
128
|
+
|
|
129
|
+
feed = ::RSS::Parser.parse(raw, false)
|
|
130
|
+
raise "parse failed (not a valid feed)" unless feed && feed.items
|
|
131
|
+
|
|
132
|
+
is_atom = feed.is_a?(::RSS::Atom::Feed)
|
|
133
|
+
feed_title = custom_title || (is_atom ? atom_text(feed.title) : feed.channel&.title) || url
|
|
134
|
+
|
|
135
|
+
feed.items.each do |item|
|
|
136
|
+
link = item_link(item, is_atom)
|
|
137
|
+
ext_id = link || item_id(item, is_atom) || Digest::MD5.hexdigest(item_title(item, is_atom) + url)
|
|
138
|
+
|
|
139
|
+
title = strip_html(item_title(item, is_atom)).gsub(/\s+/, ' ').strip
|
|
140
|
+
# Keep HTML for rich rendering; also store plain text
|
|
141
|
+
html_content = item_content(item, is_atom)
|
|
142
|
+
plain_content = strip_html(html_content)
|
|
143
|
+
|
|
144
|
+
timestamp = extract_timestamp(item)
|
|
145
|
+
author = strip_html(extract_author(item) || '')
|
|
146
|
+
categories = extract_categories(item)
|
|
147
|
+
|
|
148
|
+
labels = [feed_title] + tags + categories
|
|
149
|
+
labels.uniq!
|
|
150
|
+
|
|
151
|
+
data = {
|
|
152
|
+
source_id: source_id,
|
|
153
|
+
external_id: "rss_#{Digest::MD5.hexdigest(ext_id)}",
|
|
154
|
+
sender: (author.nil? || author.empty?) ? feed_title : author,
|
|
155
|
+
sender_name: (author.nil? || author.empty?) ? feed_title : author,
|
|
156
|
+
recipients: [feed_title],
|
|
157
|
+
subject: title,
|
|
158
|
+
content: plain_content,
|
|
159
|
+
html_content: html_content,
|
|
160
|
+
timestamp: timestamp.to_i,
|
|
161
|
+
received_at: Time.now.to_i,
|
|
162
|
+
read: false,
|
|
163
|
+
starred: false,
|
|
164
|
+
archived: false,
|
|
165
|
+
labels: labels,
|
|
166
|
+
metadata: {
|
|
167
|
+
link: link,
|
|
168
|
+
feed_url: url,
|
|
169
|
+
feed_title: feed_title,
|
|
170
|
+
author: author,
|
|
171
|
+
categories: categories,
|
|
172
|
+
tags: tags
|
|
173
|
+
},
|
|
174
|
+
raw_data: { link: link, feed_title: feed_title, author: author, categories: categories }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
@db.insert_message(data)
|
|
179
|
+
count += 1
|
|
180
|
+
rescue SQLite3::ConstraintException
|
|
181
|
+
# Already exists
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
count
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def http_get(url, _redirects = 8)
|
|
189
|
+
# Use curl — Ruby's net-http gem has chunked encoding bugs
|
|
190
|
+
result = `curl -sL --max-time 15 --max-redirs 8 -A 'Heathrow/1.0 (RSS Reader)' #{Shellwords.escape(url)} 2>/dev/null`
|
|
191
|
+
$?.success? && !result.empty? ? result : nil
|
|
192
|
+
rescue => e
|
|
193
|
+
STDERR.puts "RSS fetch error #{url}: #{e.message}" if ENV['DEBUG']
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Atom/RSS2 normalization helpers
|
|
198
|
+
def atom_text(obj)
|
|
199
|
+
return nil unless obj
|
|
200
|
+
obj.respond_to?(:content) ? obj.content : obj.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def item_title(item, is_atom)
|
|
204
|
+
if is_atom
|
|
205
|
+
atom_text(item.title) || 'No title'
|
|
206
|
+
else
|
|
207
|
+
item.title || 'No title'
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def item_link(item, is_atom)
|
|
212
|
+
if is_atom
|
|
213
|
+
# Atom links: find rel="alternate" or first link
|
|
214
|
+
if item.respond_to?(:links) && item.links
|
|
215
|
+
alt = item.links.find { |l| l.rel.nil? || l.rel == 'alternate' }
|
|
216
|
+
(alt || item.links.first)&.href
|
|
217
|
+
elsif item.link.respond_to?(:href)
|
|
218
|
+
item.link.href
|
|
219
|
+
else
|
|
220
|
+
item.link.to_s
|
|
221
|
+
end
|
|
222
|
+
else
|
|
223
|
+
item.link
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def item_id(item, is_atom)
|
|
228
|
+
if is_atom
|
|
229
|
+
atom_text(item.id)
|
|
230
|
+
else
|
|
231
|
+
item.guid&.content
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def item_content(item, is_atom)
|
|
236
|
+
if is_atom
|
|
237
|
+
# Prefer content over summary for Atom
|
|
238
|
+
c = atom_text(item.content) if item.respond_to?(:content) && item.content
|
|
239
|
+
c ||= atom_text(item.summary) if item.respond_to?(:summary) && item.summary
|
|
240
|
+
c || ''
|
|
241
|
+
else
|
|
242
|
+
item.description || (item.content_encoded rescue nil) || ''
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def extract_timestamp(item)
|
|
247
|
+
if item.respond_to?(:pubDate) && item.pubDate
|
|
248
|
+
item.pubDate
|
|
249
|
+
elsif item.respond_to?(:dc_date) && item.dc_date
|
|
250
|
+
item.dc_date
|
|
251
|
+
elsif item.respond_to?(:updated) && item.updated
|
|
252
|
+
item.updated.content rescue Time.now
|
|
253
|
+
else
|
|
254
|
+
Time.now
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def extract_author(item)
|
|
259
|
+
if item.respond_to?(:author) && item.author
|
|
260
|
+
# Atom author objects have a .name method; .to_s may give raw XML
|
|
261
|
+
author = if item.author.respond_to?(:name) && item.author.name
|
|
262
|
+
atom_text(item.author.name)
|
|
263
|
+
else
|
|
264
|
+
item.author.to_s
|
|
265
|
+
end
|
|
266
|
+
return author unless author.nil? || author.empty?
|
|
267
|
+
end
|
|
268
|
+
if item.respond_to?(:dc_creator) && item.dc_creator
|
|
269
|
+
item.dc_creator
|
|
270
|
+
else
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def extract_categories(item)
|
|
276
|
+
cats = []
|
|
277
|
+
if item.respond_to?(:categories) && item.categories
|
|
278
|
+
cats = item.categories.map { |c| c.content rescue c.to_s }.compact
|
|
279
|
+
end
|
|
280
|
+
cats
|
|
281
|
+
rescue
|
|
282
|
+
[]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def strip_html(text)
|
|
286
|
+
return '' if text.nil? || text.empty?
|
|
287
|
+
text.gsub(/<[^>]*>/, '')
|
|
288
|
+
.gsub(/ /, ' ')
|
|
289
|
+
.gsub(/&/, '&')
|
|
290
|
+
.gsub(/</, '<')
|
|
291
|
+
.gsub(/>/, '>')
|
|
292
|
+
.gsub(/"/, '"')
|
|
293
|
+
.gsub(/'/, "'")
|
|
294
|
+
.gsub(/\n\s*\n\s*\n/, "\n\n")
|
|
295
|
+
.strip
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'time'
|
|
8
|
+
|
|
9
|
+
module Heathrow
|
|
10
|
+
module Sources
|
|
11
|
+
# Slack source implementation
|
|
12
|
+
class Slack
|
|
13
|
+
def initialize(source)
|
|
14
|
+
@source = source
|
|
15
|
+
@base_url = 'https://slack.com/api'
|
|
16
|
+
|
|
17
|
+
# Cache for user and channel names
|
|
18
|
+
@users_cache = {}
|
|
19
|
+
@channels_cache = {}
|
|
20
|
+
@last_fetch = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
'slack'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_connection
|
|
28
|
+
uri = URI("#{@base_url}/auth.test")
|
|
29
|
+
response = make_api_request(uri)
|
|
30
|
+
|
|
31
|
+
if response && response['ok']
|
|
32
|
+
puts "Successfully connected to Slack workspace: #{response['team']}"
|
|
33
|
+
true
|
|
34
|
+
else
|
|
35
|
+
error = response ? response['error'] : 'Unknown error'
|
|
36
|
+
puts "Failed to connect: #{error}"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
rescue => e
|
|
40
|
+
puts "Connection error: #{e.message}"
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_messages
|
|
45
|
+
messages = []
|
|
46
|
+
|
|
47
|
+
channel_ids = @source.config['channel_ids'] || []
|
|
48
|
+
dm_user_ids = @source.config['dm_user_ids'] || []
|
|
49
|
+
|
|
50
|
+
# Fetch channel messages
|
|
51
|
+
channel_ids.each do |channel_id|
|
|
52
|
+
channel_messages = fetch_channel_messages(channel_id)
|
|
53
|
+
messages.concat(channel_messages)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fetch direct messages
|
|
57
|
+
dm_user_ids.each do |user_id|
|
|
58
|
+
dm_messages = fetch_direct_messages(user_id)
|
|
59
|
+
messages.concat(dm_messages)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fetch all channels if no specific ones configured
|
|
63
|
+
if channel_ids.empty? && dm_user_ids.empty?
|
|
64
|
+
all_channels = fetch_all_conversations
|
|
65
|
+
all_channels.each do |channel|
|
|
66
|
+
# Debug logging
|
|
67
|
+
File.write('/tmp/slack_channels.log', "Channel: #{channel['name']} (#{channel['id']}), is_member: #{channel['is_member']}\n", mode: 'a') if ENV['DEBUG']
|
|
68
|
+
|
|
69
|
+
# Only fetch from channels we're a member of
|
|
70
|
+
next unless channel['is_member']
|
|
71
|
+
|
|
72
|
+
if channel['is_im']
|
|
73
|
+
# Direct message
|
|
74
|
+
dm_messages = fetch_direct_messages(channel['user'] || channel['id'])
|
|
75
|
+
messages.concat(dm_messages)
|
|
76
|
+
else
|
|
77
|
+
# Any other type of channel (public, private, group)
|
|
78
|
+
channel_messages = fetch_channel_messages(channel['id'])
|
|
79
|
+
messages.concat(channel_messages)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
messages
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def send_message(recipient, content, metadata = {})
|
|
88
|
+
channel = resolve_channel(recipient)
|
|
89
|
+
|
|
90
|
+
uri = URI("#{@base_url}/chat.postMessage")
|
|
91
|
+
params = {
|
|
92
|
+
channel: channel,
|
|
93
|
+
text: content,
|
|
94
|
+
as_user: true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Handle thread replies
|
|
98
|
+
if metadata[:thread_ts]
|
|
99
|
+
params[:thread_ts] = metadata[:thread_ts]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
response = make_api_request(uri, params)
|
|
103
|
+
|
|
104
|
+
if response && response['ok']
|
|
105
|
+
{ success: true, message_id: response['ts'] }
|
|
106
|
+
else
|
|
107
|
+
error = response ? response['error'] : 'Unknown error'
|
|
108
|
+
{ success: false, error: error }
|
|
109
|
+
end
|
|
110
|
+
rescue => e
|
|
111
|
+
{ success: false, error: e.message }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def mark_as_read(message_id, channel_id = nil)
|
|
115
|
+
return { success: false, error: 'Channel ID required for Slack' } unless channel_id
|
|
116
|
+
|
|
117
|
+
# Determine the correct API endpoint based on channel type
|
|
118
|
+
channel_type = detect_channel_type(channel_id)
|
|
119
|
+
endpoint = case channel_type
|
|
120
|
+
when 'channel' then 'channels.mark'
|
|
121
|
+
when 'group' then 'groups.mark'
|
|
122
|
+
when 'im' then 'im.mark'
|
|
123
|
+
when 'mpim' then 'mpim.mark'
|
|
124
|
+
else 'conversations.mark'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
uri = URI("#{@base_url}/#{endpoint}")
|
|
128
|
+
params = {
|
|
129
|
+
channel: channel_id,
|
|
130
|
+
ts: message_id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
response = make_api_request(uri, params)
|
|
134
|
+
|
|
135
|
+
if response && response['ok']
|
|
136
|
+
{ success: true }
|
|
137
|
+
else
|
|
138
|
+
error = response ? response['error'] : 'Unknown error'
|
|
139
|
+
{ success: false, error: error }
|
|
140
|
+
end
|
|
141
|
+
rescue => e
|
|
142
|
+
{ success: false, error: e.message }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def make_api_request(uri, params = {}, method = :post)
|
|
148
|
+
api_token = @source.config['api_token'] || @source.config['token']
|
|
149
|
+
|
|
150
|
+
if method == :get
|
|
151
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
152
|
+
request = Net::HTTP::Get.new(uri)
|
|
153
|
+
else
|
|
154
|
+
request = Net::HTTP::Post.new(uri)
|
|
155
|
+
request['Content-Type'] = 'application/json'
|
|
156
|
+
request.body = params.to_json unless params.empty?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
request['Authorization'] = "Bearer #{api_token}"
|
|
160
|
+
|
|
161
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
162
|
+
http.use_ssl = true
|
|
163
|
+
http.read_timeout = 10 # 10 second timeout
|
|
164
|
+
http.open_timeout = 5 # 5 second connection timeout
|
|
165
|
+
|
|
166
|
+
response = http.request(request)
|
|
167
|
+
JSON.parse(response.body) rescue nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def fetch_channel_messages(channel_id)
|
|
171
|
+
channel_name = get_channel_name(channel_id)
|
|
172
|
+
|
|
173
|
+
uri = URI("#{@base_url}/conversations.history")
|
|
174
|
+
params = {
|
|
175
|
+
channel: channel_id,
|
|
176
|
+
limit: 50
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Get messages since last fetch if available
|
|
180
|
+
if @last_fetch[channel_id]
|
|
181
|
+
params[:oldest] = @last_fetch[channel_id].to_f.to_s
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
response = make_api_request(uri, params, :get)
|
|
185
|
+
|
|
186
|
+
messages = []
|
|
187
|
+
if response && response['ok']
|
|
188
|
+
response['messages'].each do |msg|
|
|
189
|
+
next if msg['type'] != 'message' || msg['subtype'] == 'bot_message'
|
|
190
|
+
|
|
191
|
+
user_name = get_user_name(msg['user'])
|
|
192
|
+
|
|
193
|
+
messages << {
|
|
194
|
+
'source_id' => @source.id,
|
|
195
|
+
'source_type' => 'slack',
|
|
196
|
+
'external_id' => "slack_#{@source.id}_#{channel_id}_#{msg['ts']}",
|
|
197
|
+
'sender' => user_name,
|
|
198
|
+
'recipient' => "##{channel_name}",
|
|
199
|
+
'subject' => nil,
|
|
200
|
+
'content' => msg['text'],
|
|
201
|
+
'timestamp' => Time.at(msg['ts'].to_f).iso8601,
|
|
202
|
+
'is_read' => 0,
|
|
203
|
+
'metadata' => {
|
|
204
|
+
'channel_id' => channel_id,
|
|
205
|
+
'channel_name' => channel_name,
|
|
206
|
+
'thread_ts' => msg['thread_ts'],
|
|
207
|
+
'ts' => msg['ts'],
|
|
208
|
+
'workspace' => @source.config['workspace']
|
|
209
|
+
}.to_json,
|
|
210
|
+
'raw_data' => msg.to_json,
|
|
211
|
+
'attachments' => nil
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
@last_fetch[channel_id] = Time.now
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
messages
|
|
219
|
+
rescue => e
|
|
220
|
+
puts "Error fetching channel messages: #{e.message}"
|
|
221
|
+
[]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def fetch_direct_messages(user_id)
|
|
225
|
+
# First, open the DM channel
|
|
226
|
+
uri = URI("#{@base_url}/conversations.open")
|
|
227
|
+
params = { users: user_id }
|
|
228
|
+
response = make_api_request(uri, params)
|
|
229
|
+
|
|
230
|
+
return [] unless response && response['ok']
|
|
231
|
+
|
|
232
|
+
channel_id = response['channel']['id']
|
|
233
|
+
user_name = get_user_name(user_id)
|
|
234
|
+
|
|
235
|
+
# Now fetch the messages
|
|
236
|
+
uri = URI("#{@base_url}/conversations.history")
|
|
237
|
+
params = {
|
|
238
|
+
channel: channel_id,
|
|
239
|
+
limit: 100
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if @last_fetch[channel_id]
|
|
243
|
+
params[:oldest] = @last_fetch[channel_id].to_f.to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
response = make_api_request(uri, params)
|
|
247
|
+
|
|
248
|
+
messages = []
|
|
249
|
+
if response && response['ok']
|
|
250
|
+
response['messages'].each do |msg|
|
|
251
|
+
next if msg['type'] != 'message'
|
|
252
|
+
|
|
253
|
+
sender_name = get_user_name(msg['user'])
|
|
254
|
+
|
|
255
|
+
messages << {
|
|
256
|
+
'source_id' => @source.id,
|
|
257
|
+
'source_type' => 'slack',
|
|
258
|
+
'external_id' => "slack_dm_#{@source.id}_#{channel_id}_#{msg['ts']}",
|
|
259
|
+
'sender' => sender_name,
|
|
260
|
+
'recipient' => "DM with #{user_name}",
|
|
261
|
+
'subject' => nil,
|
|
262
|
+
'content' => msg['text'],
|
|
263
|
+
'timestamp' => Time.at(msg['ts'].to_f).iso8601,
|
|
264
|
+
'is_read' => 0,
|
|
265
|
+
'metadata' => {
|
|
266
|
+
'channel_id' => channel_id,
|
|
267
|
+
'user_id' => user_id,
|
|
268
|
+
'ts' => msg['ts'],
|
|
269
|
+
'is_dm' => true,
|
|
270
|
+
'workspace' => @source.config['workspace']
|
|
271
|
+
}.to_json,
|
|
272
|
+
'raw_data' => msg.to_json,
|
|
273
|
+
'attachments' => nil
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
@last_fetch[channel_id] = Time.now
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
messages
|
|
281
|
+
rescue => e
|
|
282
|
+
puts "Error fetching DMs: #{e.message}"
|
|
283
|
+
[]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def fetch_all_conversations
|
|
287
|
+
uri = URI("#{@base_url}/conversations.list")
|
|
288
|
+
params = {
|
|
289
|
+
types: 'public_channel,private_channel,mpim,im',
|
|
290
|
+
limit: 100
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
response = make_api_request(uri, params, :get)
|
|
294
|
+
|
|
295
|
+
if response && response['ok']
|
|
296
|
+
response['channels'] || []
|
|
297
|
+
else
|
|
298
|
+
[]
|
|
299
|
+
end
|
|
300
|
+
rescue => e
|
|
301
|
+
puts "Error fetching conversations: #{e.message}"
|
|
302
|
+
[]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def get_user_name(user_id)
|
|
306
|
+
return @users_cache[user_id] if @users_cache[user_id]
|
|
307
|
+
|
|
308
|
+
uri = URI("#{@base_url}/users.info")
|
|
309
|
+
params = { user: user_id }
|
|
310
|
+
response = make_api_request(uri, params, :get)
|
|
311
|
+
|
|
312
|
+
if response && response['ok']
|
|
313
|
+
name = response['user']['profile']['display_name'] ||
|
|
314
|
+
response['user']['profile']['real_name'] ||
|
|
315
|
+
response['user']['name']
|
|
316
|
+
@users_cache[user_id] = name
|
|
317
|
+
name
|
|
318
|
+
else
|
|
319
|
+
user_id
|
|
320
|
+
end
|
|
321
|
+
rescue => e
|
|
322
|
+
user_id
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def get_channel_name(channel_id)
|
|
326
|
+
return @channels_cache[channel_id] if @channels_cache[channel_id]
|
|
327
|
+
|
|
328
|
+
uri = URI("#{@base_url}/conversations.info")
|
|
329
|
+
params = { channel: channel_id }
|
|
330
|
+
response = make_api_request(uri, params, :get)
|
|
331
|
+
|
|
332
|
+
if response && response['ok']
|
|
333
|
+
name = response['channel']['name']
|
|
334
|
+
@channels_cache[channel_id] = name
|
|
335
|
+
name
|
|
336
|
+
else
|
|
337
|
+
channel_id
|
|
338
|
+
end
|
|
339
|
+
rescue => e
|
|
340
|
+
channel_id
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def detect_channel_type(channel_id)
|
|
344
|
+
case channel_id[0]
|
|
345
|
+
when 'C' then 'channel'
|
|
346
|
+
when 'G' then 'group'
|
|
347
|
+
when 'D' then 'im'
|
|
348
|
+
when 'M' then 'mpim'
|
|
349
|
+
else 'channel'
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def resolve_channel(recipient)
|
|
354
|
+
# If it's already a channel ID, use it
|
|
355
|
+
return recipient if recipient =~ /^[CGDM]/
|
|
356
|
+
|
|
357
|
+
# If it starts with #, look up the channel
|
|
358
|
+
if recipient.start_with?('#')
|
|
359
|
+
channel_name = recipient[1..-1]
|
|
360
|
+
# Would need to fetch channel list and find matching name
|
|
361
|
+
# For now, assume it's provided as ID
|
|
362
|
+
recipient
|
|
363
|
+
else
|
|
364
|
+
# Assume it's a user name for DM
|
|
365
|
+
# Would need to look up user ID
|
|
366
|
+
recipient
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def generate_message_id
|
|
371
|
+
"slack_#{@source.id}_#{Time.now.to_i}_#{rand(1000)}"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|