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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Displays recent activity feed items (reactions, mentions, threads)
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Activity < Base
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ workspace = target_workspaces.first
15
+ fetch_and_display_activity(workspace)
16
+ rescue ApiError => e
17
+ error("Failed to fetch activity: #{e.message}")
18
+ 1
19
+ end
20
+
21
+ private
22
+
23
+ def fetch_and_display_activity(workspace)
24
+ api = runner.activity_api(workspace.name)
25
+ response = api.feed(limit: @options[:limit], types: activity_types)
26
+
27
+ return error_result(response) unless response['ok']
28
+
29
+ display_activity(workspace, response['items'] || [])
30
+ 0
31
+ end
32
+
33
+ def error_result(response)
34
+ error("Failed to fetch activity: #{response['error']}")
35
+ 1
36
+ end
37
+
38
+ def display_activity(workspace, items)
39
+ if @options[:json]
40
+ output_json(enricher(workspace).enrich_all(items, workspace))
41
+ else
42
+ formatter(workspace).display_all(items, workspace, options: display_options(workspace))
43
+ end
44
+ end
45
+
46
+ protected
47
+
48
+ def default_options
49
+ super.merge(
50
+ limit: 20,
51
+ filter: :all,
52
+ show_messages: false
53
+ )
54
+ end
55
+
56
+ def handle_option(arg, args, remaining)
57
+ case arg
58
+ when '-n', '--limit' then @options[:limit] = args.shift.to_i
59
+ when '--reactions' then @options[:filter] = :reactions
60
+ when '--mentions' then @options[:filter] = :mentions
61
+ when '--threads' then @options[:filter] = :threads
62
+ when '--show-messages', '-m' then @options[:show_messages] = true
63
+ else super
64
+ end
65
+ end
66
+
67
+ def help_text
68
+ help = Support::HelpFormatter.new('slk activity [options]')
69
+ help.description('Show recent activity from the activity feed.')
70
+ add_options_section(help)
71
+ help.render
72
+ end
73
+
74
+ def add_options_section(help)
75
+ help.section('OPTIONS') do |s|
76
+ s.option('-n, --limit N', 'Number of items (default: 20, max: 50)')
77
+ s.option('--reactions', 'Show only reaction activity')
78
+ s.option('--mentions', 'Show only mentions')
79
+ s.option('--threads', 'Show only thread replies')
80
+ s.option('-m, --show-messages', 'Show the message content for each activity')
81
+ add_common_options(s)
82
+ end
83
+ end
84
+
85
+ def add_common_options(section)
86
+ section.option('--json', 'Output as JSON')
87
+ section.option('-w, --workspace', 'Specify workspace')
88
+ section.option('-v, --verbose', 'Show debug information')
89
+ section.option('-q, --quiet', 'Suppress output')
90
+ end
91
+
92
+ private
93
+
94
+ def activity_types
95
+ case @options[:filter]
96
+ when :reactions
97
+ 'message_reaction'
98
+ when :mentions
99
+ 'at_user,at_user_group,at_channel,at_everyone'
100
+ when :threads
101
+ 'thread_v2'
102
+ else
103
+ # All activity types that the Slack web UI uses
104
+ 'thread_v2,message_reaction,bot_dm_bundle,at_user,at_user_group,at_channel,at_everyone'
105
+ end
106
+ end
107
+
108
+ def enricher(workspace)
109
+ Services::ActivityEnricher.new(
110
+ cache_store: cache_store,
111
+ conversations_api: runner.conversations_api(workspace.name),
112
+ on_debug: ->(msg) { debug(msg) }
113
+ )
114
+ end
115
+
116
+ def formatter(workspace)
117
+ Formatters::ActivityFormatter.new(
118
+ output: output,
119
+ enricher: enricher(workspace),
120
+ emoji_replacer: runner.emoji_replacer,
121
+ mention_replacer: runner.mention_replacer,
122
+ on_debug: ->(msg) { debug(msg) }
123
+ )
124
+ end
125
+
126
+ def display_options(_workspace)
127
+ {
128
+ show_messages: @options[:show_messages],
129
+ fetch_message: ->(ws, channel_id, message_ts) { fetch_message(ws, channel_id, message_ts) }
130
+ }
131
+ end
132
+
133
+ def fetch_message(workspace, channel_id, message_ts)
134
+ response = fetch_message_history(workspace, channel_id, message_ts)
135
+ return nil unless response['ok'] && response['messages']&.any?
136
+
137
+ response['messages'].find { |msg| msg['ts'] == message_ts }
138
+ rescue ApiError => e
139
+ debug("Could not fetch message #{message_ts} from #{channel_id}: #{e.message}")
140
+ nil
141
+ end
142
+
143
+ def fetch_message_history(workspace, channel_id, message_ts)
144
+ api = runner.conversations_api(workspace.name)
145
+ oldest_ts = (message_ts.to_f - 1).to_s
146
+ latest_ts = (message_ts.to_f + 1).to_s
147
+ api.history(channel: channel_id, limit: 10, oldest: oldest_ts, latest: latest_ts)
148
+ end
149
+ end
150
+ # rubocop:enable Metrics/ClassLength
151
+ end
152
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
4
  module Commands
5
+ # Base class for all CLI commands with option parsing and output helpers
6
+ # rubocop:disable Metrics/ClassLength
5
7
  class Base
6
8
  attr_reader :runner, :options, :positional_args
7
9
 
@@ -12,7 +14,7 @@ module SlackCli
12
14
  end
13
15
 
14
16
  def execute
15
- raise NotImplementedError, "Subclass must implement #execute"
17
+ raise NotImplementedError, 'Subclass must implement #execute'
16
18
  end
17
19
 
18
20
  protected
@@ -26,14 +28,15 @@ module SlackCli
26
28
  def api_client = runner.api_client
27
29
 
28
30
  def default_options
29
- {
30
- workspace: nil,
31
- all: false,
32
- verbose: false,
33
- quiet: false,
34
- json: false,
35
- width: default_width
36
- }
31
+ base_options.merge(formatting_options)
32
+ end
33
+
34
+ def base_options
35
+ { workspace: nil, all: false, verbose: false, quiet: false, json: false, width: default_width }
36
+ end
37
+
38
+ def formatting_options
39
+ { no_emoji: false, no_reactions: false, no_names: false, reaction_names: false, reaction_timestamps: false }
37
40
  end
38
41
 
39
42
  # Default wrap width: 72 for interactive terminals, nil (no wrap) otherwise
@@ -48,39 +51,47 @@ module SlackCli
48
51
 
49
52
  while args.any?
50
53
  arg = args.shift
54
+ next remaining << arg unless arg.start_with?('-')
51
55
 
52
- case arg
53
- when "-w", "--workspace"
54
- @options[:workspace] = args.shift
55
- when "--width"
56
- value = args.shift
57
- @options[:width] = value == '0' ? nil : value.to_i
58
- when "--no-wrap"
59
- @options[:width] = nil
60
- when "--all"
61
- @options[:all] = true
62
- when "-v", "--verbose"
63
- @options[:verbose] = true
64
- when "-q", "--quiet"
65
- @options[:quiet] = true
66
- when "--json"
67
- @options[:json] = true
68
- when "-h", "--help"
69
- @options[:help] = true
70
- when /^-/
71
- # Let subclass handle unknown options
72
- handle_option(arg, args, remaining)
73
- else
74
- remaining << arg
75
- end
56
+ parse_single_option(arg, args, remaining)
76
57
  end
77
58
 
78
59
  remaining
79
60
  end
80
61
 
62
+ private
63
+
64
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
65
+ def parse_single_option(arg, args, remaining)
66
+ case arg
67
+ when '-w', '--workspace' then @options[:workspace] = args.shift
68
+ when '--width' then parse_width_option(args)
69
+ when '--no-wrap' then @options[:width] = nil
70
+ when '--all' then @options[:all] = true
71
+ when '-v', '--verbose' then @options[:verbose] = true
72
+ when '-q', '--quiet' then @options[:quiet] = true
73
+ when '--json' then @options[:json] = true
74
+ when '-h', '--help' then @options[:help] = true
75
+ when '--no-emoji' then @options[:no_emoji] = true
76
+ when '--no-reactions' then @options[:no_reactions] = true
77
+ when '--no-names' then @options[:no_names] = true
78
+ when '--reaction-names' then @options[:reaction_names] = true
79
+ when '--reaction-timestamps' then @options[:reaction_timestamps] = true
80
+ else handle_option(arg, args, remaining)
81
+ end
82
+ end
83
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
84
+
85
+ def parse_width_option(args)
86
+ value = args.shift
87
+ @options[:width] = value == '0' ? nil : value.to_i
88
+ end
89
+
90
+ protected
91
+
81
92
  # Override in subclass to handle command-specific options
82
93
  # Return true if option was handled, false to raise unknown option error
83
- def handle_option(arg, args, remaining)
94
+ def handle_option(arg, _args, _remaining) # rubocop:disable Naming/PredicateMethod
84
95
  # By default, unknown options are errors
85
96
  # Subclasses can override and return true to accept the option,
86
97
  # or call super to get this error behavior
@@ -94,13 +105,13 @@ module SlackCli
94
105
  return nil if @unknown_options.nil? || @unknown_options.empty?
95
106
 
96
107
  error("Unknown option: #{@unknown_options.first}")
97
- error("Run with --help for available options.")
108
+ error('Run with --help for available options.')
98
109
  1
99
110
  end
100
111
 
101
112
  # Returns true if there are unknown options
102
- def has_unknown_options?
103
- @unknown_options && @unknown_options.any?
113
+ def unknown_options?
114
+ @unknown_options&.any?
104
115
  end
105
116
 
106
117
  # Get workspaces to operate on based on options
@@ -128,13 +139,13 @@ module SlackCli
128
139
  # Returns exit code if should return early, nil otherwise
129
140
  def validate_options
130
141
  return show_help if show_help?
131
- return check_unknown_options if has_unknown_options?
142
+ return check_unknown_options if unknown_options?
132
143
 
133
144
  nil
134
145
  end
135
146
 
136
147
  def help_text
137
- "No help available for this command."
148
+ 'No help available for this command.'
138
149
  end
139
150
 
140
151
  # Output helpers
@@ -152,13 +163,14 @@ module SlackCli
152
163
 
153
164
  def error(message)
154
165
  output.error(message)
166
+ 1
155
167
  end
156
168
 
157
169
  def debug(message)
158
170
  output.debug(message) if @options[:verbose]
159
171
  end
160
172
 
161
- def puts(message = "")
173
+ def puts(message = '')
162
174
  output.puts(message) unless @options[:quiet]
163
175
  end
164
176
 
@@ -170,6 +182,20 @@ module SlackCli
170
182
  def output_json(data)
171
183
  output.puts(JSON.pretty_generate(data))
172
184
  end
185
+
186
+ # Build format options hash for message formatting
187
+ # Subclasses can override to add command-specific options
188
+ def format_options
189
+ {
190
+ no_emoji: @options[:no_emoji],
191
+ no_reactions: @options[:no_reactions],
192
+ no_names: @options[:no_names],
193
+ reaction_names: @options[:reaction_names],
194
+ reaction_timestamps: @options[:reaction_timestamps],
195
+ width: @options[:width]
196
+ }
197
+ end
173
198
  end
199
+ # rubocop:enable Metrics/ClassLength
174
200
  end
175
201
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Manages user and channel name cache
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Cache < Base
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ dispatch_action
15
+ rescue ApiError => e
16
+ error("Failed: #{e.message}")
17
+ 1
18
+ end
19
+
20
+ private
21
+
22
+ def dispatch_action
23
+ case positional_args
24
+ in ['status' | 'info'] | [] then show_status
25
+ in ['clear', *rest] then clear_cache(rest.first)
26
+ in ['populate' | 'refresh', *rest] then populate_cache(rest.first)
27
+ else unknown_action
28
+ end
29
+ end
30
+
31
+ def unknown_action
32
+ error("Unknown action: #{positional_args.first}")
33
+ 1
34
+ end
35
+
36
+ protected
37
+
38
+ def help_text
39
+ help = Support::HelpFormatter.new('slk cache <action> [workspace]')
40
+ help.description('Manage user and channel cache.')
41
+ add_actions_section(help)
42
+ add_options_section(help)
43
+ help.render
44
+ end
45
+
46
+ def add_actions_section(help)
47
+ help.section('ACTIONS') do |s|
48
+ s.action('status', 'Show cache status')
49
+ s.action('clear [ws]', 'Clear cache (all or specific workspace)')
50
+ s.action('populate [ws]', 'Populate user cache from API')
51
+ end
52
+ end
53
+
54
+ def add_options_section(help)
55
+ help.section('OPTIONS') do |s|
56
+ s.option('-w, --workspace', 'Specify workspace')
57
+ s.option('-q, --quiet', 'Suppress output')
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def show_status
64
+ target_workspaces.each { |workspace| display_workspace_status(workspace) }
65
+ 0
66
+ end
67
+
68
+ def display_workspace_status(workspace)
69
+ puts output.bold(workspace.name) if target_workspaces.size > 1
70
+ display_cache_counts(workspace)
71
+ display_cache_file_status(workspace)
72
+ end
73
+
74
+ def display_cache_counts(workspace)
75
+ user_count = cache_store.user_cache_size(workspace.name)
76
+ channel_count = cache_store.channel_cache_size(workspace.name)
77
+ puts " Users cached: #{user_count}"
78
+ puts " Channels cached: #{channel_count}"
79
+ end
80
+
81
+ def display_cache_file_status(workspace)
82
+ if cache_store.user_cache_file_exists?(workspace.name)
83
+ puts " User cache: #{output.green('present')}"
84
+ else
85
+ puts " User cache: #{output.yellow('not populated')}"
86
+ end
87
+ end
88
+
89
+ def clear_cache(workspace_name)
90
+ if workspace_name
91
+ cache_store.clear_user_cache(workspace_name)
92
+ cache_store.clear_channel_cache(workspace_name)
93
+ success("Cleared cache for #{workspace_name}")
94
+ else
95
+ cache_store.clear_user_cache
96
+ cache_store.clear_channel_cache
97
+ success('Cleared all caches')
98
+ end
99
+
100
+ 0
101
+ end
102
+
103
+ def populate_cache(workspace_name)
104
+ workspaces = workspace_name ? [runner.workspace(workspace_name)] : target_workspaces
105
+ workspaces.each { |workspace| populate_workspace_cache(workspace) }
106
+ 0
107
+ end
108
+
109
+ def populate_workspace_cache(workspace)
110
+ puts "Populating user cache for #{workspace.name}..."
111
+ all_users = fetch_all_users(workspace)
112
+ count = cache_store.populate_user_cache(workspace.name, all_users)
113
+ puts
114
+ success("Cached #{count} users for #{workspace.name}")
115
+ end
116
+
117
+ def fetch_all_users(workspace)
118
+ api = runner.users_api(workspace.name)
119
+ all_users = []
120
+ cursor = nil
121
+
122
+ loop do
123
+ response, cursor = fetch_users_page(api, cursor)
124
+ all_users.concat(response)
125
+ break if cursor.nil? || cursor.empty?
126
+
127
+ print '.'
128
+ end
129
+
130
+ all_users
131
+ end
132
+
133
+ def fetch_users_page(api, cursor)
134
+ response = api.list(cursor: cursor)
135
+ users = (response['members'] || []).map { |m| Models::User.from_api(m) }
136
+ [users, response.dig('response_metadata', 'next_cursor')]
137
+ end
138
+ end
139
+ # rubocop:enable Metrics/ClassLength
140
+ end
141
+ end