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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Duration unit multipliers (seconds per unit)
|
|
6
|
+
DURATION_UNITS = { 'h' => 3600, 'm' => 60, 's' => 1 }.freeze
|
|
7
|
+
|
|
8
|
+
Duration = Data.define(:seconds) do
|
|
9
|
+
class << self
|
|
10
|
+
def parse(input)
|
|
11
|
+
return new(seconds: 0) if input.nil? || input.to_s.strip.empty?
|
|
12
|
+
return new(seconds: input.to_i) if input.to_s.match?(/^\d+$/)
|
|
13
|
+
|
|
14
|
+
parse_duration_string(input.to_s.downcase, input)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def zero = new(seconds: 0)
|
|
18
|
+
|
|
19
|
+
def from_minutes(minutes)
|
|
20
|
+
new(seconds: minutes.to_i * 60)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_duration_string(str, original)
|
|
26
|
+
validate_no_duplicate_units(str, original)
|
|
27
|
+
total = calculate_total_seconds(str)
|
|
28
|
+
raise ArgumentError, "Invalid duration format: #{original}" if total.zero? && !str.match?(/^0/)
|
|
29
|
+
|
|
30
|
+
new(seconds: total)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_no_duplicate_units(str, original)
|
|
34
|
+
DURATION_UNITS.each_key do |unit|
|
|
35
|
+
next unless str.scan(/\d+#{unit}/).length > 1
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "Duplicate '#{unit}' unit in duration: #{original}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def calculate_total_seconds(str)
|
|
42
|
+
DURATION_UNITS.sum do |unit, multiplier|
|
|
43
|
+
(match = str.match(/(\d+)#{unit}/)) ? match[1].to_i * multiplier : 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def zero? = seconds.zero?
|
|
49
|
+
|
|
50
|
+
def to_minutes = (seconds / 60.0).ceil
|
|
51
|
+
|
|
52
|
+
def to_expiration
|
|
53
|
+
return 0 if zero?
|
|
54
|
+
|
|
55
|
+
Time.now.to_i + seconds
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_s
|
|
59
|
+
return '' if zero?
|
|
60
|
+
|
|
61
|
+
format_duration
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def +(other)
|
|
65
|
+
Duration.new(seconds: seconds + other.seconds)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def -(other)
|
|
69
|
+
Duration.new(seconds: [seconds - other.seconds, 0].max)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def format_duration
|
|
75
|
+
parts = []
|
|
76
|
+
remaining = seconds
|
|
77
|
+
|
|
78
|
+
_hours, remaining = extract_unit(remaining, 3600, 'h', parts)
|
|
79
|
+
_minutes, remaining = extract_unit(remaining, 60, 'm', parts)
|
|
80
|
+
parts << "#{remaining}s" if remaining.positive? && parts.empty?
|
|
81
|
+
|
|
82
|
+
parts.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_unit(remaining, divisor, suffix, parts)
|
|
86
|
+
return [0, remaining] if remaining < divisor
|
|
87
|
+
|
|
88
|
+
value = remaining / divisor
|
|
89
|
+
parts << "#{value}#{suffix}"
|
|
90
|
+
[value, remaining % divisor]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ModuleLength
|
|
4
|
+
module Slk
|
|
5
|
+
module Models
|
|
6
|
+
# Minimum text length before we extract content from Block Kit blocks.
|
|
7
|
+
# Slack sometimes sends minimal text (like a link preview) with the full
|
|
8
|
+
# content in blocks. 20 chars catches most of these cases without
|
|
9
|
+
# unnecessarily processing blocks for normal messages.
|
|
10
|
+
MESSAGE_BLOCK_TEXT_THRESHOLD = 20
|
|
11
|
+
|
|
12
|
+
Message = Data.define(
|
|
13
|
+
:ts,
|
|
14
|
+
:user_id,
|
|
15
|
+
:text,
|
|
16
|
+
:reactions,
|
|
17
|
+
:reply_count,
|
|
18
|
+
:thread_ts,
|
|
19
|
+
:files,
|
|
20
|
+
:attachments,
|
|
21
|
+
:blocks,
|
|
22
|
+
:user_profile,
|
|
23
|
+
:bot_profile,
|
|
24
|
+
:username,
|
|
25
|
+
:subtype,
|
|
26
|
+
:channel_id
|
|
27
|
+
) do
|
|
28
|
+
def self.from_api(data, channel_id: nil)
|
|
29
|
+
text = extract_message_text(data)
|
|
30
|
+
new(**build_attributes(data, text, channel_id))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.extract_message_text(data)
|
|
34
|
+
text = data['text'] || ''
|
|
35
|
+
blocks = data['blocks'] || []
|
|
36
|
+
|
|
37
|
+
return text if text.length >= MESSAGE_BLOCK_TEXT_THRESHOLD
|
|
38
|
+
|
|
39
|
+
blocks_text = extract_block_text(blocks)
|
|
40
|
+
blocks_text.empty? ? text : blocks_text
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# rubocop:disable Metrics/MethodLength
|
|
44
|
+
def self.build_attributes(data, text, channel_id)
|
|
45
|
+
{
|
|
46
|
+
ts: data['ts'],
|
|
47
|
+
user_id: data['user'] || data['bot_id'] || data['username'],
|
|
48
|
+
text: text,
|
|
49
|
+
reactions: parse_reactions(data['reactions']),
|
|
50
|
+
reply_count: data['reply_count'] || 0,
|
|
51
|
+
thread_ts: data['thread_ts'],
|
|
52
|
+
files: data['files'] || [],
|
|
53
|
+
attachments: data['attachments'] || [],
|
|
54
|
+
blocks: data['blocks'] || [],
|
|
55
|
+
user_profile: data['user_profile'],
|
|
56
|
+
bot_profile: data['bot_profile'],
|
|
57
|
+
username: data['username'],
|
|
58
|
+
subtype: data['subtype'],
|
|
59
|
+
channel_id: channel_id
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/MethodLength
|
|
63
|
+
|
|
64
|
+
def self.parse_reactions(reactions_data)
|
|
65
|
+
(reactions_data || []).map { |r| Reaction.from_api(r) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.extract_block_text(blocks)
|
|
69
|
+
return '' unless blocks.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
blocks.filter_map do |block|
|
|
72
|
+
case block['type']
|
|
73
|
+
when 'section'
|
|
74
|
+
block.dig('text', 'text')
|
|
75
|
+
when 'rich_text'
|
|
76
|
+
extract_rich_text_content(block['elements'])
|
|
77
|
+
end
|
|
78
|
+
end.join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.extract_rich_text_content(elements)
|
|
82
|
+
return '' unless elements.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
elements.filter_map do |element|
|
|
85
|
+
next unless element['elements'].is_a?(Array)
|
|
86
|
+
|
|
87
|
+
element['elements'].filter_map do |item|
|
|
88
|
+
item['text'] if item['type'] == 'text'
|
|
89
|
+
end.join
|
|
90
|
+
end.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# rubocop:disable Metrics/ParameterLists, Naming/MethodParameterName
|
|
94
|
+
def initialize(
|
|
95
|
+
ts:,
|
|
96
|
+
user_id:,
|
|
97
|
+
text: '',
|
|
98
|
+
reactions: [],
|
|
99
|
+
reply_count: 0,
|
|
100
|
+
thread_ts: nil,
|
|
101
|
+
files: [],
|
|
102
|
+
attachments: [],
|
|
103
|
+
blocks: [],
|
|
104
|
+
user_profile: nil,
|
|
105
|
+
bot_profile: nil,
|
|
106
|
+
username: nil,
|
|
107
|
+
subtype: nil,
|
|
108
|
+
channel_id: nil
|
|
109
|
+
)
|
|
110
|
+
validate_required_fields!(ts, user_id)
|
|
111
|
+
super(**freeze_attributes(
|
|
112
|
+
ts: ts, user_id: user_id, text: text, reactions: reactions,
|
|
113
|
+
reply_count: reply_count, thread_ts: thread_ts, files: files,
|
|
114
|
+
attachments: attachments, blocks: blocks, user_profile: user_profile,
|
|
115
|
+
bot_profile: bot_profile, username: username, subtype: subtype, channel_id: channel_id
|
|
116
|
+
))
|
|
117
|
+
end
|
|
118
|
+
# rubocop:enable Metrics/ParameterLists, Naming/MethodParameterName
|
|
119
|
+
|
|
120
|
+
def validate_required_fields!(timestamp, user)
|
|
121
|
+
ts_str = timestamp.to_s.strip
|
|
122
|
+
user_id_str = user.to_s.strip
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, 'ts cannot be empty' if ts_str.empty?
|
|
125
|
+
raise ArgumentError, 'user_id cannot be empty' if user_id_str.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
129
|
+
def freeze_attributes(attrs)
|
|
130
|
+
{
|
|
131
|
+
ts: attrs[:ts].to_s.strip.freeze,
|
|
132
|
+
user_id: attrs[:user_id].to_s.strip.freeze,
|
|
133
|
+
text: attrs[:text].to_s.freeze,
|
|
134
|
+
reactions: attrs[:reactions].freeze,
|
|
135
|
+
reply_count: attrs[:reply_count].to_i,
|
|
136
|
+
thread_ts: attrs[:thread_ts]&.freeze,
|
|
137
|
+
files: deep_freeze(attrs[:files]),
|
|
138
|
+
attachments: deep_freeze(attrs[:attachments]),
|
|
139
|
+
blocks: deep_freeze(attrs[:blocks]),
|
|
140
|
+
user_profile: deep_freeze(attrs[:user_profile]),
|
|
141
|
+
bot_profile: deep_freeze(attrs[:bot_profile]),
|
|
142
|
+
username: attrs[:username]&.freeze,
|
|
143
|
+
subtype: attrs[:subtype]&.freeze,
|
|
144
|
+
channel_id: attrs[:channel_id]&.freeze
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
148
|
+
|
|
149
|
+
# Recursively freeze nested structures (arrays and hashes)
|
|
150
|
+
def self.deep_freeze(obj)
|
|
151
|
+
case obj
|
|
152
|
+
when Hash then freeze_hash(obj)
|
|
153
|
+
when Array then freeze_array(obj)
|
|
154
|
+
else obj.freeze if obj.respond_to?(:freeze)
|
|
155
|
+
end
|
|
156
|
+
obj
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.freeze_hash(hash)
|
|
160
|
+
hash.each_value { |v| deep_freeze(v) }
|
|
161
|
+
hash.freeze
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.freeze_array(array)
|
|
165
|
+
array.each { |v| deep_freeze(v) }
|
|
166
|
+
array.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private_class_method :deep_freeze, :freeze_hash, :freeze_array
|
|
170
|
+
|
|
171
|
+
# Instance method delegate to class method for use in initialize
|
|
172
|
+
def deep_freeze(obj)
|
|
173
|
+
self.class.send(:deep_freeze, obj)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def timestamp
|
|
177
|
+
Time.at(ts.to_f)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def thread?
|
|
181
|
+
reply_count.positive?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reply?
|
|
185
|
+
thread_ts && thread_ts != ts
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def reactions?
|
|
189
|
+
!reactions.empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def files?
|
|
193
|
+
!files.empty?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def blocks?
|
|
197
|
+
!blocks.empty?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def embedded_username
|
|
201
|
+
username_from_user_profile || username_from_bot_profile || fallback_username
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def username_from_user_profile
|
|
205
|
+
return nil unless user_profile
|
|
206
|
+
|
|
207
|
+
display = user_profile['display_name']
|
|
208
|
+
return display unless display.to_s.empty?
|
|
209
|
+
|
|
210
|
+
real = user_profile['real_name']
|
|
211
|
+
return real unless real.to_s.empty?
|
|
212
|
+
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def username_from_bot_profile
|
|
217
|
+
return nil unless bot_profile
|
|
218
|
+
|
|
219
|
+
name = bot_profile['name']
|
|
220
|
+
name.to_s.empty? ? nil : name
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def fallback_username
|
|
224
|
+
username.to_s.empty? ? nil : username
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def bot?
|
|
228
|
+
user_id.start_with?('B') || subtype == 'bot_message'
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def system_message?
|
|
232
|
+
%w[channel_join channel_leave channel_topic channel_purpose].include?(subtype)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Create a copy of this message with updated reactions
|
|
236
|
+
def with_reactions(new_reactions)
|
|
237
|
+
Message.new(**to_h, reactions: new_reactions)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
# rubocop:enable Metrics/ModuleLength
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
Preset = Data.define(:name, :text, :emoji, :duration, :presence, :dnd) do
|
|
6
|
+
def self.from_hash(name, data)
|
|
7
|
+
new(
|
|
8
|
+
name: name,
|
|
9
|
+
text: data['text'] || '',
|
|
10
|
+
emoji: data['emoji'] || '',
|
|
11
|
+
duration: data['duration'] || '0',
|
|
12
|
+
presence: data['presence'] || '',
|
|
13
|
+
dnd: data['dnd'] || ''
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# rubocop:disable Metrics/ParameterLists
|
|
18
|
+
def initialize(name:, text: '', emoji: '', duration: '0', presence: '', dnd: '')
|
|
19
|
+
validate_name!(name)
|
|
20
|
+
validate_duration!(duration)
|
|
21
|
+
|
|
22
|
+
super(
|
|
23
|
+
name: name.to_s.strip.freeze,
|
|
24
|
+
text: text.to_s.freeze, emoji: emoji.to_s.freeze, duration: duration.to_s.freeze,
|
|
25
|
+
presence: presence.to_s.freeze, dnd: dnd.to_s.freeze
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
# rubocop:enable Metrics/ParameterLists
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ 'text' => text, 'emoji' => emoji, 'duration' => duration, 'presence' => presence, 'dnd' => dnd }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def duration_value
|
|
35
|
+
Duration.parse(duration)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sets_presence?
|
|
39
|
+
!presence.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sets_dnd?
|
|
43
|
+
!dnd.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clears_status?
|
|
47
|
+
text.empty? && emoji.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
"#{name}: #{build_parts.join(' ')}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_name!(name)
|
|
57
|
+
raise ArgumentError, 'preset name cannot be empty' if name.to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_duration!(duration)
|
|
61
|
+
duration_str = duration.to_s
|
|
62
|
+
Duration.parse(duration_str) unless duration_str.empty? || duration_str == '0'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# rubocop:disable Metrics/AbcSize
|
|
66
|
+
def build_parts
|
|
67
|
+
parts = []
|
|
68
|
+
parts << emoji unless emoji.empty?
|
|
69
|
+
parts << "\"#{text}\"" unless text.empty?
|
|
70
|
+
parts << "(#{duration})" unless duration == '0' || duration.empty?
|
|
71
|
+
parts << "[#{presence}]" if sets_presence?
|
|
72
|
+
parts << "{dnd: #{dnd}}" if sets_dnd?
|
|
73
|
+
parts
|
|
74
|
+
end
|
|
75
|
+
# rubocop:enable Metrics/AbcSize
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Models
|
|
5
5
|
Reaction = Data.define(:name, :count, :users, :timestamps) do
|
|
6
6
|
def self.from_api(data)
|
|
7
7
|
new(
|
|
8
|
-
name: data[
|
|
9
|
-
count: data[
|
|
10
|
-
users: data[
|
|
11
|
-
timestamps: nil
|
|
8
|
+
name: data['name'],
|
|
9
|
+
count: data['count'] || 0,
|
|
10
|
+
users: data['users'] || [],
|
|
11
|
+
timestamps: nil # Will be populated by ReactionEnricher
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -34,7 +34,7 @@ module SlackCli
|
|
|
34
34
|
)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def timestamps?
|
|
38
38
|
!timestamps.nil? && !timestamps.empty?
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Models
|
|
5
5
|
Status = Data.define(:text, :emoji, :expiration) do
|
|
6
|
-
def initialize(text:
|
|
6
|
+
def initialize(text: '', emoji: '', expiration: 0)
|
|
7
7
|
exp_val = expiration.to_i
|
|
8
8
|
exp_val = 0 if exp_val.negative? # Normalize invalid negative expirations
|
|
9
9
|
|
|
@@ -19,7 +19,7 @@ module SlackCli
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def expires?
|
|
22
|
-
expiration
|
|
22
|
+
expiration.positive?
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def expired?
|
|
@@ -30,7 +30,7 @@ module SlackCli
|
|
|
30
30
|
return nil unless expires?
|
|
31
31
|
|
|
32
32
|
remaining = expiration - Time.now.to_i
|
|
33
|
-
remaining
|
|
33
|
+
remaining.positive? ? Duration.new(seconds: remaining) : nil
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def expiration_time
|
|
@@ -40,7 +40,7 @@ module SlackCli
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def to_s
|
|
43
|
-
return
|
|
43
|
+
return '(no status)' if empty?
|
|
44
44
|
|
|
45
45
|
parts = []
|
|
46
46
|
parts << emoji unless emoji.empty?
|
|
@@ -50,7 +50,7 @@ module SlackCli
|
|
|
50
50
|
parts << "(#{remaining})"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
parts.join(
|
|
53
|
+
parts.join(' ')
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Slack user IDs start with U or W (enterprise grid)
|
|
6
|
+
USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
|
|
7
|
+
|
|
8
|
+
User = Data.define(:id, :name, :real_name, :display_name, :is_bot) do
|
|
9
|
+
def self.from_api(data)
|
|
10
|
+
profile = data['profile'] || {}
|
|
11
|
+
|
|
12
|
+
new(
|
|
13
|
+
id: data['id'],
|
|
14
|
+
name: data['name'],
|
|
15
|
+
real_name: profile['real_name'] || data['real_name'],
|
|
16
|
+
display_name: profile['display_name'] || profile['display_name_normalized'],
|
|
17
|
+
is_bot: data['is_bot'] || false
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(id:, name: nil, real_name: nil, display_name: nil, is_bot: false)
|
|
22
|
+
id_str = id.to_s.strip
|
|
23
|
+
validate_id!(id_str)
|
|
24
|
+
|
|
25
|
+
super(
|
|
26
|
+
id: id_str.freeze, name: name&.freeze, real_name: real_name&.freeze,
|
|
27
|
+
display_name: display_name&.freeze, is_bot: is_bot
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_id!(id_str)
|
|
32
|
+
raise ArgumentError, 'user id cannot be empty' if id_str.empty?
|
|
33
|
+
return if id_str.match?(USER_ID_PATTERN)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "invalid user id format: #{id_str} (expected U or W prefix)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def best_name
|
|
39
|
+
return display_name unless display_name.to_s.empty?
|
|
40
|
+
return real_name unless real_name.to_s.empty?
|
|
41
|
+
return name unless name.to_s.empty?
|
|
42
|
+
|
|
43
|
+
id
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mention
|
|
47
|
+
"@#{best_name}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
best_name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Valid token prefixes for Slack tokens
|
|
6
|
+
VALID_TOKEN_PREFIXES = %w[xoxb- xoxc- xoxp-].freeze
|
|
7
|
+
|
|
8
|
+
Workspace = Data.define(:name, :token, :cookie) do
|
|
9
|
+
def initialize(name:, token:, cookie: nil)
|
|
10
|
+
name_str = name.to_s.strip
|
|
11
|
+
token_str = token.to_s
|
|
12
|
+
cookie_str = cookie&.to_s
|
|
13
|
+
|
|
14
|
+
validate_name!(name_str)
|
|
15
|
+
validate_token!(token_str)
|
|
16
|
+
validate_cookie!(token_str, cookie_str)
|
|
17
|
+
|
|
18
|
+
super(name: name_str.freeze, token: token_str.freeze, cookie: cookie_str&.freeze)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def xoxc? = token.start_with?('xoxc-')
|
|
22
|
+
def xoxb? = token.start_with?('xoxb-')
|
|
23
|
+
def xoxp? = token.start_with?('xoxp-')
|
|
24
|
+
|
|
25
|
+
def to_s = name
|
|
26
|
+
|
|
27
|
+
def headers
|
|
28
|
+
h = { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json; charset=utf-8' }
|
|
29
|
+
h['Cookie'] = "d=#{cookie}" if cookie
|
|
30
|
+
h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_name!(name_str)
|
|
36
|
+
raise ArgumentError, 'workspace name cannot be empty' if name_str.empty?
|
|
37
|
+
raise ArgumentError, 'workspace name contains invalid characters' if name_str.match?(%r{[/\\]})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_token!(token_str)
|
|
41
|
+
return if VALID_TOKEN_PREFIXES.any? { |prefix| token_str.start_with?(prefix) }
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, 'invalid token format (must start with xoxb-, xoxc-, or xoxp-)'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_cookie!(token_str, cookie_str)
|
|
47
|
+
if token_str.start_with?('xoxc-') && (cookie_str.nil? || cookie_str.strip.empty?)
|
|
48
|
+
raise ArgumentError, 'xoxc tokens require a cookie'
|
|
49
|
+
end
|
|
50
|
+
raise ArgumentError, 'cookie cannot contain newlines' if cookie_str&.match?(/[\r\n]/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
|
+
# Dependency injection container providing services to commands
|
|
4
5
|
class Runner
|
|
5
6
|
attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
|
|
6
7
|
|
|
8
|
+
# rubocop:disable Metrics/ParameterLists
|
|
7
9
|
def initialize(
|
|
8
10
|
output: nil,
|
|
9
11
|
config: nil,
|
|
@@ -22,11 +24,12 @@ module SlackCli
|
|
|
22
24
|
# Wire up warning callbacks to show warnings to users
|
|
23
25
|
wire_up_warnings
|
|
24
26
|
end
|
|
27
|
+
# rubocop:enable Metrics/ParameterLists
|
|
25
28
|
|
|
26
29
|
# Workspace helpers
|
|
27
30
|
def workspace(name = nil)
|
|
28
31
|
name ||= @config.primary_workspace
|
|
29
|
-
raise ConfigError,
|
|
32
|
+
raise ConfigError, 'No workspace specified and no primary workspace configured' unless name
|
|
30
33
|
|
|
31
34
|
@token_store.workspace(name)
|
|
32
35
|
end
|
|
@@ -39,41 +42,41 @@ module SlackCli
|
|
|
39
42
|
@token_store.workspace_names
|
|
40
43
|
end
|
|
41
44
|
|
|
42
|
-
def
|
|
45
|
+
def workspaces?
|
|
43
46
|
!@token_store.empty?
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
# API helpers - create API instances bound to workspace
|
|
47
|
-
def users_api(
|
|
48
|
-
Api::Users.new(@api_client, workspace(
|
|
50
|
+
def users_api(workspace_name = nil)
|
|
51
|
+
Api::Users.new(@api_client, workspace(workspace_name), on_debug: ->(msg) { @output.debug(msg) })
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
def conversations_api(
|
|
52
|
-
Api::Conversations.new(@api_client, workspace(
|
|
54
|
+
def conversations_api(workspace_name = nil)
|
|
55
|
+
Api::Conversations.new(@api_client, workspace(workspace_name))
|
|
53
56
|
end
|
|
54
57
|
|
|
55
|
-
def dnd_api(
|
|
56
|
-
Api::Dnd.new(@api_client, workspace(
|
|
58
|
+
def dnd_api(workspace_name = nil)
|
|
59
|
+
Api::Dnd.new(@api_client, workspace(workspace_name))
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
def client_api(
|
|
60
|
-
Api::Client.new(@api_client, workspace(
|
|
62
|
+
def client_api(workspace_name = nil)
|
|
63
|
+
Api::Client.new(@api_client, workspace(workspace_name))
|
|
61
64
|
end
|
|
62
65
|
|
|
63
|
-
def emoji_api(
|
|
64
|
-
Api::Emoji.new(@api_client, workspace(
|
|
66
|
+
def emoji_api(workspace_name = nil)
|
|
67
|
+
Api::Emoji.new(@api_client, workspace(workspace_name))
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
def bots_api(
|
|
68
|
-
Api::Bots.new(@api_client, workspace(
|
|
70
|
+
def bots_api(workspace_name = nil)
|
|
71
|
+
Api::Bots.new(@api_client, workspace(workspace_name), on_debug: ->(msg) { @output.debug(msg) })
|
|
69
72
|
end
|
|
70
73
|
|
|
71
|
-
def threads_api(
|
|
72
|
-
Api::Threads.new(@api_client, workspace(
|
|
74
|
+
def threads_api(workspace_name = nil)
|
|
75
|
+
Api::Threads.new(@api_client, workspace(workspace_name))
|
|
73
76
|
end
|
|
74
77
|
|
|
75
|
-
def activity_api(
|
|
76
|
-
Api::Activity.new(@api_client, workspace(
|
|
78
|
+
def activity_api(workspace_name = nil)
|
|
79
|
+
Api::Activity.new(@api_client, workspace(workspace_name))
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# Formatter helpers
|