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
|
@@ -1,217 +0,0 @@
|
|
|
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
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SlackCli
|
|
4
|
-
module Services
|
|
5
|
-
class ApiClient
|
|
6
|
-
BASE_URL = ENV.fetch("SLACK_API_BASE", "https://slack.com/api")
|
|
7
|
-
|
|
8
|
-
# Network errors that should be wrapped in ApiError
|
|
9
|
-
NETWORK_ERRORS = [
|
|
10
|
-
SocketError,
|
|
11
|
-
Errno::ECONNREFUSED,
|
|
12
|
-
Errno::ECONNRESET,
|
|
13
|
-
Errno::ETIMEDOUT,
|
|
14
|
-
Errno::EHOSTUNREACH,
|
|
15
|
-
Net::OpenTimeout,
|
|
16
|
-
Net::ReadTimeout,
|
|
17
|
-
OpenSSL::SSL::SSLError
|
|
18
|
-
].freeze
|
|
19
|
-
|
|
20
|
-
attr_reader :call_count
|
|
21
|
-
attr_accessor :on_request
|
|
22
|
-
|
|
23
|
-
def initialize
|
|
24
|
-
@call_count = 0
|
|
25
|
-
@on_request = nil
|
|
26
|
-
@http_cache = {}
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Close all cached HTTP connections
|
|
30
|
-
def close
|
|
31
|
-
@http_cache.each_value do |http|
|
|
32
|
-
http.finish if http.started?
|
|
33
|
-
rescue IOError
|
|
34
|
-
# Connection already closed
|
|
35
|
-
end
|
|
36
|
-
@http_cache.clear
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def post(workspace, method, params = {})
|
|
40
|
-
log_request(method)
|
|
41
|
-
uri = URI("#{BASE_URL}/#{method}")
|
|
42
|
-
|
|
43
|
-
http = get_http(uri)
|
|
44
|
-
|
|
45
|
-
request = Net::HTTP::Post.new(uri)
|
|
46
|
-
workspace.headers.each { |k, v| request[k] = v }
|
|
47
|
-
request.body = JSON.generate(params) unless params.empty?
|
|
48
|
-
|
|
49
|
-
response = http.request(request)
|
|
50
|
-
handle_response(response, method)
|
|
51
|
-
rescue *NETWORK_ERRORS => e
|
|
52
|
-
raise ApiError, "Network error: #{e.message}"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def get(workspace, method, params = {})
|
|
56
|
-
log_request(method)
|
|
57
|
-
uri = URI("#{BASE_URL}/#{method}")
|
|
58
|
-
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
59
|
-
|
|
60
|
-
http = get_http(uri)
|
|
61
|
-
|
|
62
|
-
request = Net::HTTP::Get.new(uri)
|
|
63
|
-
request["Authorization"] = workspace.headers["Authorization"]
|
|
64
|
-
request["Cookie"] = workspace.headers["Cookie"] if workspace.headers["Cookie"]
|
|
65
|
-
|
|
66
|
-
response = http.request(request)
|
|
67
|
-
handle_response(response, method)
|
|
68
|
-
rescue *NETWORK_ERRORS => e
|
|
69
|
-
raise ApiError, "Network error: #{e.message}"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Form-encoded POST (some Slack endpoints require this)
|
|
73
|
-
def post_form(workspace, method, params = {})
|
|
74
|
-
log_request(method)
|
|
75
|
-
uri = URI("#{BASE_URL}/#{method}")
|
|
76
|
-
|
|
77
|
-
http = get_http(uri)
|
|
78
|
-
|
|
79
|
-
request = Net::HTTP::Post.new(uri)
|
|
80
|
-
request["Authorization"] = workspace.headers["Authorization"]
|
|
81
|
-
request["Cookie"] = workspace.headers["Cookie"] if workspace.headers["Cookie"]
|
|
82
|
-
request.set_form_data(params)
|
|
83
|
-
|
|
84
|
-
response = http.request(request)
|
|
85
|
-
handle_response(response, method)
|
|
86
|
-
rescue *NETWORK_ERRORS => e
|
|
87
|
-
raise ApiError, "Network error: #{e.message}"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
def log_request(method)
|
|
93
|
-
@call_count += 1
|
|
94
|
-
@on_request&.call(method, @call_count)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Get or create a persistent HTTP connection for the given URI
|
|
98
|
-
def get_http(uri)
|
|
99
|
-
key = "#{uri.host}:#{uri.port}"
|
|
100
|
-
cached = @http_cache[key]
|
|
101
|
-
|
|
102
|
-
# Return cached connection if it's still active
|
|
103
|
-
if cached && cached.started?
|
|
104
|
-
return cached
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Create new connection
|
|
108
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
109
|
-
configure_ssl(http, uri)
|
|
110
|
-
http.start
|
|
111
|
-
|
|
112
|
-
@http_cache[key] = http
|
|
113
|
-
http
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def configure_ssl(http, uri)
|
|
117
|
-
http.use_ssl = uri.scheme == "https"
|
|
118
|
-
http.open_timeout = 10
|
|
119
|
-
http.read_timeout = 30
|
|
120
|
-
http.keep_alive_timeout = 30
|
|
121
|
-
|
|
122
|
-
return unless http.use_ssl?
|
|
123
|
-
|
|
124
|
-
# Use system certificate store for SSL verification
|
|
125
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
126
|
-
http.cert_store = OpenSSL::X509::Store.new
|
|
127
|
-
http.cert_store.set_default_paths
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def handle_response(response, method)
|
|
131
|
-
case response
|
|
132
|
-
when Net::HTTPSuccess
|
|
133
|
-
result = JSON.parse(response.body)
|
|
134
|
-
raise ApiError, result["error"] || "Unknown error" unless result["ok"]
|
|
135
|
-
|
|
136
|
-
result
|
|
137
|
-
when Net::HTTPUnauthorized
|
|
138
|
-
raise ApiError, "Invalid token or session expired"
|
|
139
|
-
when Net::HTTPTooManyRequests
|
|
140
|
-
raise ApiError, "Rate limited - please wait and try again"
|
|
141
|
-
else
|
|
142
|
-
raise ApiError, "HTTP #{response.code}: #{response.message}"
|
|
143
|
-
end
|
|
144
|
-
rescue JSON::ParserError
|
|
145
|
-
raise ApiError, "Invalid JSON response from Slack API"
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SlackCli
|
|
4
|
-
module Services
|
|
5
|
-
class ReactionEnricher
|
|
6
|
-
def initialize(activity_api:)
|
|
7
|
-
@activity_api = activity_api
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
# Enriches messages with reaction timestamps
|
|
11
|
-
# Returns new array of messages with timestamps added to reactions
|
|
12
|
-
def enrich_messages(messages, channel_id)
|
|
13
|
-
return messages if messages.empty?
|
|
14
|
-
|
|
15
|
-
# Fetch reaction activity
|
|
16
|
-
activity_map = fetch_reaction_activity(channel_id, messages.map(&:ts))
|
|
17
|
-
|
|
18
|
-
# Enhance messages with timestamps
|
|
19
|
-
messages.map do |msg|
|
|
20
|
-
enhanced_reactions = enhance_reactions(msg, activity_map)
|
|
21
|
-
msg.with_reactions(enhanced_reactions)
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def fetch_reaction_activity(channel_id, message_timestamps)
|
|
28
|
-
# Fetch first page of recent reactions (max 50 per API limit)
|
|
29
|
-
# Note: This may not cover all historical reactions, but that's acceptable
|
|
30
|
-
# for performance reasons. Older reactions simply won't have timestamps.
|
|
31
|
-
response = @activity_api.feed(limit: 50, types: 'message_reaction')
|
|
32
|
-
return {} unless response['ok']
|
|
33
|
-
|
|
34
|
-
# Build map: "channel_id:message_ts:emoji:user" => timestamp
|
|
35
|
-
activity_map = {}
|
|
36
|
-
items = response['items'] || []
|
|
37
|
-
|
|
38
|
-
items.each do |item|
|
|
39
|
-
next unless item.dig('item', 'type') == 'message_reaction'
|
|
40
|
-
|
|
41
|
-
msg_data = item.dig('item', 'message')
|
|
42
|
-
reaction_data = item.dig('item', 'reaction')
|
|
43
|
-
next unless msg_data && reaction_data
|
|
44
|
-
|
|
45
|
-
# Only include reactions for messages we care about
|
|
46
|
-
msg_ts = msg_data['ts']
|
|
47
|
-
next unless message_timestamps.include?(msg_ts)
|
|
48
|
-
|
|
49
|
-
key = [
|
|
50
|
-
msg_data['channel'],
|
|
51
|
-
msg_ts,
|
|
52
|
-
reaction_data['name'],
|
|
53
|
-
reaction_data['user']
|
|
54
|
-
].join(':')
|
|
55
|
-
|
|
56
|
-
activity_map[key] = item['feed_ts']
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
activity_map
|
|
60
|
-
rescue SlackCli::ApiError
|
|
61
|
-
# If activity API fails, gracefully degrade - return empty map
|
|
62
|
-
# Messages will still be displayed, just without reaction timestamps
|
|
63
|
-
{}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def enhance_reactions(message, activity_map)
|
|
67
|
-
return message.reactions if message.reactions.empty?
|
|
68
|
-
|
|
69
|
-
message.reactions.map do |reaction|
|
|
70
|
-
timestamp_map = {}
|
|
71
|
-
|
|
72
|
-
reaction.users.each do |user_id|
|
|
73
|
-
key = [message.channel_id, message.ts, reaction.name, user_id].join(':')
|
|
74
|
-
timestamp_map[user_id] = activity_map[key] if activity_map[key]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Only create a new reaction with timestamps if we found any
|
|
78
|
-
if timestamp_map.empty?
|
|
79
|
-
reaction
|
|
80
|
-
else
|
|
81
|
-
reaction.with_timestamps(timestamp_map)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|