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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +5 -5
  4. data/bin/slk +3 -3
  5. data/lib/{slack_cli → slk}/api/activity.rb +10 -11
  6. data/lib/{slack_cli → slk}/api/bots.rb +5 -4
  7. data/lib/slk/api/client.rb +51 -0
  8. data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
  9. data/lib/slk/api/dnd.rb +41 -0
  10. data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
  11. data/lib/{slack_cli → slk}/api/threads.rb +13 -12
  12. data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
  13. data/lib/slk/api/users.rb +105 -0
  14. data/lib/slk/cli.rb +157 -0
  15. data/lib/slk/commands/activity.rb +152 -0
  16. data/lib/{slack_cli → slk}/commands/base.rb +67 -41
  17. data/lib/slk/commands/cache.rb +141 -0
  18. data/lib/slk/commands/catchup.rb +411 -0
  19. data/lib/slk/commands/config.rb +114 -0
  20. data/lib/slk/commands/dnd.rb +172 -0
  21. data/lib/slk/commands/emoji.rb +352 -0
  22. data/lib/slk/commands/help.rb +97 -0
  23. data/lib/slk/commands/messages.rb +299 -0
  24. data/lib/slk/commands/presence.rb +109 -0
  25. data/lib/slk/commands/preset.rb +231 -0
  26. data/lib/slk/commands/status.rb +223 -0
  27. data/lib/slk/commands/thread.rb +72 -0
  28. data/lib/slk/commands/unread.rb +305 -0
  29. data/lib/slk/commands/workspaces.rb +168 -0
  30. data/lib/slk/formatters/activity_formatter.rb +148 -0
  31. data/lib/slk/formatters/attachment_formatter.rb +65 -0
  32. data/lib/slk/formatters/block_formatter.rb +57 -0
  33. data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
  34. data/lib/slk/formatters/emoji_replacer.rb +141 -0
  35. data/lib/slk/formatters/json_message_formatter.rb +95 -0
  36. data/lib/slk/formatters/mention_replacer.rb +158 -0
  37. data/lib/slk/formatters/message_formatter.rb +174 -0
  38. data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
  39. data/lib/slk/formatters/reaction_formatter.rb +87 -0
  40. data/lib/{slack_cli → slk}/models/channel.rb +12 -10
  41. data/lib/slk/models/duration.rb +94 -0
  42. data/lib/slk/models/message.rb +242 -0
  43. data/lib/slk/models/preset.rb +78 -0
  44. data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
  45. data/lib/{slack_cli → slk}/models/status.rb +6 -6
  46. data/lib/slk/models/user.rb +55 -0
  47. data/lib/slk/models/workspace.rb +54 -0
  48. data/lib/{slack_cli → slk}/runner.rb +22 -19
  49. data/lib/slk/services/activity_enricher.rb +124 -0
  50. data/lib/slk/services/api_client.rb +145 -0
  51. data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
  52. data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
  53. data/lib/slk/services/emoji_downloader.rb +103 -0
  54. data/lib/slk/services/emoji_searcher.rb +72 -0
  55. data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
  56. data/lib/slk/services/gemoji_sync.rb +97 -0
  57. data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
  58. data/lib/slk/services/reaction_enricher.rb +82 -0
  59. data/lib/slk/services/setup_wizard.rb +131 -0
  60. data/lib/slk/services/target_resolver.rb +108 -0
  61. data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
  62. data/lib/slk/services/unread_marker.rb +101 -0
  63. data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
  64. data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
  65. data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
  66. data/lib/slk/support/interactive_prompt.rb +29 -0
  67. data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
  68. data/lib/slk/support/text_wrapper.rb +57 -0
  69. data/lib/slk/support/user_resolver.rb +141 -0
  70. data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
  71. data/lib/slk/version.rb +5 -0
  72. data/lib/slk.rb +112 -0
  73. metadata +80 -59
  74. data/lib/slack_cli/api/client.rb +0 -49
  75. data/lib/slack_cli/api/dnd.rb +0 -40
  76. data/lib/slack_cli/api/users.rb +0 -101
  77. data/lib/slack_cli/cli.rb +0 -118
  78. data/lib/slack_cli/commands/activity.rb +0 -292
  79. data/lib/slack_cli/commands/cache.rb +0 -116
  80. data/lib/slack_cli/commands/catchup.rb +0 -484
  81. data/lib/slack_cli/commands/config.rb +0 -159
  82. data/lib/slack_cli/commands/dnd.rb +0 -143
  83. data/lib/slack_cli/commands/emoji.rb +0 -412
  84. data/lib/slack_cli/commands/help.rb +0 -76
  85. data/lib/slack_cli/commands/messages.rb +0 -317
  86. data/lib/slack_cli/commands/presence.rb +0 -107
  87. data/lib/slack_cli/commands/preset.rb +0 -239
  88. data/lib/slack_cli/commands/status.rb +0 -194
  89. data/lib/slack_cli/commands/thread.rb +0 -62
  90. data/lib/slack_cli/commands/unread.rb +0 -312
  91. data/lib/slack_cli/commands/workspaces.rb +0 -151
  92. data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
  93. data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
  94. data/lib/slack_cli/formatters/message_formatter.rb +0 -429
  95. data/lib/slack_cli/models/duration.rb +0 -85
  96. data/lib/slack_cli/models/message.rb +0 -217
  97. data/lib/slack_cli/models/preset.rb +0 -73
  98. data/lib/slack_cli/models/user.rb +0 -56
  99. data/lib/slack_cli/models/workspace.rb +0 -52
  100. data/lib/slack_cli/services/api_client.rb +0 -149
  101. data/lib/slack_cli/services/reaction_enricher.rb +0 -87
  102. data/lib/slack_cli/support/user_resolver.rb +0 -114
  103. data/lib/slack_cli/version.rb +0 -5
  104. 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 SlackCli
3
+ module Slk
4
4
  module Services
5
+ # Manages saved status presets in JSON format
5
6
  class PresetStore
6
7
  DEFAULT_PRESETS = {
7
- "meeting" => {
8
- "text" => "In a meeting",
9
- "emoji" => ":calendar:",
10
- "duration" => "1h",
11
- "presence" => "",
12
- "dnd" => ""
8
+ 'meeting' => {
9
+ 'text' => 'In a meeting',
10
+ 'emoji' => ':calendar:',
11
+ 'duration' => '1h',
12
+ 'presence' => '',
13
+ 'dnd' => ''
13
14
  },
14
- "lunch" => {
15
- "text" => "Lunch",
16
- "emoji" => ":knife_fork_plate:",
17
- "duration" => "1h",
18
- "presence" => "away",
19
- "dnd" => ""
15
+ 'lunch' => {
16
+ 'text' => 'Lunch',
17
+ 'emoji' => ':knife_fork_plate:',
18
+ 'duration' => '1h',
19
+ 'presence' => 'away',
20
+ 'dnd' => ''
20
21
  },
21
- "focus" => {
22
- "text" => "Focus time",
23
- "emoji" => ":headphones:",
24
- "duration" => "2h",
25
- "presence" => "",
26
- "dnd" => "2h"
22
+ 'focus' => {
23
+ 'text' => 'Focus time',
24
+ 'emoji' => ':headphones:',
25
+ 'duration' => '2h',
26
+ 'presence' => '',
27
+ 'dnd' => '2h'
27
28
  },
28
- "brb" => {
29
- "text" => "Be right back",
30
- "emoji" => ":brb:",
31
- "duration" => "15m",
32
- "presence" => "away",
33
- "dnd" => ""
29
+ 'brb' => {
30
+ 'text' => 'Be right back',
31
+ 'emoji' => ':brb:',
32
+ 'duration' => '15m',
33
+ 'presence' => 'away',
34
+ 'dnd' => ''
34
35
  },
35
- "clear" => {
36
- "text" => "",
37
- "emoji" => "",
38
- "duration" => "0",
39
- "presence" => "auto",
40
- "dnd" => "off"
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("presets.json")
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 SlackCli
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["token"],
23
- cookie: data["cookie"]
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["token"],
32
- cookie: data["cookie"]
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] = { "token" => token, "cookie" => cookie }.compact
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
- File.delete(plain_tokens_file) if File.exist?(plain_tokens_file)
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("tokens.age")
110
+ @paths.config_file('tokens.age')
110
111
  end
111
112
 
112
113
  def plain_tokens_file
113
- @paths.config_file("tokens.json")
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
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
4
  module Support
5
+ # Logs errors to a file for debugging
5
6
  module ErrorLogger
6
7
  # Log an error to the error log file
7
8
  # @param error [Exception] The error to log