slk 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +39 -1
- data/README.md +30 -12
- data/bin/ci +15 -0
- data/bin/coverage +225 -0
- data/bin/test +7 -0
- data/lib/slk/api/search.rb +31 -0
- data/lib/slk/cli.rb +50 -3
- data/lib/slk/commands/base.rb +1 -0
- data/lib/slk/commands/catchup.rb +3 -2
- data/lib/slk/commands/config.rb +48 -45
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/messages.rb +59 -11
- data/lib/slk/commands/search.rb +223 -0
- data/lib/slk/commands/ssh_key_manager.rb +129 -0
- data/lib/slk/formatters/attachment_formatter.rb +16 -2
- data/lib/slk/formatters/mention_replacer.rb +13 -31
- data/lib/slk/formatters/message_formatter.rb +8 -15
- data/lib/slk/formatters/search_formatter.rb +75 -0
- data/lib/slk/models/search_result.rb +115 -0
- data/lib/slk/runner.rb +12 -0
- data/lib/slk/services/api_client.rb +60 -11
- data/lib/slk/services/cache_store.rb +55 -36
- data/lib/slk/services/encryption.rb +114 -11
- data/lib/slk/services/setup_wizard.rb +3 -3
- data/lib/slk/services/target_resolver.rb +27 -4
- data/lib/slk/services/token_loader.rb +83 -0
- data/lib/slk/services/token_saver.rb +87 -0
- data/lib/slk/services/token_store.rb +35 -65
- data/lib/slk/services/user_lookup.rb +117 -0
- data/lib/slk/support/date_parser.rb +64 -0
- data/lib/slk/support/platform.rb +34 -0
- data/lib/slk/support/xdg_paths.rb +27 -9
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +8 -0
- metadata +14 -1
|
@@ -30,6 +30,11 @@ module Slk
|
|
|
30
30
|
replace_special_mentions(result)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Public API for looking up user names (used by MessageFormatter)
|
|
34
|
+
def lookup_user_name(workspace, user_id)
|
|
35
|
+
user_lookup_for(workspace).resolve_name(user_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
private
|
|
34
39
|
|
|
35
40
|
def replace_user_mentions(text, workspace)
|
|
@@ -76,42 +81,19 @@ module Slk
|
|
|
76
81
|
|
|
77
82
|
def lookup_by_type(workspace, id, type)
|
|
78
83
|
case type
|
|
79
|
-
when :user then
|
|
84
|
+
when :user then user_lookup_for(workspace).resolve_name(id)
|
|
80
85
|
when :channel then lookup_channel_name(workspace, id)
|
|
81
86
|
when :subteam then lookup_subteam_handle(workspace, id)
|
|
82
87
|
end
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def fetch_user_name_from_api(workspace, user_id)
|
|
93
|
-
return nil unless @api
|
|
94
|
-
|
|
95
|
-
response = Api::Users.new(@api, workspace).info(user_id)
|
|
96
|
-
return nil unless response['ok'] && response['user']
|
|
97
|
-
|
|
98
|
-
name = extract_user_display_name(response['user'])
|
|
99
|
-
cache_user_name(workspace, user_id, name)
|
|
100
|
-
name
|
|
101
|
-
rescue ApiError => e
|
|
102
|
-
@on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
|
|
103
|
-
nil
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def cache_user_name(workspace, user_id, name)
|
|
107
|
-
@cache.set_user(workspace.name, user_id, name, persist: true) unless name.to_s.empty?
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def extract_user_display_name(user)
|
|
111
|
-
profile = user['profile'] || {}
|
|
112
|
-
profile['display_name'].then { |n| n.to_s.empty? ? nil : n } ||
|
|
113
|
-
profile['real_name'].then { |n| n.to_s.empty? ? nil : n } ||
|
|
114
|
-
user['name'].then { |n| n.to_s.empty? ? nil : n }
|
|
90
|
+
def user_lookup_for(workspace)
|
|
91
|
+
Services::UserLookup.new(
|
|
92
|
+
cache_store: @cache,
|
|
93
|
+
workspace: workspace,
|
|
94
|
+
api_client: @api,
|
|
95
|
+
on_debug: @on_debug
|
|
96
|
+
)
|
|
115
97
|
end
|
|
116
98
|
|
|
117
99
|
def lookup_channel_name(workspace, channel_id)
|
|
@@ -105,23 +105,16 @@ module Slk
|
|
|
105
105
|
return message.user_id if options[:no_names]
|
|
106
106
|
return message.embedded_username if message.embedded_username
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
return cached if cached
|
|
110
|
-
|
|
111
|
-
lookup_bot_if_needed(message, workspace) || message.user_id
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def lookup_bot_if_needed(message, workspace)
|
|
115
|
-
return unless message.user_id.start_with?('B') && @api_client
|
|
116
|
-
|
|
117
|
-
lookup_bot_name(workspace, message.user_id)
|
|
108
|
+
user_lookup_for(workspace).resolve_name_or_bot(message.user_id) || message.user_id
|
|
118
109
|
end
|
|
119
110
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
111
|
+
def user_lookup_for(workspace)
|
|
112
|
+
Services::UserLookup.new(
|
|
113
|
+
cache_store: @cache,
|
|
114
|
+
workspace: workspace,
|
|
115
|
+
api_client: @api_client,
|
|
116
|
+
on_debug: @on_debug
|
|
117
|
+
)
|
|
125
118
|
end
|
|
126
119
|
|
|
127
120
|
def format_timestamp(time)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats search results for terminal display
|
|
6
|
+
class SearchFormatter
|
|
7
|
+
def initialize(output:, emoji_replacer:, mention_replacer:)
|
|
8
|
+
@output = output
|
|
9
|
+
@emoji = emoji_replacer
|
|
10
|
+
@mentions = mention_replacer
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Display a list of search results
|
|
14
|
+
def display_all(results, workspace, options: {})
|
|
15
|
+
return @output.puts 'No results found.' if results.empty?
|
|
16
|
+
|
|
17
|
+
results.each_with_index do |result, index|
|
|
18
|
+
display_result(result, workspace, options)
|
|
19
|
+
@output.puts if index < results.length - 1
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Display a single search result
|
|
24
|
+
def display_result(result, workspace, options = {})
|
|
25
|
+
timestamp = @output.blue("[#{format_time(result.timestamp)}]")
|
|
26
|
+
channel = @output.cyan(resolve_channel(result, workspace))
|
|
27
|
+
user = @output.bold("#{resolve_user(result, workspace)}:")
|
|
28
|
+
text = prepare_text(result.text, workspace, options)
|
|
29
|
+
|
|
30
|
+
@output.puts "#{timestamp} #{channel} #{user} #{text}"
|
|
31
|
+
display_files(result.files) if result.files&.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def resolve_channel(result, workspace)
|
|
37
|
+
if result.dm?
|
|
38
|
+
# For DMs, channel_name is a user ID - resolve it
|
|
39
|
+
@mentions.replace("<@#{result.channel_name}>", workspace)
|
|
40
|
+
else
|
|
41
|
+
"##{result.channel_name}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve_user(result, workspace)
|
|
46
|
+
# If we have a username, use it directly
|
|
47
|
+
return result.username if result.username && !result.username.empty?
|
|
48
|
+
|
|
49
|
+
# Fallback if user_id is also missing
|
|
50
|
+
return 'Unknown User' unless result.user_id && !result.user_id.to_s.empty?
|
|
51
|
+
|
|
52
|
+
# Otherwise resolve the user_id via MentionReplacer
|
|
53
|
+
@mentions.replace("<@#{result.user_id}>", workspace)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def prepare_text(text, workspace, options)
|
|
57
|
+
return text if options[:no_emoji] && options[:no_mentions]
|
|
58
|
+
|
|
59
|
+
text = @emoji.replace(text) unless options[:no_emoji]
|
|
60
|
+
text = @mentions.replace(text, workspace) unless options[:no_mentions]
|
|
61
|
+
text
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def display_files(files)
|
|
65
|
+
files.each do |file|
|
|
66
|
+
@output.puts @output.blue("[Image: #{file[:name]}]")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_time(time)
|
|
71
|
+
time.strftime('%Y-%m-%d %H:%M')
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Value object for search.messages API results
|
|
6
|
+
# Search results include channel info inline, unlike regular messages
|
|
7
|
+
SearchResult = Data.define(
|
|
8
|
+
:ts,
|
|
9
|
+
:user_id,
|
|
10
|
+
:username,
|
|
11
|
+
:text,
|
|
12
|
+
:channel_id,
|
|
13
|
+
:channel_name,
|
|
14
|
+
:channel_type,
|
|
15
|
+
:thread_ts,
|
|
16
|
+
:permalink,
|
|
17
|
+
:files
|
|
18
|
+
) do
|
|
19
|
+
def self.from_api(match)
|
|
20
|
+
channel = match['channel'] || {}
|
|
21
|
+
new(**build_attributes(match, channel))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# rubocop:disable Metrics/MethodLength
|
|
25
|
+
def self.build_attributes(match, channel)
|
|
26
|
+
{
|
|
27
|
+
ts: match['ts'],
|
|
28
|
+
user_id: match['user'] || match['username'],
|
|
29
|
+
username: match['username'],
|
|
30
|
+
text: match['text'] || '',
|
|
31
|
+
channel_id: channel['id'],
|
|
32
|
+
channel_name: channel['name'],
|
|
33
|
+
channel_type: determine_channel_type(channel),
|
|
34
|
+
thread_ts: extract_thread_ts(match),
|
|
35
|
+
permalink: match['permalink'],
|
|
36
|
+
files: extract_files(match)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.extract_files(match)
|
|
41
|
+
files = extract_uploaded_files(match)
|
|
42
|
+
files += extract_attachments(match)
|
|
43
|
+
files
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.extract_uploaded_files(match)
|
|
47
|
+
return [] unless match['files']
|
|
48
|
+
|
|
49
|
+
match['files'].map { |f| { name: f['name'], type: f['filetype'] } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.extract_attachments(match)
|
|
53
|
+
return [] unless match['attachments']
|
|
54
|
+
|
|
55
|
+
match['attachments'].flat_map { |a| extract_attachment_images(a) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.extract_attachment_images(attachment)
|
|
59
|
+
unless attachment['blocks']
|
|
60
|
+
fallback = attachment['fallback']
|
|
61
|
+
return [] unless fallback
|
|
62
|
+
|
|
63
|
+
return [{ name: fallback, type: 'attachment' }]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attachment['blocks'].filter_map do |block|
|
|
67
|
+
next unless block['type'] == 'image'
|
|
68
|
+
|
|
69
|
+
{ name: block.dig('title', 'text') || 'Image', type: 'attachment' }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
# rubocop:enable Metrics/MethodLength
|
|
73
|
+
|
|
74
|
+
def self.determine_channel_type(channel)
|
|
75
|
+
return 'im' if channel['is_im']
|
|
76
|
+
return 'mpim' if channel['is_mpim']
|
|
77
|
+
|
|
78
|
+
'channel'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.extract_thread_ts(match)
|
|
82
|
+
return nil unless match['permalink']
|
|
83
|
+
|
|
84
|
+
# Extract thread_ts from permalink URL if present
|
|
85
|
+
uri = URI.parse(match['permalink'])
|
|
86
|
+
params = URI.decode_www_form(uri.query || '')
|
|
87
|
+
params.find { |k, _| k == 'thread_ts' }&.last
|
|
88
|
+
rescue URI::InvalidURIError => e
|
|
89
|
+
# Log for debugging - malformed permalinks from API should be rare
|
|
90
|
+
warn "Invalid permalink URI: #{match['permalink']}: #{e.message}" if ENV['DEBUG']
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def timestamp
|
|
95
|
+
Time.at(ts.to_f)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def thread?
|
|
99
|
+
!thread_ts.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def dm?
|
|
103
|
+
%w[im mpim].include?(channel_type)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def display_channel
|
|
107
|
+
if dm?
|
|
108
|
+
"@#{channel_name}"
|
|
109
|
+
else
|
|
110
|
+
"##{channel_name}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/slk/runner.rb
CHANGED
|
@@ -79,6 +79,10 @@ module Slk
|
|
|
79
79
|
Api::Activity.new(@api_client, workspace(workspace_name))
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def search_api(workspace_name = nil)
|
|
83
|
+
Api::Search.new(@api_client, workspace(workspace_name))
|
|
84
|
+
end
|
|
85
|
+
|
|
82
86
|
# Formatter helpers
|
|
83
87
|
def message_formatter
|
|
84
88
|
@message_formatter ||= Formatters::MessageFormatter.new(
|
|
@@ -107,6 +111,14 @@ module Slk
|
|
|
107
111
|
@duration_formatter ||= Formatters::DurationFormatter.new
|
|
108
112
|
end
|
|
109
113
|
|
|
114
|
+
def search_formatter
|
|
115
|
+
@search_formatter ||= Formatters::SearchFormatter.new(
|
|
116
|
+
output: @output,
|
|
117
|
+
emoji_replacer: emoji_replacer,
|
|
118
|
+
mention_replacer: mention_replacer
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
110
122
|
# Logging
|
|
111
123
|
def log_error(error)
|
|
112
124
|
Support::ErrorLogger.log(error)
|
|
@@ -20,11 +20,14 @@ module Slk
|
|
|
20
20
|
].freeze
|
|
21
21
|
|
|
22
22
|
attr_reader :call_count
|
|
23
|
-
attr_accessor :on_request
|
|
23
|
+
attr_accessor :on_request, :on_response, :on_request_body, :on_response_body
|
|
24
24
|
|
|
25
25
|
def initialize
|
|
26
26
|
@call_count = 0
|
|
27
27
|
@on_request = nil
|
|
28
|
+
@on_response = nil
|
|
29
|
+
@on_request_body = nil
|
|
30
|
+
@on_response_body = nil
|
|
28
31
|
@http_cache = {}
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -35,10 +38,11 @@ module Slk
|
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def post(workspace, method, params = {})
|
|
38
|
-
|
|
41
|
+
body = params.empty? ? nil : JSON.generate(params)
|
|
42
|
+
execute_request(method, body: body) do |uri, http|
|
|
39
43
|
request = Net::HTTP::Post.new(uri)
|
|
40
44
|
workspace.headers.each { |k, v| request[k] = v }
|
|
41
|
-
request.body =
|
|
45
|
+
request.body = body
|
|
42
46
|
http.request(request)
|
|
43
47
|
end
|
|
44
48
|
end
|
|
@@ -53,10 +57,11 @@ module Slk
|
|
|
53
57
|
|
|
54
58
|
# Form-encoded POST (some Slack endpoints require this)
|
|
55
59
|
def post_form(workspace, method, params = {})
|
|
56
|
-
|
|
60
|
+
body = params.empty? ? nil : URI.encode_www_form(params)
|
|
61
|
+
execute_request(method, body: body) do |uri, http|
|
|
57
62
|
request = Net::HTTP::Post.new(uri)
|
|
58
63
|
apply_auth_headers(request, workspace)
|
|
59
|
-
request.set_form_data(params)
|
|
64
|
+
request.set_form_data(params) unless params.empty?
|
|
60
65
|
http.request(request)
|
|
61
66
|
end
|
|
62
67
|
end
|
|
@@ -66,19 +71,33 @@ module Slk
|
|
|
66
71
|
def safe_close(http)
|
|
67
72
|
http.finish if http.started?
|
|
68
73
|
rescue IOError
|
|
69
|
-
# Connection already closed
|
|
74
|
+
# Connection already closed - this is expected, not an error
|
|
70
75
|
end
|
|
71
76
|
|
|
72
|
-
def execute_request(method, query_params = nil)
|
|
77
|
+
def execute_request(method, query_params = nil, body: nil, &)
|
|
73
78
|
log_request(method)
|
|
79
|
+
log_request_body(method, body)
|
|
80
|
+
uri = build_uri(method, query_params)
|
|
81
|
+
response, elapsed_ms = timed_request(uri, &)
|
|
82
|
+
log_response(method, response, elapsed_ms)
|
|
83
|
+
log_response_body(method, response.body)
|
|
84
|
+
handle_response(response, method)
|
|
85
|
+
rescue *NETWORK_ERRORS => e
|
|
86
|
+
raise ApiError, "Network error: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_uri(method, query_params)
|
|
74
90
|
uri = URI("#{BASE_URL}/#{method}")
|
|
75
91
|
uri.query = URI.encode_www_form(query_params) if query_params&.any?
|
|
92
|
+
uri
|
|
93
|
+
end
|
|
76
94
|
|
|
95
|
+
def timed_request(uri)
|
|
96
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
97
|
http = get_http(uri)
|
|
78
98
|
response = yield(uri, http)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
raise ApiError, "Network error: #{e.message}"
|
|
99
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
100
|
+
[response, elapsed_ms]
|
|
82
101
|
end
|
|
83
102
|
|
|
84
103
|
def apply_auth_headers(request, workspace)
|
|
@@ -91,6 +110,29 @@ module Slk
|
|
|
91
110
|
@on_request&.call(method, @call_count)
|
|
92
111
|
end
|
|
93
112
|
|
|
113
|
+
def log_request_body(method, body)
|
|
114
|
+
@on_request_body&.call(method, body) if body
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def log_response(method, response, elapsed_ms)
|
|
118
|
+
return unless @on_response
|
|
119
|
+
|
|
120
|
+
headers = {
|
|
121
|
+
'elapsed_ms' => elapsed_ms,
|
|
122
|
+
'X-Slack-Req-Id' => response['X-Slack-Req-Id'],
|
|
123
|
+
'X-RateLimit-Limit' => response['X-RateLimit-Limit'],
|
|
124
|
+
'X-RateLimit-Remaining' => response['X-RateLimit-Remaining'],
|
|
125
|
+
'X-RateLimit-Reset' => response['X-RateLimit-Reset'],
|
|
126
|
+
'Retry-After' => response['Retry-After']
|
|
127
|
+
}.compact
|
|
128
|
+
|
|
129
|
+
@on_response.call(method, response.code, headers)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def log_response_body(method, body)
|
|
133
|
+
@on_response_body&.call(method, body) if body
|
|
134
|
+
end
|
|
135
|
+
|
|
94
136
|
# Get or create a persistent HTTP connection for the given URI
|
|
95
137
|
def get_http(uri)
|
|
96
138
|
key = "#{uri.host}:#{uri.port}"
|
|
@@ -126,11 +168,18 @@ module Slk
|
|
|
126
168
|
case response
|
|
127
169
|
when Net::HTTPSuccess then parse_success_response(response)
|
|
128
170
|
when Net::HTTPUnauthorized then raise ApiError, 'Invalid token or session expired'
|
|
129
|
-
when Net::HTTPTooManyRequests then
|
|
171
|
+
when Net::HTTPTooManyRequests then handle_rate_limit(response)
|
|
130
172
|
else raise ApiError, "HTTP #{response.code}: #{response.message}"
|
|
131
173
|
end
|
|
132
174
|
end
|
|
133
175
|
|
|
176
|
+
def handle_rate_limit(response)
|
|
177
|
+
retry_after = response['Retry-After']
|
|
178
|
+
raise ApiError, "Rate limited - retry after #{retry_after} seconds" if retry_after
|
|
179
|
+
|
|
180
|
+
raise ApiError, 'Rate limited - please wait and try again'
|
|
181
|
+
end
|
|
182
|
+
|
|
134
183
|
def parse_success_response(response)
|
|
135
184
|
result = JSON.parse(response.body)
|
|
136
185
|
raise ApiError, result['error'] || 'Unknown error' unless result['ok']
|
|
@@ -5,7 +5,7 @@ module Slk
|
|
|
5
5
|
# Persistent cache for user names, channel names, and subteams
|
|
6
6
|
# rubocop:disable Metrics/ClassLength
|
|
7
7
|
class CacheStore
|
|
8
|
-
attr_accessor :on_warning
|
|
8
|
+
attr_accessor :on_warning, :on_cache_access
|
|
9
9
|
|
|
10
10
|
def initialize(paths: nil)
|
|
11
11
|
@paths = paths || Support::XdgPaths.new
|
|
@@ -13,12 +13,24 @@ module Slk
|
|
|
13
13
|
@channel_cache = {}
|
|
14
14
|
@subteam_cache = {}
|
|
15
15
|
@on_warning = nil
|
|
16
|
+
@on_cache_access = nil
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# User cache methods
|
|
19
20
|
def get_user(workspace_name, user_id)
|
|
20
21
|
load_user_cache(workspace_name)
|
|
21
|
-
@user_cache.dig(workspace_name, user_id)
|
|
22
|
+
result = @user_cache.dig(workspace_name, user_id)
|
|
23
|
+
log_cache_access('user', workspace_name, user_id, result)
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_user_id_by_name(workspace_name, name)
|
|
28
|
+
load_user_cache(workspace_name)
|
|
29
|
+
cache = @user_cache[workspace_name] || {}
|
|
30
|
+
# Reverse lookup: find user_id where display_name matches
|
|
31
|
+
result = cache.find { |_id, display_name| display_name == name }&.first
|
|
32
|
+
log_cache_access('user_by_name', workspace_name, name, result)
|
|
33
|
+
result
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
def set_user(workspace_name, user_id, display_name, persist: false)
|
|
@@ -66,13 +78,17 @@ module Slk
|
|
|
66
78
|
# Channel cache methods
|
|
67
79
|
def get_channel_id(workspace_name, channel_name)
|
|
68
80
|
load_channel_cache(workspace_name)
|
|
69
|
-
@channel_cache.dig(workspace_name, channel_name)
|
|
81
|
+
result = @channel_cache.dig(workspace_name, channel_name)
|
|
82
|
+
log_cache_access('channel_id', workspace_name, channel_name, result)
|
|
83
|
+
result
|
|
70
84
|
end
|
|
71
85
|
|
|
72
86
|
def get_channel_name(workspace_name, channel_id)
|
|
73
87
|
load_channel_cache(workspace_name)
|
|
74
88
|
cache = @channel_cache[workspace_name] || {}
|
|
75
|
-
cache.key(channel_id)
|
|
89
|
+
result = cache.key(channel_id)
|
|
90
|
+
log_cache_access('channel_name', workspace_name, channel_id, result)
|
|
91
|
+
result
|
|
76
92
|
end
|
|
77
93
|
|
|
78
94
|
def set_channel(workspace_name, channel_name, channel_id)
|
|
@@ -95,7 +111,9 @@ module Slk
|
|
|
95
111
|
# Subteam cache methods
|
|
96
112
|
def get_subteam(workspace_name, subteam_id)
|
|
97
113
|
load_subteam_cache(workspace_name)
|
|
98
|
-
@subteam_cache.dig(workspace_name, subteam_id)
|
|
114
|
+
result = @subteam_cache.dig(workspace_name, subteam_id)
|
|
115
|
+
log_cache_access('subteam', workspace_name, subteam_id, result)
|
|
116
|
+
result
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
def set_subteam(workspace_name, subteam_id, handle)
|
|
@@ -129,29 +147,37 @@ module Slk
|
|
|
129
147
|
def load_user_cache(workspace_name)
|
|
130
148
|
return if @user_cache.key?(workspace_name)
|
|
131
149
|
|
|
132
|
-
|
|
133
|
-
@user_cache[workspace_name] = if File.exist?(file)
|
|
134
|
-
JSON.parse(File.read(file))
|
|
135
|
-
else
|
|
136
|
-
{}
|
|
137
|
-
end
|
|
138
|
-
rescue JSON::ParserError => e
|
|
139
|
-
@on_warning&.call("User cache corrupted for #{workspace_name}: #{e.message}")
|
|
140
|
-
@user_cache[workspace_name] = {}
|
|
150
|
+
@user_cache[workspace_name] = load_cache_file(user_cache_file(workspace_name), 'User', workspace_name)
|
|
141
151
|
end
|
|
142
152
|
|
|
143
153
|
def load_channel_cache(workspace_name)
|
|
144
154
|
return if @channel_cache.key?(workspace_name)
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
@channel_cache[workspace_name] = load_cache_file(channel_cache_file(workspace_name), 'Channel', workspace_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def load_subteam_cache(workspace_name)
|
|
160
|
+
return if @subteam_cache.key?(workspace_name)
|
|
161
|
+
|
|
162
|
+
@subteam_cache[workspace_name] = load_cache_file(subteam_cache_file(workspace_name), 'Subteam', workspace_name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Load and parse a JSON cache file, returning empty hash if missing.
|
|
166
|
+
# Corrupted files are deleted and rebuilt on next access.
|
|
167
|
+
def load_cache_file(file, cache_type, workspace_name)
|
|
168
|
+
return {} unless File.exist?(file)
|
|
169
|
+
|
|
170
|
+
JSON.parse(File.read(file))
|
|
152
171
|
rescue JSON::ParserError => e
|
|
153
|
-
@on_warning&.call("
|
|
154
|
-
|
|
172
|
+
@on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
|
|
173
|
+
safely_delete_file(file)
|
|
174
|
+
{}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def safely_delete_file(file)
|
|
178
|
+
FileUtils.rm(file)
|
|
179
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
|
|
180
|
+
@on_warning&.call("Could not remove corrupted cache file: #{e.message}")
|
|
155
181
|
end
|
|
156
182
|
|
|
157
183
|
def save_channel_cache(workspace_name)
|
|
@@ -170,20 +196,6 @@ module Slk
|
|
|
170
196
|
@paths.cache_file("channels-#{workspace_name}.json")
|
|
171
197
|
end
|
|
172
198
|
|
|
173
|
-
def load_subteam_cache(workspace_name)
|
|
174
|
-
return if @subteam_cache.key?(workspace_name)
|
|
175
|
-
|
|
176
|
-
file = subteam_cache_file(workspace_name)
|
|
177
|
-
@subteam_cache[workspace_name] = if File.exist?(file)
|
|
178
|
-
JSON.parse(File.read(file))
|
|
179
|
-
else
|
|
180
|
-
{}
|
|
181
|
-
end
|
|
182
|
-
rescue JSON::ParserError => e
|
|
183
|
-
@on_warning&.call("Subteam cache corrupted for #{workspace_name}: #{e.message}")
|
|
184
|
-
@subteam_cache[workspace_name] = {}
|
|
185
|
-
end
|
|
186
|
-
|
|
187
199
|
def save_subteam_cache(workspace_name)
|
|
188
200
|
return if @subteam_cache[workspace_name].nil? || @subteam_cache[workspace_name].empty?
|
|
189
201
|
|
|
@@ -195,6 +207,13 @@ module Slk
|
|
|
195
207
|
def subteam_cache_file(workspace_name)
|
|
196
208
|
@paths.cache_file("subteams-#{workspace_name}.json")
|
|
197
209
|
end
|
|
210
|
+
|
|
211
|
+
def log_cache_access(type, workspace, key, result)
|
|
212
|
+
return unless @on_cache_access
|
|
213
|
+
|
|
214
|
+
hit = !result.nil?
|
|
215
|
+
@on_cache_access.call(type, workspace, key, hit, result)
|
|
216
|
+
end
|
|
198
217
|
end
|
|
199
218
|
# rubocop:enable Metrics/ClassLength
|
|
200
219
|
end
|