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,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class TokenStore
|
|
6
|
+
attr_accessor :on_warning
|
|
7
|
+
|
|
8
|
+
def initialize(config: nil, encryption: nil, paths: nil)
|
|
9
|
+
@config = config || Configuration.new
|
|
10
|
+
@encryption = encryption || Encryption.new
|
|
11
|
+
@paths = paths || Support::XdgPaths.new
|
|
12
|
+
@on_warning = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def workspace(name)
|
|
16
|
+
tokens = load_tokens
|
|
17
|
+
data = tokens[name]
|
|
18
|
+
raise WorkspaceNotFoundError, "Workspace '#{name}' not found" unless data
|
|
19
|
+
|
|
20
|
+
Models::Workspace.new(
|
|
21
|
+
name: name,
|
|
22
|
+
token: data["token"],
|
|
23
|
+
cookie: data["cookie"]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def all_workspaces
|
|
28
|
+
load_tokens.map do |name, data|
|
|
29
|
+
Models::Workspace.new(
|
|
30
|
+
name: name,
|
|
31
|
+
token: data["token"],
|
|
32
|
+
cookie: data["cookie"]
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def workspace_names
|
|
38
|
+
load_tokens.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def exists?(name)
|
|
42
|
+
load_tokens.key?(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add(name, token, cookie = nil)
|
|
46
|
+
# Validate by constructing a Workspace (will raise ArgumentError if invalid)
|
|
47
|
+
Models::Workspace.new(name: name, token: token, cookie: cookie)
|
|
48
|
+
|
|
49
|
+
tokens = load_tokens
|
|
50
|
+
tokens[name] = { "token" => token, "cookie" => cookie }.compact
|
|
51
|
+
save_tokens(tokens)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def remove(name)
|
|
55
|
+
tokens = load_tokens
|
|
56
|
+
removed = tokens.delete(name)
|
|
57
|
+
save_tokens(tokens) if removed
|
|
58
|
+
!removed.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def empty?
|
|
62
|
+
load_tokens.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def load_tokens
|
|
68
|
+
if encrypted_file_exists?
|
|
69
|
+
decrypt_tokens
|
|
70
|
+
elsif plain_file_exists?
|
|
71
|
+
JSON.parse(File.read(plain_tokens_file))
|
|
72
|
+
else
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
rescue JSON::ParserError => e
|
|
76
|
+
raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def save_tokens(tokens)
|
|
80
|
+
@paths.ensure_config_dir
|
|
81
|
+
|
|
82
|
+
if @config.ssh_key
|
|
83
|
+
# When encryption is configured, always use it - don't silently fall back
|
|
84
|
+
@encryption.encrypt(JSON.generate(tokens), @config.ssh_key, encrypted_tokens_file)
|
|
85
|
+
File.delete(plain_tokens_file) if File.exist?(plain_tokens_file)
|
|
86
|
+
else
|
|
87
|
+
# Plain text storage (no encryption configured)
|
|
88
|
+
File.write(plain_tokens_file, JSON.pretty_generate(tokens))
|
|
89
|
+
File.chmod(0o600, plain_tokens_file)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def decrypt_tokens
|
|
94
|
+
content = @encryption.decrypt(encrypted_tokens_file, @config.ssh_key)
|
|
95
|
+
content ? JSON.parse(content) : {}
|
|
96
|
+
rescue JSON::ParserError => e
|
|
97
|
+
raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def encrypted_file_exists?
|
|
101
|
+
File.exist?(encrypted_tokens_file)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def plain_file_exists?
|
|
105
|
+
File.exist?(plain_tokens_file)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def encrypted_tokens_file
|
|
109
|
+
@paths.config_file("tokens.age")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def plain_tokens_file
|
|
113
|
+
@paths.config_file("tokens.json")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
module ErrorLogger
|
|
6
|
+
# Log an error to the error log file
|
|
7
|
+
# @param error [Exception] The error to log
|
|
8
|
+
# @param paths [XdgPaths] Path helper (for testing)
|
|
9
|
+
# @return [String, nil] Path to the log file, or nil if logging failed
|
|
10
|
+
def self.log(error, paths: XdgPaths.new)
|
|
11
|
+
paths.ensure_cache_dir
|
|
12
|
+
|
|
13
|
+
log_file = paths.cache_file('error.log')
|
|
14
|
+
File.open(log_file, 'a') do |f|
|
|
15
|
+
f.puts "#{Time.now.iso8601} - #{error.class}: #{error.message}"
|
|
16
|
+
f.puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n") if error.backtrace
|
|
17
|
+
f.puts
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
log_file
|
|
21
|
+
rescue SystemCallError, IOError
|
|
22
|
+
# If we can't write to the log, fail silently rather than crashing
|
|
23
|
+
# The user will still see the error message in the console
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
# Formats help text with auto-aligned columns
|
|
6
|
+
#
|
|
7
|
+
# Example usage:
|
|
8
|
+
# help = HelpFormatter.new("slk status [text] [emoji] [options]")
|
|
9
|
+
# help.description("Get or set your Slack status.")
|
|
10
|
+
# help.note("GET shows all workspaces by default. SET applies to primary only.")
|
|
11
|
+
#
|
|
12
|
+
# help.section("EXAMPLES") do |s|
|
|
13
|
+
# s.example("slk status", "Show status (all workspaces)")
|
|
14
|
+
# s.example("slk status clear", "Clear status")
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# help.section("OPTIONS") do |s|
|
|
18
|
+
# s.option("-n, --limit N", "Messages per channel (default: 10)")
|
|
19
|
+
# s.option("--muted", "Include muted channels")
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# puts help.render
|
|
23
|
+
#
|
|
24
|
+
class HelpFormatter
|
|
25
|
+
def initialize(usage)
|
|
26
|
+
@usage = usage
|
|
27
|
+
@description = nil
|
|
28
|
+
@notes = []
|
|
29
|
+
@sections = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def description(text)
|
|
33
|
+
@description = text
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def note(text)
|
|
38
|
+
@notes << text
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def section(title, &block)
|
|
43
|
+
section = Section.new(title)
|
|
44
|
+
block.call(section)
|
|
45
|
+
@sections << section
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render
|
|
50
|
+
lines = []
|
|
51
|
+
lines << "USAGE: #{@usage}"
|
|
52
|
+
lines << ""
|
|
53
|
+
|
|
54
|
+
if @description
|
|
55
|
+
lines << @description
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@notes.each do |note|
|
|
59
|
+
lines << note
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
lines << "" if @description || @notes.any?
|
|
63
|
+
|
|
64
|
+
@sections.each do |section|
|
|
65
|
+
lines << "#{section.title}:"
|
|
66
|
+
lines.concat(section.render)
|
|
67
|
+
lines << ""
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Remove trailing blank line
|
|
71
|
+
lines.pop if lines.last == ""
|
|
72
|
+
|
|
73
|
+
lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Section
|
|
77
|
+
attr_reader :title
|
|
78
|
+
|
|
79
|
+
def initialize(title)
|
|
80
|
+
@title = title
|
|
81
|
+
@items = []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def option(flags, description)
|
|
85
|
+
@items << [:option, flags, description]
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def action(name, description)
|
|
90
|
+
@items << [:action, name, description]
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def example(command, description = nil)
|
|
95
|
+
@items << [:example, command, description]
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def item(left, right)
|
|
100
|
+
@items << [:item, left, right]
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def text(content)
|
|
105
|
+
@items << [:text, content, nil]
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def render
|
|
110
|
+
return [] if @items.empty?
|
|
111
|
+
|
|
112
|
+
# Calculate max width of left column
|
|
113
|
+
max_left = @items
|
|
114
|
+
.reject { |type, _, _| type == :text }
|
|
115
|
+
.map { |_, left, _| left.length }
|
|
116
|
+
.max || 0
|
|
117
|
+
|
|
118
|
+
# Add padding (2 spaces between columns)
|
|
119
|
+
padding = 2
|
|
120
|
+
|
|
121
|
+
@items.map do |type, left, right|
|
|
122
|
+
case type
|
|
123
|
+
when :text
|
|
124
|
+
" #{left}"
|
|
125
|
+
when :example
|
|
126
|
+
if right
|
|
127
|
+
" #{left.ljust(max_left + padding)}#{right}"
|
|
128
|
+
else
|
|
129
|
+
" #{left}"
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
" #{left.ljust(max_left + padding)}#{right}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
# Shared module for inline image display in iTerm2/WezTerm/Mintty terminals
|
|
6
|
+
# Includes special handling for tmux passthrough sequences
|
|
7
|
+
module InlineImages
|
|
8
|
+
# Check if terminal supports iTerm2 inline image protocol
|
|
9
|
+
def inline_images_supported?
|
|
10
|
+
# iTerm2, WezTerm, Mintty support inline images
|
|
11
|
+
# LC_TERMINAL persists through tmux/ssh
|
|
12
|
+
ENV["TERM_PROGRAM"] == "iTerm.app" ||
|
|
13
|
+
ENV["TERM_PROGRAM"] == "WezTerm" ||
|
|
14
|
+
ENV["LC_TERMINAL"] == "iTerm2" ||
|
|
15
|
+
ENV["LC_TERMINAL"] == "WezTerm" ||
|
|
16
|
+
ENV["TERM"] == "mintty"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Check if running inside tmux
|
|
20
|
+
def in_tmux?
|
|
21
|
+
# tmux sets TERM to screen-* or tmux-*
|
|
22
|
+
ENV["TERM"]&.include?("screen") || ENV["TERM"]&.start_with?("tmux")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Print an inline image using iTerm2 protocol
|
|
26
|
+
# In tmux, uses passthrough sequence and cursor positioning
|
|
27
|
+
def print_inline_image(path, height: 1)
|
|
28
|
+
return unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
data = File.binread(path)
|
|
31
|
+
encoded = [data].pack("m0") # Base64 encode
|
|
32
|
+
|
|
33
|
+
if in_tmux?
|
|
34
|
+
# tmux passthrough: \n + space required for image to render
|
|
35
|
+
printf "\ePtmux;\e\e]1337;File=inline=1;preserveAspectRatio=0;size=%d;height=%d:%s\a\e\\\n ",
|
|
36
|
+
encoded.length, height, encoded
|
|
37
|
+
else
|
|
38
|
+
# Standard iTerm2 format
|
|
39
|
+
printf "\e]1337;File=inline=1;height=%d:%s\a", height, encoded
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Print inline image with name on same line
|
|
44
|
+
# Handles tmux cursor positioning to keep image and text on same line
|
|
45
|
+
def print_inline_image_with_text(path, text, height: 1)
|
|
46
|
+
return false unless inline_images_supported? && File.exist?(path)
|
|
47
|
+
|
|
48
|
+
print_inline_image(path, height: height)
|
|
49
|
+
|
|
50
|
+
if in_tmux?
|
|
51
|
+
# tmux: image ends with \n + space, cursor on next line
|
|
52
|
+
# Move up 1 line, right 3 cols (past image), then print text
|
|
53
|
+
print "\e[1A\e[3C#{text}\n"
|
|
54
|
+
else
|
|
55
|
+
puts " #{text}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
class SlackUrlParser
|
|
6
|
+
# Patterns for Slack URLs
|
|
7
|
+
# Channel IDs: C=channel, G=group DM, D=direct message
|
|
8
|
+
URL_PATTERNS = [
|
|
9
|
+
# https://workspace.slack.com/archives/C123ABC/p1234567890123456
|
|
10
|
+
%r{https?://([^.]+)\.slack\.com/archives/([CDG][A-Z0-9]+)/p(\d+)(?:\?thread_ts=(\d+\.\d+))?},
|
|
11
|
+
# https://workspace.slack.com/archives/C123ABC (no message)
|
|
12
|
+
%r{https?://([^.]+)\.slack\.com/archives/([CDG][A-Z0-9]+)/?$}
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
Result = Data.define(:workspace, :channel_id, :msg_ts, :thread_ts) do
|
|
16
|
+
def message?
|
|
17
|
+
!msg_ts.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def thread?
|
|
21
|
+
!thread_ts.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the thread parent timestamp if this URL points to a threaded message.
|
|
25
|
+
# Use this when fetching thread replies - pass this as the thread_ts parameter.
|
|
26
|
+
# Returns nil if the URL does not contain a thread_ts query parameter.
|
|
27
|
+
def ts
|
|
28
|
+
thread_ts
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse(input)
|
|
33
|
+
return nil unless input.to_s.include?("slack.com")
|
|
34
|
+
|
|
35
|
+
URL_PATTERNS.each do |pattern|
|
|
36
|
+
match = input.match(pattern)
|
|
37
|
+
next unless match
|
|
38
|
+
|
|
39
|
+
workspace = match[1]
|
|
40
|
+
channel_id = match[2]
|
|
41
|
+
msg_ts = match[3] ? format_ts(match[3]) : nil
|
|
42
|
+
thread_ts = match[4]
|
|
43
|
+
|
|
44
|
+
return Result.new(
|
|
45
|
+
workspace: workspace,
|
|
46
|
+
channel_id: channel_id,
|
|
47
|
+
msg_ts: msg_ts,
|
|
48
|
+
thread_ts: thread_ts
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def slack_url?(input)
|
|
56
|
+
input.to_s.include?("slack.com/archives")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Convert Slack URL timestamp format to API format
|
|
62
|
+
# URL: p1234567890123456 -> API: 1234567890.123456
|
|
63
|
+
def format_ts(url_ts)
|
|
64
|
+
return nil unless url_ts
|
|
65
|
+
|
|
66
|
+
# Remove 'p' prefix if present
|
|
67
|
+
ts = url_ts.sub(/^p/, "")
|
|
68
|
+
|
|
69
|
+
# Insert decimal point
|
|
70
|
+
if ts.length > 6
|
|
71
|
+
"#{ts[0..-7]}.#{ts[-6..]}"
|
|
72
|
+
else
|
|
73
|
+
ts
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
# Shared logic for resolving user and channel names from Slack data.
|
|
6
|
+
# Include this module in commands that need to look up user/channel names.
|
|
7
|
+
#
|
|
8
|
+
# == Required Interface
|
|
9
|
+
# Including classes must provide these methods:
|
|
10
|
+
# - runner: Returns the Runner instance for API access
|
|
11
|
+
# - cache_store: Returns the CacheStore for name lookups
|
|
12
|
+
# - debug(message): Logs debug messages (can be a no-op)
|
|
13
|
+
module UserResolver
|
|
14
|
+
# Resolves a DM channel ID to the user's display name
|
|
15
|
+
# @param workspace [Models::Workspace] The workspace to look up in
|
|
16
|
+
# @param channel_id [String] The DM channel ID (starts with D)
|
|
17
|
+
# @param conversations [Api::Conversations] API client for conversation info
|
|
18
|
+
# @return [String] User name or channel ID if not found
|
|
19
|
+
def resolve_dm_user_name(workspace, channel_id, conversations)
|
|
20
|
+
info = conversations.info(channel: channel_id)
|
|
21
|
+
return channel_id unless info["ok"] && info["channel"]
|
|
22
|
+
|
|
23
|
+
user_id = info["channel"]["user"]
|
|
24
|
+
return channel_id unless user_id
|
|
25
|
+
|
|
26
|
+
# Try cache first
|
|
27
|
+
cached = cache_store.get_user(workspace.name, user_id)
|
|
28
|
+
return cached if cached
|
|
29
|
+
|
|
30
|
+
# Try users API lookup
|
|
31
|
+
begin
|
|
32
|
+
users_api = runner.users_api(workspace.name)
|
|
33
|
+
user_info = users_api.info(user_id)
|
|
34
|
+
if user_info["ok"] && user_info["user"]
|
|
35
|
+
profile = user_info["user"]["profile"] || {}
|
|
36
|
+
name = profile["display_name"]
|
|
37
|
+
name = profile["real_name"] if name.to_s.empty?
|
|
38
|
+
name = user_info["user"]["name"] if name.to_s.empty?
|
|
39
|
+
if name && !name.empty?
|
|
40
|
+
cache_store.set_user(workspace.name, user_id, name, persist: true)
|
|
41
|
+
return name
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
rescue ApiError => e
|
|
45
|
+
debug("User lookup failed for #{user_id}: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
user_id
|
|
49
|
+
rescue ApiError => e
|
|
50
|
+
debug("DM info lookup failed for #{channel_id}: #{e.message}")
|
|
51
|
+
channel_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolves a channel ID to a formatted label (@username or #channel)
|
|
55
|
+
# @param workspace [Models::Workspace] The workspace to look up in
|
|
56
|
+
# @param channel_id [String] The channel ID
|
|
57
|
+
# @return [String] Formatted label like "@username" or "#channel"
|
|
58
|
+
def resolve_conversation_label(workspace, channel_id)
|
|
59
|
+
# DM channels start with D
|
|
60
|
+
if channel_id.start_with?("D")
|
|
61
|
+
conversations = runner.conversations_api(workspace.name)
|
|
62
|
+
user_name = resolve_dm_user_name(workspace, channel_id, conversations)
|
|
63
|
+
return "@#{user_name}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Try cache first
|
|
67
|
+
cached_name = cache_store.get_channel_name(workspace.name, channel_id)
|
|
68
|
+
return "##{cached_name}" if cached_name
|
|
69
|
+
|
|
70
|
+
# Try API lookup
|
|
71
|
+
begin
|
|
72
|
+
conversations = runner.conversations_api(workspace.name)
|
|
73
|
+
response = conversations.info(channel: channel_id)
|
|
74
|
+
if response["ok"] && response["channel"]
|
|
75
|
+
name = response["channel"]["name"]
|
|
76
|
+
if name
|
|
77
|
+
cache_store.set_channel(workspace.name, name, channel_id)
|
|
78
|
+
return "##{name}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
rescue ApiError => e
|
|
82
|
+
debug("Channel info lookup failed for #{channel_id}: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
"##{channel_id}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extracts the user name from a message hash
|
|
89
|
+
# @param msg [Hash] The message data from API
|
|
90
|
+
# @param workspace [Models::Workspace] The workspace context
|
|
91
|
+
# @return [String] User name, user_id as fallback, or "unknown" if neither found
|
|
92
|
+
def extract_user_from_message(msg, workspace)
|
|
93
|
+
# Try user_profile embedded in message
|
|
94
|
+
if msg["user_profile"]
|
|
95
|
+
name = msg["user_profile"]["display_name"]
|
|
96
|
+
name = msg["user_profile"]["real_name"] if name.to_s.empty?
|
|
97
|
+
return name unless name.to_s.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Try username field
|
|
101
|
+
return msg["username"] unless msg["username"].to_s.empty?
|
|
102
|
+
|
|
103
|
+
# Try cache
|
|
104
|
+
user_id = msg["user"] || msg["bot_id"]
|
|
105
|
+
if user_id
|
|
106
|
+
cached = cache_store.get_user(workspace.name, user_id)
|
|
107
|
+
return cached if cached
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
user_id || "unknown"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Support
|
|
5
|
+
class XdgPaths
|
|
6
|
+
def config_dir
|
|
7
|
+
@config_dir ||= File.join(
|
|
8
|
+
ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")),
|
|
9
|
+
"slack-cli"
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cache_dir
|
|
14
|
+
@cache_dir ||= File.join(
|
|
15
|
+
ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache")),
|
|
16
|
+
"slack-cli"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def config_file(filename)
|
|
21
|
+
File.join(config_dir, filename)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cache_file(filename)
|
|
25
|
+
File.join(cache_dir, filename)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_config_dir
|
|
29
|
+
FileUtils.mkdir_p(config_dir)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_cache_dir
|
|
33
|
+
FileUtils.mkdir_p(cache_dir)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/slack_cli.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "time"
|
|
9
|
+
require "io/console"
|
|
10
|
+
|
|
11
|
+
module SlackCli
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class ApiError < Error; end
|
|
14
|
+
class ConfigError < Error; end
|
|
15
|
+
class EncryptionError < Error; end
|
|
16
|
+
class TokenStoreError < Error; end
|
|
17
|
+
class WorkspaceNotFoundError < ConfigError; end
|
|
18
|
+
class PresetNotFoundError < ConfigError; end
|
|
19
|
+
|
|
20
|
+
autoload :VERSION, "slack_cli/version"
|
|
21
|
+
autoload :CLI, "slack_cli/cli"
|
|
22
|
+
autoload :Runner, "slack_cli/runner"
|
|
23
|
+
|
|
24
|
+
module Models
|
|
25
|
+
autoload :Duration, "slack_cli/models/duration"
|
|
26
|
+
autoload :Workspace, "slack_cli/models/workspace"
|
|
27
|
+
autoload :Status, "slack_cli/models/status"
|
|
28
|
+
autoload :Message, "slack_cli/models/message"
|
|
29
|
+
autoload :Reaction, "slack_cli/models/reaction"
|
|
30
|
+
autoload :User, "slack_cli/models/user"
|
|
31
|
+
autoload :Channel, "slack_cli/models/channel"
|
|
32
|
+
autoload :Preset, "slack_cli/models/preset"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module Services
|
|
36
|
+
autoload :ApiClient, "slack_cli/services/api_client"
|
|
37
|
+
autoload :Configuration, "slack_cli/services/configuration"
|
|
38
|
+
autoload :TokenStore, "slack_cli/services/token_store"
|
|
39
|
+
autoload :CacheStore, "slack_cli/services/cache_store"
|
|
40
|
+
autoload :PresetStore, "slack_cli/services/preset_store"
|
|
41
|
+
autoload :Encryption, "slack_cli/services/encryption"
|
|
42
|
+
autoload :ReactionEnricher, "slack_cli/services/reaction_enricher"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module Formatters
|
|
46
|
+
autoload :Output, "slack_cli/formatters/output"
|
|
47
|
+
autoload :DurationFormatter, "slack_cli/formatters/duration_formatter"
|
|
48
|
+
autoload :MentionReplacer, "slack_cli/formatters/mention_replacer"
|
|
49
|
+
autoload :EmojiReplacer, "slack_cli/formatters/emoji_replacer"
|
|
50
|
+
autoload :MessageFormatter, "slack_cli/formatters/message_formatter"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module Commands
|
|
54
|
+
autoload :Base, "slack_cli/commands/base"
|
|
55
|
+
autoload :Status, "slack_cli/commands/status"
|
|
56
|
+
autoload :Presence, "slack_cli/commands/presence"
|
|
57
|
+
autoload :Dnd, "slack_cli/commands/dnd"
|
|
58
|
+
autoload :Messages, "slack_cli/commands/messages"
|
|
59
|
+
autoload :Thread, "slack_cli/commands/thread"
|
|
60
|
+
autoload :Unread, "slack_cli/commands/unread"
|
|
61
|
+
autoload :Catchup, "slack_cli/commands/catchup"
|
|
62
|
+
autoload :Activity, "slack_cli/commands/activity"
|
|
63
|
+
autoload :Preset, "slack_cli/commands/preset"
|
|
64
|
+
autoload :Workspaces, "slack_cli/commands/workspaces"
|
|
65
|
+
autoload :Cache, "slack_cli/commands/cache"
|
|
66
|
+
autoload :Emoji, "slack_cli/commands/emoji"
|
|
67
|
+
autoload :Config, "slack_cli/commands/config"
|
|
68
|
+
autoload :Help, "slack_cli/commands/help"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module Api
|
|
72
|
+
autoload :Users, "slack_cli/api/users"
|
|
73
|
+
autoload :Conversations, "slack_cli/api/conversations"
|
|
74
|
+
autoload :Dnd, "slack_cli/api/dnd"
|
|
75
|
+
autoload :Emoji, "slack_cli/api/emoji"
|
|
76
|
+
autoload :Client, "slack_cli/api/client"
|
|
77
|
+
autoload :Bots, "slack_cli/api/bots"
|
|
78
|
+
autoload :Threads, "slack_cli/api/threads"
|
|
79
|
+
autoload :Usergroups, "slack_cli/api/usergroups"
|
|
80
|
+
autoload :Activity, "slack_cli/api/activity"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
module Support
|
|
84
|
+
autoload :XdgPaths, "slack_cli/support/xdg_paths"
|
|
85
|
+
autoload :SlackUrlParser, "slack_cli/support/slack_url_parser"
|
|
86
|
+
autoload :InlineImages, "slack_cli/support/inline_images"
|
|
87
|
+
autoload :HelpFormatter, "slack_cli/support/help_formatter"
|
|
88
|
+
autoload :ErrorLogger, "slack_cli/support/error_logger"
|
|
89
|
+
autoload :UserResolver, "slack_cli/support/user_resolver"
|
|
90
|
+
end
|
|
91
|
+
end
|