slk 0.1.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 +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/bin/slk +7 -0
- data/lib/slack_cli/api/activity.rb +28 -0
- data/lib/slack_cli/api/bots.rb +32 -0
- data/lib/slack_cli/api/client.rb +49 -0
- data/lib/slack_cli/api/conversations.rb +52 -0
- data/lib/slack_cli/api/dnd.rb +40 -0
- data/lib/slack_cli/api/emoji.rb +21 -0
- data/lib/slack_cli/api/threads.rb +44 -0
- data/lib/slack_cli/api/usergroups.rb +25 -0
- data/lib/slack_cli/api/users.rb +101 -0
- data/lib/slack_cli/cli.rb +118 -0
- data/lib/slack_cli/commands/activity.rb +292 -0
- data/lib/slack_cli/commands/base.rb +175 -0
- data/lib/slack_cli/commands/cache.rb +116 -0
- data/lib/slack_cli/commands/catchup.rb +484 -0
- data/lib/slack_cli/commands/config.rb +159 -0
- data/lib/slack_cli/commands/dnd.rb +143 -0
- data/lib/slack_cli/commands/emoji.rb +412 -0
- data/lib/slack_cli/commands/help.rb +76 -0
- data/lib/slack_cli/commands/messages.rb +317 -0
- data/lib/slack_cli/commands/presence.rb +107 -0
- data/lib/slack_cli/commands/preset.rb +239 -0
- data/lib/slack_cli/commands/status.rb +194 -0
- data/lib/slack_cli/commands/thread.rb +62 -0
- data/lib/slack_cli/commands/unread.rb +312 -0
- data/lib/slack_cli/commands/workspaces.rb +151 -0
- data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
- data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
- data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
- data/lib/slack_cli/formatters/message_formatter.rb +429 -0
- data/lib/slack_cli/formatters/output.rb +89 -0
- data/lib/slack_cli/models/channel.rb +52 -0
- data/lib/slack_cli/models/duration.rb +85 -0
- data/lib/slack_cli/models/message.rb +217 -0
- data/lib/slack_cli/models/preset.rb +73 -0
- data/lib/slack_cli/models/reaction.rb +54 -0
- data/lib/slack_cli/models/status.rb +57 -0
- data/lib/slack_cli/models/user.rb +56 -0
- data/lib/slack_cli/models/workspace.rb +52 -0
- data/lib/slack_cli/runner.rb +123 -0
- data/lib/slack_cli/services/api_client.rb +149 -0
- data/lib/slack_cli/services/cache_store.rb +198 -0
- data/lib/slack_cli/services/configuration.rb +74 -0
- data/lib/slack_cli/services/encryption.rb +51 -0
- data/lib/slack_cli/services/preset_store.rb +112 -0
- data/lib/slack_cli/services/reaction_enricher.rb +87 -0
- data/lib/slack_cli/services/token_store.rb +117 -0
- data/lib/slack_cli/support/error_logger.rb +28 -0
- data/lib/slack_cli/support/help_formatter.rb +139 -0
- data/lib/slack_cli/support/inline_images.rb +62 -0
- data/lib/slack_cli/support/slack_url_parser.rb +78 -0
- data/lib/slack_cli/support/user_resolver.rb +114 -0
- data/lib/slack_cli/support/xdg_paths.rb +37 -0
- data/lib/slack_cli/version.rb +5 -0
- data/lib/slack_cli.rb +91 -0
- metadata +103 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Message = Data.define(
|
|
6
|
+
:ts,
|
|
7
|
+
:user_id,
|
|
8
|
+
:text,
|
|
9
|
+
:reactions,
|
|
10
|
+
:reply_count,
|
|
11
|
+
:thread_ts,
|
|
12
|
+
:files,
|
|
13
|
+
:attachments,
|
|
14
|
+
:blocks,
|
|
15
|
+
:user_profile,
|
|
16
|
+
:bot_profile,
|
|
17
|
+
:username,
|
|
18
|
+
:subtype,
|
|
19
|
+
:channel_id
|
|
20
|
+
) do
|
|
21
|
+
# Minimum text length before we extract content from Block Kit blocks.
|
|
22
|
+
# Slack sometimes sends minimal text (like a link preview) with the full
|
|
23
|
+
# content in blocks. 20 chars catches most of these cases without
|
|
24
|
+
# unnecessarily processing blocks for normal messages.
|
|
25
|
+
BLOCK_TEXT_THRESHOLD = 20
|
|
26
|
+
|
|
27
|
+
def self.from_api(data, channel_id: nil)
|
|
28
|
+
text = data["text"] || ""
|
|
29
|
+
blocks = data["blocks"] || []
|
|
30
|
+
|
|
31
|
+
# Extract text from Block Kit blocks if text is empty or minimal
|
|
32
|
+
if text.length < BLOCK_TEXT_THRESHOLD
|
|
33
|
+
blocks_text = extract_block_text(blocks)
|
|
34
|
+
text = blocks_text unless blocks_text.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
new(
|
|
38
|
+
ts: data["ts"],
|
|
39
|
+
user_id: data["user"] || data["bot_id"] || data["username"],
|
|
40
|
+
text: text,
|
|
41
|
+
reactions: (data["reactions"] || []).map { |r| Reaction.from_api(r) },
|
|
42
|
+
reply_count: data["reply_count"] || 0,
|
|
43
|
+
thread_ts: data["thread_ts"],
|
|
44
|
+
files: data["files"] || [],
|
|
45
|
+
attachments: data["attachments"] || [],
|
|
46
|
+
blocks: blocks,
|
|
47
|
+
user_profile: data["user_profile"],
|
|
48
|
+
bot_profile: data["bot_profile"],
|
|
49
|
+
username: data["username"],
|
|
50
|
+
subtype: data["subtype"],
|
|
51
|
+
channel_id: channel_id
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.extract_block_text(blocks)
|
|
56
|
+
return "" unless blocks.is_a?(Array)
|
|
57
|
+
|
|
58
|
+
blocks.filter_map do |block|
|
|
59
|
+
case block["type"]
|
|
60
|
+
when "section"
|
|
61
|
+
block.dig("text", "text")
|
|
62
|
+
when "rich_text"
|
|
63
|
+
extract_rich_text_content(block["elements"])
|
|
64
|
+
end
|
|
65
|
+
end.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.extract_rich_text_content(elements)
|
|
69
|
+
return "" unless elements.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
elements.filter_map do |element|
|
|
72
|
+
next unless element["elements"].is_a?(Array)
|
|
73
|
+
|
|
74
|
+
element["elements"].filter_map do |item|
|
|
75
|
+
item["text"] if item["type"] == "text"
|
|
76
|
+
end.join
|
|
77
|
+
end.join
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(
|
|
81
|
+
ts:,
|
|
82
|
+
user_id:,
|
|
83
|
+
text: "",
|
|
84
|
+
reactions: [],
|
|
85
|
+
reply_count: 0,
|
|
86
|
+
thread_ts: nil,
|
|
87
|
+
files: [],
|
|
88
|
+
attachments: [],
|
|
89
|
+
blocks: [],
|
|
90
|
+
user_profile: nil,
|
|
91
|
+
bot_profile: nil,
|
|
92
|
+
username: nil,
|
|
93
|
+
subtype: nil,
|
|
94
|
+
channel_id: nil
|
|
95
|
+
)
|
|
96
|
+
ts_str = ts.to_s.strip
|
|
97
|
+
user_id_str = user_id.to_s.strip
|
|
98
|
+
|
|
99
|
+
raise ArgumentError, "ts cannot be empty" if ts_str.empty?
|
|
100
|
+
raise ArgumentError, "user_id cannot be empty" if user_id_str.empty?
|
|
101
|
+
|
|
102
|
+
super(
|
|
103
|
+
ts: ts_str.freeze,
|
|
104
|
+
user_id: user_id_str.freeze,
|
|
105
|
+
text: text.to_s.freeze,
|
|
106
|
+
reactions: reactions.freeze,
|
|
107
|
+
reply_count: reply_count.to_i,
|
|
108
|
+
thread_ts: thread_ts&.freeze,
|
|
109
|
+
files: deep_freeze(files),
|
|
110
|
+
attachments: deep_freeze(attachments),
|
|
111
|
+
blocks: deep_freeze(blocks),
|
|
112
|
+
user_profile: deep_freeze(user_profile),
|
|
113
|
+
bot_profile: deep_freeze(bot_profile),
|
|
114
|
+
username: username&.freeze,
|
|
115
|
+
subtype: subtype&.freeze,
|
|
116
|
+
channel_id: channel_id&.freeze
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Recursively freeze nested structures (arrays and hashes)
|
|
121
|
+
def self.deep_freeze(obj)
|
|
122
|
+
case obj
|
|
123
|
+
when Hash
|
|
124
|
+
obj.each_value { |v| deep_freeze(v) }
|
|
125
|
+
obj.freeze
|
|
126
|
+
when Array
|
|
127
|
+
obj.each { |v| deep_freeze(v) }
|
|
128
|
+
obj.freeze
|
|
129
|
+
else
|
|
130
|
+
obj.freeze if obj.respond_to?(:freeze)
|
|
131
|
+
end
|
|
132
|
+
obj
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private_class_method :deep_freeze
|
|
136
|
+
|
|
137
|
+
# Instance method delegate to class method for use in initialize
|
|
138
|
+
def deep_freeze(obj)
|
|
139
|
+
self.class.send(:deep_freeze, obj)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def timestamp
|
|
143
|
+
Time.at(ts.to_f)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def has_thread?
|
|
147
|
+
reply_count > 0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def is_reply?
|
|
151
|
+
thread_ts && thread_ts != ts
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def has_reactions?
|
|
155
|
+
!reactions.empty?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def has_files?
|
|
159
|
+
!files.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def has_blocks?
|
|
163
|
+
!blocks.empty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def embedded_username
|
|
167
|
+
# Try user_profile first (regular users)
|
|
168
|
+
if user_profile
|
|
169
|
+
display = user_profile["display_name"]
|
|
170
|
+
real = user_profile["real_name"]
|
|
171
|
+
|
|
172
|
+
return display unless display.to_s.empty?
|
|
173
|
+
return real unless real.to_s.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Try bot_profile (bot messages)
|
|
177
|
+
if bot_profile
|
|
178
|
+
name = bot_profile["name"]
|
|
179
|
+
return name unless name.to_s.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Fall back to username field (some bots/integrations)
|
|
183
|
+
return username unless username.to_s.empty?
|
|
184
|
+
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def bot?
|
|
189
|
+
user_id.start_with?("B") || subtype == "bot_message"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def system_message?
|
|
193
|
+
%w[channel_join channel_leave channel_topic channel_purpose].include?(subtype)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Create a copy of this message with updated reactions
|
|
197
|
+
def with_reactions(new_reactions)
|
|
198
|
+
Message.new(
|
|
199
|
+
ts: ts,
|
|
200
|
+
user_id: user_id,
|
|
201
|
+
text: text,
|
|
202
|
+
reactions: new_reactions,
|
|
203
|
+
reply_count: reply_count,
|
|
204
|
+
thread_ts: thread_ts,
|
|
205
|
+
files: files,
|
|
206
|
+
attachments: attachments,
|
|
207
|
+
blocks: blocks,
|
|
208
|
+
user_profile: user_profile,
|
|
209
|
+
bot_profile: bot_profile,
|
|
210
|
+
username: username,
|
|
211
|
+
subtype: subtype,
|
|
212
|
+
channel_id: channel_id
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
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
|
+
def initialize(name:, text: "", emoji: "", duration: "0", presence: "", dnd: "")
|
|
18
|
+
name_str = name.to_s.strip
|
|
19
|
+
raise ArgumentError, "preset name cannot be empty" if name_str.empty?
|
|
20
|
+
|
|
21
|
+
duration_str = duration.to_s
|
|
22
|
+
# Validate duration at construction time (will raise ArgumentError if invalid)
|
|
23
|
+
Duration.parse(duration_str) unless duration_str.empty? || duration_str == "0"
|
|
24
|
+
|
|
25
|
+
super(
|
|
26
|
+
name: name_str.freeze,
|
|
27
|
+
text: text.to_s.freeze,
|
|
28
|
+
emoji: emoji.to_s.freeze,
|
|
29
|
+
duration: duration_str.freeze,
|
|
30
|
+
presence: presence.to_s.freeze,
|
|
31
|
+
dnd: dnd.to_s.freeze
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
"text" => text,
|
|
38
|
+
"emoji" => emoji,
|
|
39
|
+
"duration" => duration,
|
|
40
|
+
"presence" => presence,
|
|
41
|
+
"dnd" => dnd
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def duration_value
|
|
46
|
+
Duration.parse(duration)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sets_presence?
|
|
50
|
+
!presence.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sets_dnd?
|
|
54
|
+
!dnd.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def clears_status?
|
|
58
|
+
text.empty? && emoji.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
parts = []
|
|
63
|
+
parts << emoji unless emoji.empty?
|
|
64
|
+
parts << "\"#{text}\"" unless text.empty?
|
|
65
|
+
parts << "(#{duration})" unless duration == "0" || duration.empty?
|
|
66
|
+
parts << "[#{presence}]" if sets_presence?
|
|
67
|
+
parts << "{dnd: #{dnd}}" if sets_dnd?
|
|
68
|
+
|
|
69
|
+
"#{name}: #{parts.join(" ")}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Reaction = Data.define(:name, :count, :users, :timestamps) do
|
|
6
|
+
def self.from_api(data)
|
|
7
|
+
new(
|
|
8
|
+
name: data["name"],
|
|
9
|
+
count: data["count"] || 0,
|
|
10
|
+
users: data["users"] || [],
|
|
11
|
+
timestamps: nil # Will be populated by ReactionEnricher
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name:, count: 0, users: [], timestamps: nil)
|
|
16
|
+
count_val = count.to_i
|
|
17
|
+
count_val = 0 if count_val.negative? # Normalize invalid negative counts
|
|
18
|
+
|
|
19
|
+
super(
|
|
20
|
+
name: name.to_s.freeze,
|
|
21
|
+
count: count_val,
|
|
22
|
+
users: users.freeze,
|
|
23
|
+
timestamps: timestamps&.freeze
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create a new Reaction with timestamps added
|
|
28
|
+
def with_timestamps(timestamp_map)
|
|
29
|
+
Reaction.new(
|
|
30
|
+
name: name,
|
|
31
|
+
count: count,
|
|
32
|
+
users: users,
|
|
33
|
+
timestamps: timestamp_map
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def has_timestamps?
|
|
38
|
+
!timestamps.nil? && !timestamps.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def timestamp_for(user_id)
|
|
42
|
+
timestamps&.dig(user_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def emoji_code
|
|
46
|
+
":#{name}:"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_s
|
|
50
|
+
"#{count} #{emoji_code}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Status = Data.define(:text, :emoji, :expiration) do
|
|
6
|
+
def initialize(text: "", emoji: "", expiration: 0)
|
|
7
|
+
exp_val = expiration.to_i
|
|
8
|
+
exp_val = 0 if exp_val.negative? # Normalize invalid negative expirations
|
|
9
|
+
|
|
10
|
+
super(
|
|
11
|
+
text: text.to_s.freeze,
|
|
12
|
+
emoji: emoji.to_s.freeze,
|
|
13
|
+
expiration: exp_val
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
text.empty? && emoji.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def expires?
|
|
22
|
+
expiration > 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expired?
|
|
26
|
+
expires? && expiration < Time.now.to_i
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def time_remaining
|
|
30
|
+
return nil unless expires?
|
|
31
|
+
|
|
32
|
+
remaining = expiration - Time.now.to_i
|
|
33
|
+
remaining > 0 ? Duration.new(seconds: remaining) : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def expiration_time
|
|
37
|
+
return nil unless expires?
|
|
38
|
+
|
|
39
|
+
Time.at(expiration)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
return "(no status)" if empty?
|
|
44
|
+
|
|
45
|
+
parts = []
|
|
46
|
+
parts << emoji unless emoji.empty?
|
|
47
|
+
parts << text unless text.empty?
|
|
48
|
+
|
|
49
|
+
if (remaining = time_remaining)
|
|
50
|
+
parts << "(#{remaining})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
parts.join(" ")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
User = Data.define(:id, :name, :real_name, :display_name, :is_bot) do
|
|
6
|
+
def self.from_api(data)
|
|
7
|
+
profile = data["profile"] || {}
|
|
8
|
+
|
|
9
|
+
new(
|
|
10
|
+
id: data["id"],
|
|
11
|
+
name: data["name"],
|
|
12
|
+
real_name: profile["real_name"] || data["real_name"],
|
|
13
|
+
display_name: profile["display_name"] || profile["display_name_normalized"],
|
|
14
|
+
is_bot: data["is_bot"] || false
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Slack user IDs start with U or W (enterprise grid)
|
|
19
|
+
USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
|
|
20
|
+
|
|
21
|
+
def initialize(id:, name: nil, real_name: nil, display_name: nil, is_bot: false)
|
|
22
|
+
id_str = id.to_s.strip
|
|
23
|
+
raise ArgumentError, "user id cannot be empty" if id_str.empty?
|
|
24
|
+
|
|
25
|
+
# Validate user ID format (starts with U or W followed by alphanumeric)
|
|
26
|
+
unless id_str.match?(USER_ID_PATTERN)
|
|
27
|
+
raise ArgumentError, "invalid user id format: #{id_str} (expected U or W prefix)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
super(
|
|
31
|
+
id: id_str.freeze,
|
|
32
|
+
name: name&.freeze,
|
|
33
|
+
real_name: real_name&.freeze,
|
|
34
|
+
display_name: display_name&.freeze,
|
|
35
|
+
is_bot: is_bot
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def best_name
|
|
40
|
+
return display_name unless display_name.to_s.empty?
|
|
41
|
+
return real_name unless real_name.to_s.empty?
|
|
42
|
+
return name unless name.to_s.empty?
|
|
43
|
+
|
|
44
|
+
id
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mention
|
|
48
|
+
"@#{best_name}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
best_name
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Workspace = Data.define(:name, :token, :cookie) do
|
|
6
|
+
# Valid token prefixes for Slack tokens
|
|
7
|
+
VALID_TOKEN_PREFIXES = %w[xoxb- xoxc- xoxp-].freeze
|
|
8
|
+
|
|
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 is not empty and doesn't contain path separators
|
|
15
|
+
raise ArgumentError, "workspace name cannot be empty" if name_str.empty?
|
|
16
|
+
raise ArgumentError, "workspace name contains invalid characters" if name_str.match?(%r{[/\\]})
|
|
17
|
+
|
|
18
|
+
# Validate token format
|
|
19
|
+
unless VALID_TOKEN_PREFIXES.any? { |prefix| token_str.start_with?(prefix) }
|
|
20
|
+
raise ArgumentError, "invalid token format (must start with xoxb-, xoxc-, or xoxp-)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# xoxc tokens require a cookie
|
|
24
|
+
if token_str.start_with?("xoxc-") && (cookie_str.nil? || cookie_str.strip.empty?)
|
|
25
|
+
raise ArgumentError, "xoxc tokens require a cookie"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validate cookie doesn't contain newlines (HTTP header injection prevention)
|
|
29
|
+
if cookie_str && cookie_str.match?(/[\r\n]/)
|
|
30
|
+
raise ArgumentError, "cookie cannot contain newlines"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
super(name: name_str.freeze, token: token_str.freeze, cookie: cookie_str&.freeze)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def xoxc? = token.start_with?("xoxc-")
|
|
37
|
+
def xoxb? = token.start_with?("xoxb-")
|
|
38
|
+
def xoxp? = token.start_with?("xoxp-")
|
|
39
|
+
|
|
40
|
+
def to_s = name
|
|
41
|
+
|
|
42
|
+
def headers
|
|
43
|
+
h = {
|
|
44
|
+
"Authorization" => "Bearer #{token}",
|
|
45
|
+
"Content-Type" => "application/json; charset=utf-8"
|
|
46
|
+
}
|
|
47
|
+
h["Cookie"] = "d=#{cookie}" if cookie
|
|
48
|
+
h
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
class Runner
|
|
5
|
+
attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
output: nil,
|
|
9
|
+
config: nil,
|
|
10
|
+
token_store: nil,
|
|
11
|
+
api_client: nil,
|
|
12
|
+
cache_store: nil,
|
|
13
|
+
preset_store: nil
|
|
14
|
+
)
|
|
15
|
+
@output = output || Formatters::Output.new
|
|
16
|
+
@config = config || Services::Configuration.new
|
|
17
|
+
@token_store = token_store || Services::TokenStore.new(config: @config)
|
|
18
|
+
@api_client = api_client || Services::ApiClient.new
|
|
19
|
+
@cache_store = cache_store || Services::CacheStore.new
|
|
20
|
+
@preset_store = preset_store || Services::PresetStore.new
|
|
21
|
+
|
|
22
|
+
# Wire up warning callbacks to show warnings to users
|
|
23
|
+
wire_up_warnings
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Workspace helpers
|
|
27
|
+
def workspace(name = nil)
|
|
28
|
+
name ||= @config.primary_workspace
|
|
29
|
+
raise ConfigError, "No workspace specified and no primary workspace configured" unless name
|
|
30
|
+
|
|
31
|
+
@token_store.workspace(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all_workspaces
|
|
35
|
+
@token_store.all_workspaces
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def workspace_names
|
|
39
|
+
@token_store.workspace_names
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def has_workspaces?
|
|
43
|
+
!@token_store.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# API helpers - create API instances bound to workspace
|
|
47
|
+
def users_api(ws = nil)
|
|
48
|
+
Api::Users.new(@api_client, workspace(ws), on_debug: ->(msg) { @output.debug(msg) })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def conversations_api(ws = nil)
|
|
52
|
+
Api::Conversations.new(@api_client, workspace(ws))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dnd_api(ws = nil)
|
|
56
|
+
Api::Dnd.new(@api_client, workspace(ws))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def client_api(ws = nil)
|
|
60
|
+
Api::Client.new(@api_client, workspace(ws))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emoji_api(ws = nil)
|
|
64
|
+
Api::Emoji.new(@api_client, workspace(ws))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bots_api(ws = nil)
|
|
68
|
+
Api::Bots.new(@api_client, workspace(ws), on_debug: ->(msg) { @output.debug(msg) })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def threads_api(ws = nil)
|
|
72
|
+
Api::Threads.new(@api_client, workspace(ws))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def activity_api(ws = nil)
|
|
76
|
+
Api::Activity.new(@api_client, workspace(ws))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Formatter helpers
|
|
80
|
+
def message_formatter
|
|
81
|
+
@message_formatter ||= Formatters::MessageFormatter.new(
|
|
82
|
+
output: @output,
|
|
83
|
+
mention_replacer: mention_replacer,
|
|
84
|
+
emoji_replacer: emoji_replacer,
|
|
85
|
+
cache_store: @cache_store,
|
|
86
|
+
api_client: @api_client,
|
|
87
|
+
on_debug: ->(msg) { @output.debug(msg) }
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def mention_replacer
|
|
92
|
+
@mention_replacer ||= Formatters::MentionReplacer.new(
|
|
93
|
+
cache_store: @cache_store,
|
|
94
|
+
api_client: @api_client,
|
|
95
|
+
on_debug: ->(msg) { @output.debug(msg) }
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def emoji_replacer
|
|
100
|
+
@emoji_replacer ||= Formatters::EmojiReplacer.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def duration_formatter
|
|
104
|
+
@duration_formatter ||= Formatters::DurationFormatter.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Logging
|
|
108
|
+
def log_error(error)
|
|
109
|
+
Support::ErrorLogger.log(error)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def wire_up_warnings
|
|
115
|
+
warning_handler = ->(message) { @output.warn(message) }
|
|
116
|
+
|
|
117
|
+
@config.on_warning = warning_handler
|
|
118
|
+
@token_store.on_warning = warning_handler
|
|
119
|
+
@preset_store.on_warning = warning_handler
|
|
120
|
+
@cache_store.on_warning = warning_handler
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|