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,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(/&nbsp;/, ' ')
289
+ .gsub(/&amp;/, '&')
290
+ .gsub(/&lt;/, '<')
291
+ .gsub(/&gt;/, '>')
292
+ .gsub(/&quot;/, '"')
293
+ .gsub(/&#39;/, "'")
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