slk 0.1.0 → 0.2.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 +22 -1
- data/README.md +5 -5
- data/bin/slk +3 -3
- data/lib/{slack_cli → slk}/api/activity.rb +10 -11
- data/lib/{slack_cli → slk}/api/bots.rb +5 -4
- data/lib/slk/api/client.rb +51 -0
- data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
- data/lib/slk/api/dnd.rb +41 -0
- data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
- data/lib/{slack_cli → slk}/api/threads.rb +13 -12
- data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
- data/lib/slk/api/users.rb +105 -0
- data/lib/slk/cli.rb +157 -0
- data/lib/slk/commands/activity.rb +152 -0
- data/lib/{slack_cli → slk}/commands/base.rb +67 -41
- data/lib/slk/commands/cache.rb +141 -0
- data/lib/slk/commands/catchup.rb +411 -0
- data/lib/slk/commands/config.rb +114 -0
- data/lib/slk/commands/dnd.rb +172 -0
- data/lib/slk/commands/emoji.rb +352 -0
- data/lib/slk/commands/help.rb +97 -0
- data/lib/slk/commands/messages.rb +299 -0
- data/lib/slk/commands/presence.rb +109 -0
- data/lib/slk/commands/preset.rb +231 -0
- data/lib/slk/commands/status.rb +223 -0
- data/lib/slk/commands/thread.rb +72 -0
- data/lib/slk/commands/unread.rb +305 -0
- data/lib/slk/commands/workspaces.rb +168 -0
- data/lib/slk/formatters/activity_formatter.rb +148 -0
- data/lib/slk/formatters/attachment_formatter.rb +65 -0
- data/lib/slk/formatters/block_formatter.rb +57 -0
- data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
- data/lib/slk/formatters/emoji_replacer.rb +141 -0
- data/lib/slk/formatters/json_message_formatter.rb +95 -0
- data/lib/slk/formatters/mention_replacer.rb +158 -0
- data/lib/slk/formatters/message_formatter.rb +174 -0
- data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
- data/lib/slk/formatters/reaction_formatter.rb +87 -0
- data/lib/{slack_cli → slk}/models/channel.rb +12 -10
- data/lib/slk/models/duration.rb +94 -0
- data/lib/slk/models/message.rb +242 -0
- data/lib/slk/models/preset.rb +78 -0
- data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
- data/lib/{slack_cli → slk}/models/status.rb +6 -6
- data/lib/slk/models/user.rb +55 -0
- data/lib/slk/models/workspace.rb +54 -0
- data/lib/{slack_cli → slk}/runner.rb +22 -19
- data/lib/slk/services/activity_enricher.rb +124 -0
- data/lib/slk/services/api_client.rb +145 -0
- data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
- data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
- data/lib/slk/services/emoji_downloader.rb +103 -0
- data/lib/slk/services/emoji_searcher.rb +72 -0
- data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
- data/lib/slk/services/gemoji_sync.rb +97 -0
- data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
- data/lib/slk/services/reaction_enricher.rb +82 -0
- data/lib/slk/services/setup_wizard.rb +131 -0
- data/lib/slk/services/target_resolver.rb +108 -0
- data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
- data/lib/slk/services/unread_marker.rb +101 -0
- data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
- data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
- data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
- data/lib/slk/support/interactive_prompt.rb +29 -0
- data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
- data/lib/slk/support/text_wrapper.rb +57 -0
- data/lib/slk/support/user_resolver.rb +141 -0
- data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
- data/lib/slk/version.rb +5 -0
- data/lib/slk.rb +112 -0
- metadata +80 -59
- data/lib/slack_cli/api/client.rb +0 -49
- data/lib/slack_cli/api/dnd.rb +0 -40
- data/lib/slack_cli/api/users.rb +0 -101
- data/lib/slack_cli/cli.rb +0 -118
- data/lib/slack_cli/commands/activity.rb +0 -292
- data/lib/slack_cli/commands/cache.rb +0 -116
- data/lib/slack_cli/commands/catchup.rb +0 -484
- data/lib/slack_cli/commands/config.rb +0 -159
- data/lib/slack_cli/commands/dnd.rb +0 -143
- data/lib/slack_cli/commands/emoji.rb +0 -412
- data/lib/slack_cli/commands/help.rb +0 -76
- data/lib/slack_cli/commands/messages.rb +0 -317
- data/lib/slack_cli/commands/presence.rb +0 -107
- data/lib/slack_cli/commands/preset.rb +0 -239
- data/lib/slack_cli/commands/status.rb +0 -194
- data/lib/slack_cli/commands/thread.rb +0 -62
- data/lib/slack_cli/commands/unread.rb +0 -312
- data/lib/slack_cli/commands/workspaces.rb +0 -151
- data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
- data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
- data/lib/slack_cli/formatters/message_formatter.rb +0 -429
- data/lib/slack_cli/models/duration.rb +0 -85
- data/lib/slack_cli/models/message.rb +0 -217
- data/lib/slack_cli/models/preset.rb +0 -73
- data/lib/slack_cli/models/user.rb +0 -56
- data/lib/slack_cli/models/workspace.rb +0 -52
- data/lib/slack_cli/services/api_client.rb +0 -149
- data/lib/slack_cli/services/reaction_enricher.rb +0 -87
- data/lib/slack_cli/support/user_resolver.rb +0 -114
- data/lib/slack_cli/version.rb +0 -5
- data/lib/slack_cli.rb +0 -91
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Downloads and caches standard emoji database from gemoji
|
|
6
|
+
class GemojiSync
|
|
7
|
+
GEMOJI_URL = 'https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json'
|
|
8
|
+
|
|
9
|
+
NETWORK_ERRORS = [
|
|
10
|
+
SocketError,
|
|
11
|
+
Errno::ECONNREFUSED,
|
|
12
|
+
Errno::ETIMEDOUT,
|
|
13
|
+
Net::OpenTimeout,
|
|
14
|
+
Net::ReadTimeout,
|
|
15
|
+
URI::InvalidURIError,
|
|
16
|
+
OpenSSL::SSL::SSLError
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(cache_dir:, on_progress: nil)
|
|
20
|
+
@cache_dir = cache_dir
|
|
21
|
+
@on_progress = on_progress
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Download and cache standard emoji database
|
|
25
|
+
# @return [Hash] Result with :success, :count, :path, or :error
|
|
26
|
+
def sync
|
|
27
|
+
@on_progress&.call('Downloading standard emoji database...')
|
|
28
|
+
|
|
29
|
+
response = fetch_gemoji_data
|
|
30
|
+
return response if response[:error]
|
|
31
|
+
|
|
32
|
+
emoji_map = parse_and_transform(response[:body])
|
|
33
|
+
return emoji_map if emoji_map[:error]
|
|
34
|
+
|
|
35
|
+
save_result = save_to_cache(emoji_map[:data])
|
|
36
|
+
return save_result if save_result[:error]
|
|
37
|
+
|
|
38
|
+
{ success: true, count: emoji_map[:data].size, path: emoji_json_path }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def emoji_json_path
|
|
42
|
+
File.join(@cache_dir, 'gemoji.json')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def fetch_gemoji_data
|
|
48
|
+
http = build_http_client(GEMOJI_URL)
|
|
49
|
+
request = Net::HTTP::Get.new(URI.parse(GEMOJI_URL))
|
|
50
|
+
response = http.request(request)
|
|
51
|
+
|
|
52
|
+
return { error: "Failed to download: HTTP #{response.code}" } unless response.is_a?(Net::HTTPSuccess)
|
|
53
|
+
|
|
54
|
+
{ body: response.body }
|
|
55
|
+
rescue *NETWORK_ERRORS => e
|
|
56
|
+
{ error: "Network error: #{e.message}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_http_client(url)
|
|
60
|
+
uri = URI.parse(url)
|
|
61
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
62
|
+
http.use_ssl = true
|
|
63
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
64
|
+
http.cert_store = OpenSSL::X509::Store.new
|
|
65
|
+
http.cert_store.set_default_paths
|
|
66
|
+
http
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_and_transform(body)
|
|
70
|
+
emoji_data = JSON.parse(body)
|
|
71
|
+
emoji_map = extract_emoji_aliases(emoji_data)
|
|
72
|
+
{ data: emoji_map }
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
{ error: "Failed to parse emoji data: #{e.message}" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_emoji_aliases(emoji_data)
|
|
78
|
+
emoji_map = {}
|
|
79
|
+
emoji_data.each do |emoji|
|
|
80
|
+
char = emoji['emoji']
|
|
81
|
+
next unless char
|
|
82
|
+
|
|
83
|
+
(emoji['aliases'] || []).each { |name| emoji_map[name] = char }
|
|
84
|
+
end
|
|
85
|
+
emoji_map
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def save_to_cache(emoji_map)
|
|
89
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
90
|
+
File.write(emoji_json_path, JSON.pretty_generate(emoji_map))
|
|
91
|
+
{ success: true }
|
|
92
|
+
rescue SystemCallError => e
|
|
93
|
+
{ error: "File system error: #{e.message}" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -1,43 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Services
|
|
5
|
+
# Manages saved status presets in JSON format
|
|
5
6
|
class PresetStore
|
|
6
7
|
DEFAULT_PRESETS = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
'meeting' => {
|
|
9
|
+
'text' => 'In a meeting',
|
|
10
|
+
'emoji' => ':calendar:',
|
|
11
|
+
'duration' => '1h',
|
|
12
|
+
'presence' => '',
|
|
13
|
+
'dnd' => ''
|
|
13
14
|
},
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
'lunch' => {
|
|
16
|
+
'text' => 'Lunch',
|
|
17
|
+
'emoji' => ':knife_fork_plate:',
|
|
18
|
+
'duration' => '1h',
|
|
19
|
+
'presence' => 'away',
|
|
20
|
+
'dnd' => ''
|
|
20
21
|
},
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
'focus' => {
|
|
23
|
+
'text' => 'Focus time',
|
|
24
|
+
'emoji' => ':headphones:',
|
|
25
|
+
'duration' => '2h',
|
|
26
|
+
'presence' => '',
|
|
27
|
+
'dnd' => '2h'
|
|
27
28
|
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
'brb' => {
|
|
30
|
+
'text' => 'Be right back',
|
|
31
|
+
'emoji' => ':brb:',
|
|
32
|
+
'duration' => '15m',
|
|
33
|
+
'presence' => 'away',
|
|
34
|
+
'dnd' => ''
|
|
34
35
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
'clear' => {
|
|
37
|
+
'text' => '',
|
|
38
|
+
'emoji' => '',
|
|
39
|
+
'duration' => '0',
|
|
40
|
+
'presence' => 'auto',
|
|
41
|
+
'dnd' => 'off'
|
|
41
42
|
}
|
|
42
43
|
}.freeze
|
|
43
44
|
|
|
@@ -74,7 +75,7 @@ module SlackCli
|
|
|
74
75
|
save_presets(presets)
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
def remove(name)
|
|
78
|
+
def remove(name) # rubocop:disable Naming/PredicateMethod
|
|
78
79
|
presets = load_presets
|
|
79
80
|
removed = presets.delete(name)
|
|
80
81
|
save_presets(presets) if removed
|
|
@@ -105,7 +106,7 @@ module SlackCli
|
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
def presets_file
|
|
108
|
-
@paths.config_file(
|
|
109
|
+
@paths.config_file('presets.json')
|
|
109
110
|
end
|
|
110
111
|
end
|
|
111
112
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Adds timestamps to message reactions via activity API
|
|
6
|
+
class ReactionEnricher
|
|
7
|
+
def initialize(activity_api:)
|
|
8
|
+
@activity_api = activity_api
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Enriches messages with reaction timestamps
|
|
12
|
+
# Returns new array of messages with timestamps added to reactions
|
|
13
|
+
def enrich_messages(messages, channel_id)
|
|
14
|
+
return messages if messages.empty?
|
|
15
|
+
|
|
16
|
+
# Fetch reaction activity
|
|
17
|
+
activity_map = fetch_reaction_activity(channel_id, messages.map(&:ts))
|
|
18
|
+
|
|
19
|
+
# Enhance messages with timestamps
|
|
20
|
+
messages.map do |msg|
|
|
21
|
+
enhanced_reactions = enhance_reactions(msg, activity_map)
|
|
22
|
+
msg.with_reactions(enhanced_reactions)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fetch_reaction_activity(_channel_id, message_timestamps)
|
|
29
|
+
response = @activity_api.feed(limit: 50, types: 'message_reaction')
|
|
30
|
+
return {} unless response['ok']
|
|
31
|
+
|
|
32
|
+
build_activity_map(response['items'] || [], message_timestamps)
|
|
33
|
+
rescue Slk::ApiError
|
|
34
|
+
# If activity API fails, gracefully degrade - return empty map
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_activity_map(items, message_timestamps)
|
|
39
|
+
activity_map = {}
|
|
40
|
+
items.each do |item|
|
|
41
|
+
key, timestamp = extract_reaction_key(item, message_timestamps)
|
|
42
|
+
activity_map[key] = timestamp if key
|
|
43
|
+
end
|
|
44
|
+
activity_map
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def extract_reaction_key(item, message_timestamps)
|
|
48
|
+
return nil unless item.dig('item', 'type') == 'message_reaction'
|
|
49
|
+
|
|
50
|
+
msg_data = item.dig('item', 'message')
|
|
51
|
+
reaction_data = item.dig('item', 'reaction')
|
|
52
|
+
return nil unless msg_data && reaction_data
|
|
53
|
+
|
|
54
|
+
msg_ts = msg_data['ts']
|
|
55
|
+
return nil unless message_timestamps.include?(msg_ts)
|
|
56
|
+
|
|
57
|
+
key = [msg_data['channel'], msg_ts, reaction_data['name'], reaction_data['user']].join(':')
|
|
58
|
+
[key, item['feed_ts']]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def enhance_reactions(message, activity_map)
|
|
62
|
+
return message.reactions if message.reactions.empty?
|
|
63
|
+
|
|
64
|
+
message.reactions.map { |reaction| enhance_reaction(message, reaction, activity_map) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def enhance_reaction(message, reaction, activity_map)
|
|
68
|
+
timestamp_map = build_timestamp_map(message, reaction, activity_map)
|
|
69
|
+
timestamp_map.empty? ? reaction : reaction.with_timestamps(timestamp_map)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_timestamp_map(message, reaction, activity_map)
|
|
73
|
+
timestamp_map = {}
|
|
74
|
+
reaction.users.each do |user_id|
|
|
75
|
+
key = [message.channel_id, message.ts, reaction.name, user_id].join(':')
|
|
76
|
+
timestamp_map[user_id] = activity_map[key] if activity_map[key]
|
|
77
|
+
end
|
|
78
|
+
timestamp_map
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Interactive setup wizard for configuring workspaces
|
|
6
|
+
class SetupWizard
|
|
7
|
+
def initialize(runner:, config:, token_store:, output:)
|
|
8
|
+
@runner = runner
|
|
9
|
+
@config = config
|
|
10
|
+
@token_store = token_store
|
|
11
|
+
@output = output
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Run the setup wizard
|
|
15
|
+
# @return [Integer] exit code (0 for success, 1 for error)
|
|
16
|
+
def run
|
|
17
|
+
print_header
|
|
18
|
+
|
|
19
|
+
return 0 if skip_if_configured?
|
|
20
|
+
|
|
21
|
+
setup_encryption unless @config.ssh_key
|
|
22
|
+
result = setup_workspace
|
|
23
|
+
return result if result != 0
|
|
24
|
+
|
|
25
|
+
print_success
|
|
26
|
+
0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def print_header
|
|
32
|
+
@output.puts 'Slack CLI Setup'
|
|
33
|
+
@output.puts '==============='
|
|
34
|
+
@output.puts
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def skip_if_configured?
|
|
38
|
+
return false unless @runner.workspaces?
|
|
39
|
+
|
|
40
|
+
@output.puts 'You already have workspaces configured.'
|
|
41
|
+
@output.print 'Add another workspace? (y/n): '
|
|
42
|
+
answer = $stdin.gets&.chomp&.downcase
|
|
43
|
+
answer != 'y'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def setup_encryption
|
|
47
|
+
print_encryption_header
|
|
48
|
+
ssh_key = $stdin.gets&.chomp
|
|
49
|
+
configure_ssh_key(ssh_key) if ssh_key && !ssh_key.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_encryption_header
|
|
53
|
+
@output.puts
|
|
54
|
+
@output.puts 'Encryption Setup (optional)'
|
|
55
|
+
@output.puts '----------------------------'
|
|
56
|
+
@output.puts 'You can encrypt your tokens with age using an SSH key.'
|
|
57
|
+
@output.print 'SSH key path (or press Enter to skip): '
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def configure_ssh_key(ssh_key)
|
|
61
|
+
if File.exist?(ssh_key)
|
|
62
|
+
@config.ssh_key = ssh_key
|
|
63
|
+
@output.success('SSH key configured')
|
|
64
|
+
else
|
|
65
|
+
@output.warn("File not found: #{ssh_key}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def setup_workspace
|
|
70
|
+
print_workspace_header
|
|
71
|
+
name, token = prompt_credentials
|
|
72
|
+
return 1 unless name && token
|
|
73
|
+
|
|
74
|
+
save_workspace(name, token, prompt_cookie_if_needed(token))
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def print_workspace_header
|
|
79
|
+
@output.puts
|
|
80
|
+
@output.puts 'Workspace Setup'
|
|
81
|
+
@output.puts '---------------'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prompt_credentials
|
|
85
|
+
[prompt_workspace_name, prompt_token]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def save_workspace(name, token, cookie)
|
|
89
|
+
@token_store.add(name, token, cookie)
|
|
90
|
+
@config.primary_workspace = name if @config.primary_workspace.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prompt_workspace_name
|
|
94
|
+
@output.print 'Workspace name: '
|
|
95
|
+
name = $stdin.gets&.chomp
|
|
96
|
+
return name unless name.nil? || name.empty?
|
|
97
|
+
|
|
98
|
+
@output.error('Name is required')
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def prompt_token
|
|
103
|
+
@output.print 'Token (xoxb-... or xoxc-...): '
|
|
104
|
+
token = $stdin.gets&.chomp
|
|
105
|
+
return token unless token.nil? || token.empty?
|
|
106
|
+
|
|
107
|
+
@output.error('Token is required')
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def prompt_cookie_if_needed(token)
|
|
112
|
+
return nil unless token.start_with?('xoxc-')
|
|
113
|
+
|
|
114
|
+
@output.puts
|
|
115
|
+
@output.puts 'xoxc tokens require a cookie for authentication.'
|
|
116
|
+
@output.print 'Cookie (d=...): '
|
|
117
|
+
$stdin.gets&.chomp
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def print_success
|
|
121
|
+
@output.puts
|
|
122
|
+
@output.success('Setup complete!')
|
|
123
|
+
@output.puts
|
|
124
|
+
@output.puts 'Try these commands:'
|
|
125
|
+
@output.puts ' slack status - View your status'
|
|
126
|
+
@output.puts ' slack messages #general - Read channel messages'
|
|
127
|
+
@output.puts ' slack help - See all commands'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Resolves message targets (channels, DMs, URLs) to channel IDs
|
|
6
|
+
class TargetResolver
|
|
7
|
+
Result = Data.define(:workspace, :channel_id, :thread_ts, :msg_ts)
|
|
8
|
+
|
|
9
|
+
def initialize(runner:, cache_store:)
|
|
10
|
+
@runner = runner
|
|
11
|
+
@cache = cache_store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Resolve a target string to workspace, channel_id, and optional thread/message ts
|
|
15
|
+
# @param target [String] Channel name, @user, channel ID, or Slack URL
|
|
16
|
+
# @param default_workspace [Workspace] Workspace to use if not in URL
|
|
17
|
+
# @return [Result] Resolved target
|
|
18
|
+
def resolve(target, default_workspace:)
|
|
19
|
+
url_result = resolve_url(target)
|
|
20
|
+
return url_result if url_result
|
|
21
|
+
|
|
22
|
+
resolve_non_url(target, default_workspace)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def resolve_non_url(target, workspace)
|
|
28
|
+
return build_result(workspace, target) if channel_id?(target)
|
|
29
|
+
return resolve_dm_target(workspace, target) if target.start_with?('@')
|
|
30
|
+
|
|
31
|
+
# Default: treat as channel name (with or without #)
|
|
32
|
+
resolve_channel_target(workspace, target)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def channel_id?(target)
|
|
36
|
+
target.match?(/^[CDG][A-Z0-9]+$/)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_channel_target(workspace, target)
|
|
40
|
+
channel_id = resolve_channel(workspace, target.delete_prefix('#'))
|
|
41
|
+
build_result(workspace, channel_id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve_dm_target(workspace, target)
|
|
45
|
+
channel_id = resolve_dm(workspace, target.delete_prefix('@'))
|
|
46
|
+
build_result(workspace, channel_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_result(workspace, channel_id, thread_ts: nil, msg_ts: nil)
|
|
50
|
+
Result.new(workspace: workspace, channel_id: channel_id, thread_ts: thread_ts, msg_ts: msg_ts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_url(target)
|
|
54
|
+
url_parser = Support::SlackUrlParser.new
|
|
55
|
+
return nil unless url_parser.slack_url?(target)
|
|
56
|
+
|
|
57
|
+
result = url_parser.parse(target)
|
|
58
|
+
return nil unless result
|
|
59
|
+
|
|
60
|
+
ws = @runner.workspace(result.workspace)
|
|
61
|
+
if result.thread?
|
|
62
|
+
Result.new(workspace: ws, channel_id: result.channel_id, thread_ts: result.thread_ts, msg_ts: nil)
|
|
63
|
+
else
|
|
64
|
+
Result.new(workspace: ws, channel_id: result.channel_id, thread_ts: nil, msg_ts: result.msg_ts)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_channel(workspace, name)
|
|
69
|
+
cached = @cache.get_channel_id(workspace.name, name)
|
|
70
|
+
return cached if cached
|
|
71
|
+
|
|
72
|
+
fetch_and_cache_channel(workspace, name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_and_cache_channel(workspace, name)
|
|
76
|
+
channels = @runner.conversations_api(workspace.name).list['channels'] || []
|
|
77
|
+
channel = channels.find { |c| c['name'] == name }
|
|
78
|
+
raise ConfigError, "Channel not found: ##{name}" unless channel
|
|
79
|
+
|
|
80
|
+
@cache.set_channel(workspace.name, name, channel['id'])
|
|
81
|
+
channel['id']
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_dm(workspace, username)
|
|
85
|
+
user_id = find_user_id(workspace, username)
|
|
86
|
+
raise ConfigError, "User not found: @#{username}" unless user_id
|
|
87
|
+
|
|
88
|
+
api = @runner.conversations_api(workspace.name)
|
|
89
|
+
response = api.open(users: user_id)
|
|
90
|
+
response.dig('channel', 'id')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_user_id(workspace, username)
|
|
94
|
+
api = @runner.users_api(workspace.name)
|
|
95
|
+
response = api.list
|
|
96
|
+
users = response['members'] || []
|
|
97
|
+
|
|
98
|
+
user = users.find do |u|
|
|
99
|
+
u['name'] == username ||
|
|
100
|
+
u.dig('profile', 'display_name') == username ||
|
|
101
|
+
u.dig('profile', 'real_name') == username
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
user&.dig('id')
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Services
|
|
5
|
+
# Manages workspace tokens with optional encryption
|
|
5
6
|
class TokenStore
|
|
6
7
|
attr_accessor :on_warning
|
|
7
8
|
|
|
@@ -19,8 +20,8 @@ module SlackCli
|
|
|
19
20
|
|
|
20
21
|
Models::Workspace.new(
|
|
21
22
|
name: name,
|
|
22
|
-
token: data[
|
|
23
|
-
cookie: data[
|
|
23
|
+
token: data['token'],
|
|
24
|
+
cookie: data['cookie']
|
|
24
25
|
)
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -28,8 +29,8 @@ module SlackCli
|
|
|
28
29
|
load_tokens.map do |name, data|
|
|
29
30
|
Models::Workspace.new(
|
|
30
31
|
name: name,
|
|
31
|
-
token: data[
|
|
32
|
-
cookie: data[
|
|
32
|
+
token: data['token'],
|
|
33
|
+
cookie: data['cookie']
|
|
33
34
|
)
|
|
34
35
|
end
|
|
35
36
|
end
|
|
@@ -47,11 +48,11 @@ module SlackCli
|
|
|
47
48
|
Models::Workspace.new(name: name, token: token, cookie: cookie)
|
|
48
49
|
|
|
49
50
|
tokens = load_tokens
|
|
50
|
-
tokens[name] = {
|
|
51
|
+
tokens[name] = { 'token' => token, 'cookie' => cookie }.compact
|
|
51
52
|
save_tokens(tokens)
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
def remove(name)
|
|
55
|
+
def remove(name) # rubocop:disable Naming/PredicateMethod
|
|
55
56
|
tokens = load_tokens
|
|
56
57
|
removed = tokens.delete(name)
|
|
57
58
|
save_tokens(tokens) if removed
|
|
@@ -82,7 +83,7 @@ module SlackCli
|
|
|
82
83
|
if @config.ssh_key
|
|
83
84
|
# When encryption is configured, always use it - don't silently fall back
|
|
84
85
|
@encryption.encrypt(JSON.generate(tokens), @config.ssh_key, encrypted_tokens_file)
|
|
85
|
-
|
|
86
|
+
FileUtils.rm_f(plain_tokens_file)
|
|
86
87
|
else
|
|
87
88
|
# Plain text storage (no encryption configured)
|
|
88
89
|
File.write(plain_tokens_file, JSON.pretty_generate(tokens))
|
|
@@ -106,11 +107,11 @@ module SlackCli
|
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
def encrypted_tokens_file
|
|
109
|
-
@paths.config_file(
|
|
110
|
+
@paths.config_file('tokens.age')
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
def plain_tokens_file
|
|
113
|
-
@paths.config_file(
|
|
114
|
+
@paths.config_file('tokens.json')
|
|
114
115
|
end
|
|
115
116
|
end
|
|
116
117
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Handles batch marking of unreads as read
|
|
6
|
+
class UnreadMarker
|
|
7
|
+
def initialize(conversations_api:, threads_api:, client_api:, users_api:, on_debug: nil)
|
|
8
|
+
@conversations = conversations_api
|
|
9
|
+
@threads = threads_api
|
|
10
|
+
@client = client_api
|
|
11
|
+
@users = users_api
|
|
12
|
+
@on_debug = on_debug
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Mark all unreads in a workspace, return counts
|
|
16
|
+
# @param options [Hash] :muted - include muted channels
|
|
17
|
+
# @return [Hash] { dms: count, channels: count, threads: count }
|
|
18
|
+
def mark_all(options: {})
|
|
19
|
+
counts = @client.counts
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
dms: mark_dms(counts['ims'] || []),
|
|
23
|
+
channels: mark_channels(counts['channels'] || [], muted: options[:muted]),
|
|
24
|
+
threads: mark_threads
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Mark a single channel as read
|
|
29
|
+
# @param channel_id [String] Channel ID
|
|
30
|
+
# @return [Boolean] true if marked successfully
|
|
31
|
+
def mark_single_channel(channel_id)
|
|
32
|
+
mark_conversation(channel_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def mark_dms(ims)
|
|
38
|
+
count = 0
|
|
39
|
+
ims.each do |im|
|
|
40
|
+
next unless im['has_unreads']
|
|
41
|
+
|
|
42
|
+
count += 1 if mark_conversation(im['id'])
|
|
43
|
+
end
|
|
44
|
+
count
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mark_channels(channels, muted: false)
|
|
48
|
+
muted_ids = muted ? [] : @users.muted_channels
|
|
49
|
+
count = 0
|
|
50
|
+
|
|
51
|
+
channels.each do |channel|
|
|
52
|
+
next unless channel['has_unreads']
|
|
53
|
+
next if !muted && muted_ids.include?(channel['id'])
|
|
54
|
+
|
|
55
|
+
count += 1 if mark_conversation(channel['id'])
|
|
56
|
+
end
|
|
57
|
+
count
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mark_conversation(channel_id)
|
|
61
|
+
history = @conversations.history(channel: channel_id, limit: 1)
|
|
62
|
+
messages = history['messages']
|
|
63
|
+
return false unless messages&.any?
|
|
64
|
+
|
|
65
|
+
@conversations.mark(channel: channel_id, timestamp: messages.first['ts'])
|
|
66
|
+
true
|
|
67
|
+
rescue ApiError => e
|
|
68
|
+
@on_debug&.call("Could not mark #{channel_id}: #{e.message}")
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mark_threads
|
|
73
|
+
response = @threads.get_view(limit: 50)
|
|
74
|
+
return 0 unless response['ok']
|
|
75
|
+
|
|
76
|
+
count = 0
|
|
77
|
+
(response['threads'] || []).each do |thread|
|
|
78
|
+
count += 1 if mark_thread(thread)
|
|
79
|
+
end
|
|
80
|
+
count
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mark_thread(thread)
|
|
84
|
+
unread_replies = thread['unread_replies'] || []
|
|
85
|
+
return false if unread_replies.empty?
|
|
86
|
+
|
|
87
|
+
call_mark_thread(thread, unread_replies)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def call_mark_thread(thread, unread_replies)
|
|
91
|
+
root_msg = thread['root_msg'] || {}
|
|
92
|
+
@threads.mark(channel: root_msg['channel'], thread_ts: root_msg['thread_ts'],
|
|
93
|
+
timestamp: unread_replies.map { |r| r['ts'] }.max)
|
|
94
|
+
true
|
|
95
|
+
rescue ApiError => e
|
|
96
|
+
@on_debug&.call("Could not mark thread #{root_msg['thread_ts']} in #{root_msg['channel']}: #{e.message}")
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|