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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/inline_images'
4
+ require_relative '../support/help_formatter'
5
+
6
+ module Slk
7
+ module Commands
8
+ # Gets or sets user status text and emoji
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Status < Base
11
+ include Support::InlineImages
12
+
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ dispatch_action
18
+ rescue ApiError => e
19
+ error("Failed: #{e.message}")
20
+ 1
21
+ end
22
+
23
+ def dispatch_action
24
+ case positional_args
25
+ in ['clear', *] then clear_status
26
+ in [text, *rest] then set_status(text, rest)
27
+ in [] then get_status
28
+ end
29
+ end
30
+
31
+ protected
32
+
33
+ def default_options
34
+ super.merge(presence: nil, dnd: nil)
35
+ end
36
+
37
+ def handle_option(arg, args, remaining)
38
+ case arg
39
+ when '-p', '--presence'
40
+ @options[:presence] = args.shift
41
+ when '-d', '--dnd'
42
+ @options[:dnd] = args.shift
43
+ else
44
+ super
45
+ end
46
+ end
47
+
48
+ def help_text
49
+ help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
50
+ help.description('Get or set your Slack status.')
51
+ help.note('GET shows all workspaces by default. SET applies to primary only.')
52
+ add_examples_section(help)
53
+ add_options_section(help)
54
+ help.render
55
+ end
56
+
57
+ def add_examples_section(help)
58
+ help.section('EXAMPLES') do |s|
59
+ s.example('slk status', 'Show status (all workspaces)')
60
+ s.example('slk status clear', 'Clear status')
61
+ s.example('slk status "Working" :laptop:', 'Set status with emoji')
62
+ s.example('slk status "Meeting" :calendar: 1h', 'Set status for 1 hour')
63
+ s.example('slk status "Focus" :headphones: 2h -p away -d 2h')
64
+ end
65
+ end
66
+
67
+ def add_options_section(help)
68
+ help.section('OPTIONS') do |s|
69
+ s.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
70
+ s.option('-d, --dnd DURATION', "Also set DND (or 'off')")
71
+ s.option('-w, --workspace', 'Limit to specific workspace')
72
+ s.option('--all', 'Set across all workspaces')
73
+ s.option('-v, --verbose', 'Show debug information')
74
+ s.option('-q, --quiet', 'Suppress output')
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def get_status # rubocop:disable Naming/AccessorMethodName
81
+ # GET defaults to all workspaces unless -w specified
82
+ workspaces = target_workspaces_for_get
83
+
84
+ workspaces.each do |workspace|
85
+ status = runner.users_api(workspace.name).get_status
86
+ print_workspace_status(workspaces, workspace, status)
87
+ end
88
+
89
+ 0
90
+ end
91
+
92
+ def target_workspaces_for_get
93
+ @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
94
+ end
95
+
96
+ def print_workspace_status(workspaces, workspace, status)
97
+ puts output.bold(workspace.name) if workspaces.size > 1
98
+
99
+ if status.empty?
100
+ puts ' (no status set)'
101
+ else
102
+ display_status(workspace, status)
103
+ end
104
+ end
105
+
106
+ def display_status(workspace, status)
107
+ emoji_path = workspace_emoji_path(workspace.name, status.emoji)
108
+
109
+ if emoji_path && inline_images_supported?
110
+ print_status_with_image(emoji_path, status)
111
+ else
112
+ puts " #{status}"
113
+ end
114
+ end
115
+
116
+ def workspace_emoji_path(workspace_name, emoji)
117
+ emoji_name = emoji.delete_prefix(':').delete_suffix(':')
118
+ find_workspace_emoji(workspace_name, emoji_name)
119
+ end
120
+
121
+ def print_status_with_image(emoji_path, status)
122
+ parts = []
123
+ parts << status.text unless status.text.empty?
124
+ parts << "(#{status.time_remaining})" if status.time_remaining
125
+ print_inline_image_with_text(emoji_path, " #{parts.join(' ')}")
126
+ end
127
+
128
+ def find_workspace_emoji(workspace_name, emoji_name)
129
+ return nil if emoji_name.empty?
130
+
131
+ paths = Support::XdgPaths.new
132
+ emoji_dir = config.emoji_dir || paths.cache_dir
133
+ workspace_dir = File.join(emoji_dir, workspace_name)
134
+ return nil unless Dir.exist?(workspace_dir)
135
+
136
+ # Look for emoji file with any extension
137
+ Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
138
+ end
139
+
140
+ def set_status(text, rest)
141
+ emoji = extract_emoji(rest)
142
+ duration = extract_duration(rest)
143
+
144
+ target_workspaces.each do |workspace|
145
+ apply_status_to_workspace(workspace, text, emoji, duration)
146
+ end
147
+
148
+ show_all_workspaces_hint
149
+ 0
150
+ end
151
+
152
+ def extract_emoji(rest)
153
+ rest.find { |arg| arg.start_with?(':') && arg.end_with?(':') } || ':speech_balloon:'
154
+ end
155
+
156
+ def extract_duration(rest)
157
+ duration_str = rest.find { |arg| arg.match?(/^\d+[hms]?$/) }
158
+ duration_str ? Models::Duration.parse(duration_str) : Models::Duration.zero
159
+ end
160
+
161
+ def apply_status_to_workspace(workspace, text, emoji, duration)
162
+ api = runner.users_api(workspace.name)
163
+ api.set_status(text: text, emoji: emoji, duration: duration)
164
+
165
+ log_status_set(workspace.name, text, emoji, duration)
166
+ apply_presence(workspace) if @options[:presence]
167
+ apply_dnd(workspace) if @options[:dnd]
168
+ end
169
+
170
+ def log_status_set(workspace_name, text, emoji, duration)
171
+ success("Status set on #{workspace_name}")
172
+ debug(" Text: #{text}")
173
+ debug(" Emoji: #{emoji}")
174
+ debug(" Duration: #{duration}") unless duration.zero?
175
+ end
176
+
177
+ def apply_presence(workspace)
178
+ value = @options[:presence]
179
+ value = 'auto' if value == 'active'
180
+
181
+ api = runner.users_api(workspace.name)
182
+ api.set_presence(value)
183
+ success("Presence set to #{value} on #{workspace.name}")
184
+ end
185
+
186
+ def apply_dnd(workspace)
187
+ value = @options[:dnd]
188
+ dnd_api = runner.dnd_api(workspace.name)
189
+
190
+ if value == 'off'
191
+ dnd_api.end_snooze
192
+ success("DND disabled on #{workspace.name}")
193
+ else
194
+ duration = Models::Duration.parse(value)
195
+ dnd_api.set_snooze(duration)
196
+ success("DND enabled for #{value} on #{workspace.name}")
197
+ end
198
+ end
199
+
200
+ def clear_status
201
+ target_workspaces.each do |workspace|
202
+ api = runner.users_api(workspace.name)
203
+ api.clear_status
204
+
205
+ success("Status cleared on #{workspace.name}")
206
+ end
207
+
208
+ show_all_workspaces_hint
209
+
210
+ 0
211
+ end
212
+
213
+ def show_all_workspaces_hint
214
+ # Show hint if user has multiple workspaces and didn't use --all or -w
215
+ return if @options[:all] || @options[:workspace]
216
+ return if runner.all_workspaces.size <= 1
217
+
218
+ info('Tip: Use --all to set across all workspaces')
219
+ end
220
+ end
221
+ # rubocop:enable Metrics/ClassLength
222
+ end
223
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'messages'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Views a message thread from a Slack URL
8
+ class Thread < Messages
9
+ def execute
10
+ result = validate_options
11
+ return result if result
12
+
13
+ target = positional_args.first
14
+ return usage_error unless target
15
+ return url_required_error unless Support::SlackUrlParser.new.slack_url?(target)
16
+
17
+ super
18
+ end
19
+
20
+ protected
21
+
22
+ def usage_error
23
+ error('Usage: slk thread <url>')
24
+ 1
25
+ end
26
+
27
+ def url_required_error
28
+ error('thread command requires a Slack URL')
29
+ 1
30
+ end
31
+
32
+ def default_options
33
+ super.merge(
34
+ limit: 1,
35
+ limit_set: true, # Prevent apply_default_limit from overriding
36
+ threads: true
37
+ )
38
+ end
39
+
40
+ def help_text
41
+ help = Support::HelpFormatter.new('slk thread <url> [options]')
42
+ help.description('View a message thread from a Slack URL.')
43
+ add_usage_section(help)
44
+ add_options_section(help)
45
+ add_examples_section(help)
46
+ help.render
47
+ end
48
+
49
+ private
50
+
51
+ def add_usage_section(help)
52
+ help.section('USAGE') { |s| s.item('<slack_url>', 'Slack message URL') }
53
+ end
54
+
55
+ def add_options_section(help)
56
+ help.section('OPTIONS') do |s|
57
+ s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
58
+ s.option('--no-reactions', 'Hide reactions')
59
+ s.option('--no-names', 'Skip user name lookups (faster)')
60
+ s.option('--json', 'Output as JSON')
61
+ s.option('-v, --verbose', 'Show debug information')
62
+ end
63
+ end
64
+
65
+ def add_examples_section(help)
66
+ help.section('EXAMPLES') do |s|
67
+ s.item('slk thread https://work.slack.com/archives/C123/p1234567890', 'View thread')
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Views and manages unread messages across workspaces
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Unread < Base
10
+ include Support::UserResolver
11
+
12
+ def execute
13
+ result = validate_options
14
+ return result if result
15
+
16
+ dispatch_action
17
+ rescue ApiError => e
18
+ error("Failed: #{e.message}")
19
+ 1
20
+ end
21
+
22
+ private
23
+
24
+ def dispatch_action
25
+ case positional_args
26
+ in ['clear', *rest] then clear_unread(rest.first)
27
+ in [] then show_unread
28
+ else unknown_action
29
+ end
30
+ end
31
+
32
+ def unknown_action
33
+ error("Unknown action: #{positional_args.first}")
34
+ 1
35
+ end
36
+
37
+ protected
38
+
39
+ def default_options
40
+ super.merge(
41
+ all: true, # Default to all workspaces
42
+ muted: false,
43
+ limit: 10
44
+ )
45
+ end
46
+
47
+ def handle_option(arg, args, remaining)
48
+ case arg
49
+ when '--muted'
50
+ @options[:muted] = true
51
+ when '-n', '--limit'
52
+ @options[:limit] = args.shift.to_i
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def help_text
59
+ help = Support::HelpFormatter.new('slk unread [action] [options]')
60
+ help.description('View and manage unread messages (all workspaces by default).')
61
+ add_actions_section(help)
62
+ add_options_section(help)
63
+ help.render
64
+ end
65
+
66
+ def add_actions_section(help)
67
+ help.section('ACTIONS') do |s|
68
+ s.action('(none)', 'Show unread messages')
69
+ s.action('clear', 'Mark all as read')
70
+ s.action('clear #channel', 'Mark specific channel as read')
71
+ end
72
+ end
73
+
74
+ def add_options_section(help)
75
+ help.section('OPTIONS') do |s|
76
+ add_core_options(s)
77
+ add_formatting_options(s)
78
+ end
79
+ end
80
+
81
+ def add_core_options(section)
82
+ section.option('-n, --limit N', 'Messages per channel (default: 10)')
83
+ section.option('--muted', 'Include/clear muted channels')
84
+ section.option('-w, --workspace', 'Limit to specific workspace')
85
+ section.option('--json', 'Output as JSON')
86
+ section.option('-q, --quiet', 'Suppress output')
87
+ end
88
+
89
+ def add_formatting_options(section)
90
+ section.option('--no-emoji', 'Show :emoji: codes instead of unicode')
91
+ section.option('--no-reactions', 'Hide reactions')
92
+ section.option('--reaction-names', 'Show reactions with user names')
93
+ section.option('--reaction-timestamps', 'Show when each person reacted')
94
+ end
95
+
96
+ private
97
+
98
+ def show_unread
99
+ target_workspaces.each do |workspace|
100
+ puts output.bold(workspace.name) if @options[:all] || target_workspaces.size > 1
101
+
102
+ unread_data = fetch_unread_data(workspace)
103
+
104
+ if @options[:json]
105
+ output_unread_json(workspace, unread_data)
106
+ else
107
+ display_unread(workspace, unread_data)
108
+ end
109
+ end
110
+
111
+ 0
112
+ end
113
+
114
+ def fetch_unread_data(workspace)
115
+ counts = runner.client_api(workspace.name).counts
116
+ muted_ids = fetch_muted_ids(workspace)
117
+
118
+ {
119
+ unread_ims: filter_unread_ims(counts['ims'] || []),
120
+ unread_channels: filter_unread_channels(counts['channels'] || [], muted_ids)
121
+ }
122
+ end
123
+
124
+ def fetch_muted_ids(workspace)
125
+ @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
126
+ end
127
+
128
+ def filter_unread_ims(ims)
129
+ ims.select { |i| i['has_unreads'] }
130
+ end
131
+
132
+ def filter_unread_channels(channels, muted_ids)
133
+ channels
134
+ .select { |c| c['has_unreads'] || (c['mention_count'] || 0).positive? }
135
+ .reject { |c| muted_ids.include?(c['id']) }
136
+ end
137
+
138
+ def output_unread_json(workspace, data)
139
+ output_json({
140
+ channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
141
+ dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
142
+ })
143
+ end
144
+
145
+ def format_channel_json(workspace, channel)
146
+ channel_hash = { id: channel['id'], mentions: channel['mention_count'] }
147
+ channel_name = cache_store.get_channel_name(workspace.name, channel['id'])
148
+ channel_hash[:name] = channel_name if channel_name
149
+ channel_hash
150
+ end
151
+
152
+ def format_dm_json(workspace, dm_item)
153
+ dm_hash = { id: dm_item['id'], mentions: dm_item['mention_count'] }
154
+ user_id = dm_item['user_id'] || dm_item['user']
155
+ if user_id
156
+ user_name = cache_store.get_user(workspace.name, user_id)
157
+ dm_hash[:user_name] = user_name if user_name
158
+ end
159
+ dm_hash
160
+ end
161
+
162
+ def display_unread(workspace, data)
163
+ conversations_api = runner.conversations_api(workspace.name)
164
+ formatter = runner.message_formatter
165
+
166
+ display_unread_dms(workspace, data[:unread_ims], conversations_api, formatter)
167
+ display_unread_channels(workspace, data[:unread_channels], conversations_api, formatter)
168
+ show_threads(workspace, formatter)
169
+ end
170
+
171
+ def display_unread_dms(workspace, unread_ims, conversations_api, formatter)
172
+ unread_ims.each do |im|
173
+ mention_count = im['mention_count'] || 0
174
+ user_name = resolve_dm_user_name(workspace, im['id'], conversations_api)
175
+ puts
176
+ puts output.bold("@#{user_name}") + (mention_count.positive? ? " (#{mention_count} mentions)" : '')
177
+ puts
178
+ show_channel_messages(workspace, im['id'], @options[:limit], conversations_api, formatter)
179
+ end
180
+ end
181
+
182
+ def display_unread_channels(workspace, unreads, conversations_api, formatter)
183
+ return puts('No unread messages') if unreads.empty?
184
+
185
+ unreads.each { |ch| display_channel(workspace, ch, conversations_api, formatter) }
186
+ end
187
+
188
+ def display_channel(workspace, channel, conversations_api, formatter)
189
+ name = cache_store.get_channel_name(workspace.name, channel['id']) || channel['id']
190
+ puts
191
+ puts "#{output.bold("##{name}")} (showing last #{@options[:limit]})"
192
+ puts
193
+ show_channel_messages(workspace, channel['id'], @options[:limit], conversations_api, formatter)
194
+ end
195
+
196
+ def show_threads(workspace, formatter)
197
+ threads_response = runner.threads_api(workspace.name).get_view(limit: 20)
198
+ return unless threads_response['ok']
199
+
200
+ total_unreads = threads_response['total_unread_replies'] || 0
201
+ return if total_unreads.zero?
202
+
203
+ print_threads_header(total_unreads)
204
+ (threads_response['threads'] || []).each { |t| display_thread(workspace, t, formatter) }
205
+ end
206
+
207
+ def print_threads_header(total_unreads)
208
+ puts
209
+ puts "#{output.bold('🧵 Threads')} (#{total_unreads} unread replies)"
210
+ puts
211
+ end
212
+
213
+ def display_thread(workspace, thread, formatter)
214
+ unread_replies = thread['unread_replies'] || []
215
+ return if unread_replies.empty?
216
+
217
+ print_thread_header(workspace, thread)
218
+ print_thread_replies(workspace, thread, unread_replies, formatter)
219
+ puts
220
+ end
221
+
222
+ def print_thread_header(workspace, thread)
223
+ root_msg = thread['root_msg'] || {}
224
+ channel_id = root_msg['channel']
225
+ conversation_label = resolve_conversation_label(workspace, channel_id)
226
+ root_user = extract_user_from_message(root_msg, workspace)
227
+
228
+ puts "#{output.blue(" #{conversation_label}")} - thread by #{output.bold(root_user)}"
229
+ end
230
+
231
+ def print_thread_replies(workspace, thread, unread_replies, formatter)
232
+ channel_id = (thread['root_msg'] || {})['channel']
233
+ unread_replies.first(@options[:limit]).each do |reply|
234
+ message = Models::Message.from_api(reply, channel_id: channel_id)
235
+ puts " #{formatter.format_simple(message, workspace: workspace, options: format_options)}"
236
+ end
237
+ end
238
+
239
+ def show_channel_messages(workspace, channel_id, limit, api, formatter)
240
+ messages = fetch_channel_messages(workspace, channel_id, limit, api)
241
+ messages.each do |message|
242
+ puts formatter.format_simple(message, workspace: workspace, options: format_options)
243
+ end
244
+ rescue ApiError => e
245
+ puts output.dim(" (Could not fetch messages: #{e.message})")
246
+ end
247
+
248
+ def fetch_channel_messages(workspace, channel_id, limit, api)
249
+ history = api.history(channel: channel_id, limit: limit)
250
+ raw_messages = (history['messages'] || []).reverse
251
+ messages = raw_messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
252
+
253
+ return messages unless @options[:reaction_timestamps]
254
+
255
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
256
+ enricher.enrich_messages(messages, channel_id)
257
+ end
258
+
259
+ def clear_unread(channel_name)
260
+ target_workspaces.each do |ws|
261
+ channel_name ? clear_single_channel(ws, channel_name) : clear_all_channels(ws)
262
+ end
263
+ 0
264
+ end
265
+
266
+ def clear_single_channel(workspace, channel_name)
267
+ channel_id = resolve_channel_id(workspace, channel_name)
268
+ return unless unread_marker(workspace).mark_single_channel(channel_id)
269
+
270
+ success("Marked ##{channel_name} as read on #{workspace.name}")
271
+ end
272
+
273
+ def clear_all_channels(workspace)
274
+ counts = unread_marker(workspace).mark_all(options: { muted: @options[:muted] })
275
+ success("Cleared #{counts[:channels]} channels and #{counts[:threads]} threads on #{workspace.name}")
276
+ end
277
+
278
+ def unread_marker(workspace)
279
+ Services::UnreadMarker.new(
280
+ conversations_api: runner.conversations_api(workspace.name),
281
+ threads_api: runner.threads_api(workspace.name),
282
+ client_api: runner.client_api(workspace.name),
283
+ users_api: runner.users_api(workspace.name),
284
+ on_debug: ->(msg) { debug(msg) }
285
+ )
286
+ end
287
+
288
+ def resolve_channel_id(workspace, channel_name)
289
+ return channel_name if channel_name.match?(/^[CDG][A-Z0-9]+$/)
290
+
291
+ name = channel_name.delete_prefix('#')
292
+ cache_store.get_channel_id(workspace.name, name) || resolve_channel(workspace, name)
293
+ end
294
+
295
+ def resolve_channel(workspace, name)
296
+ api = runner.conversations_api(workspace.name)
297
+ response = api.list
298
+ channels = response['channels'] || []
299
+ channel = channels.find { |c| c['name'] == name }
300
+ channel&.dig('id') || raise(ConfigError, "Channel not found: ##{name}")
301
+ end
302
+ end
303
+ # rubocop:enable Metrics/ClassLength
304
+ end
305
+ end