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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/bin/slk +7 -0
  6. data/lib/slack_cli/api/activity.rb +28 -0
  7. data/lib/slack_cli/api/bots.rb +32 -0
  8. data/lib/slack_cli/api/client.rb +49 -0
  9. data/lib/slack_cli/api/conversations.rb +52 -0
  10. data/lib/slack_cli/api/dnd.rb +40 -0
  11. data/lib/slack_cli/api/emoji.rb +21 -0
  12. data/lib/slack_cli/api/threads.rb +44 -0
  13. data/lib/slack_cli/api/usergroups.rb +25 -0
  14. data/lib/slack_cli/api/users.rb +101 -0
  15. data/lib/slack_cli/cli.rb +118 -0
  16. data/lib/slack_cli/commands/activity.rb +292 -0
  17. data/lib/slack_cli/commands/base.rb +175 -0
  18. data/lib/slack_cli/commands/cache.rb +116 -0
  19. data/lib/slack_cli/commands/catchup.rb +484 -0
  20. data/lib/slack_cli/commands/config.rb +159 -0
  21. data/lib/slack_cli/commands/dnd.rb +143 -0
  22. data/lib/slack_cli/commands/emoji.rb +412 -0
  23. data/lib/slack_cli/commands/help.rb +76 -0
  24. data/lib/slack_cli/commands/messages.rb +317 -0
  25. data/lib/slack_cli/commands/presence.rb +107 -0
  26. data/lib/slack_cli/commands/preset.rb +239 -0
  27. data/lib/slack_cli/commands/status.rb +194 -0
  28. data/lib/slack_cli/commands/thread.rb +62 -0
  29. data/lib/slack_cli/commands/unread.rb +312 -0
  30. data/lib/slack_cli/commands/workspaces.rb +151 -0
  31. data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
  32. data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
  33. data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
  34. data/lib/slack_cli/formatters/message_formatter.rb +429 -0
  35. data/lib/slack_cli/formatters/output.rb +89 -0
  36. data/lib/slack_cli/models/channel.rb +52 -0
  37. data/lib/slack_cli/models/duration.rb +85 -0
  38. data/lib/slack_cli/models/message.rb +217 -0
  39. data/lib/slack_cli/models/preset.rb +73 -0
  40. data/lib/slack_cli/models/reaction.rb +54 -0
  41. data/lib/slack_cli/models/status.rb +57 -0
  42. data/lib/slack_cli/models/user.rb +56 -0
  43. data/lib/slack_cli/models/workspace.rb +52 -0
  44. data/lib/slack_cli/runner.rb +123 -0
  45. data/lib/slack_cli/services/api_client.rb +149 -0
  46. data/lib/slack_cli/services/cache_store.rb +198 -0
  47. data/lib/slack_cli/services/configuration.rb +74 -0
  48. data/lib/slack_cli/services/encryption.rb +51 -0
  49. data/lib/slack_cli/services/preset_store.rb +112 -0
  50. data/lib/slack_cli/services/reaction_enricher.rb +87 -0
  51. data/lib/slack_cli/services/token_store.rb +117 -0
  52. data/lib/slack_cli/support/error_logger.rb +28 -0
  53. data/lib/slack_cli/support/help_formatter.rb +139 -0
  54. data/lib/slack_cli/support/inline_images.rb +62 -0
  55. data/lib/slack_cli/support/slack_url_parser.rb +78 -0
  56. data/lib/slack_cli/support/user_resolver.rb +114 -0
  57. data/lib/slack_cli/support/xdg_paths.rb +37 -0
  58. data/lib/slack_cli/version.rb +5 -0
  59. data/lib/slack_cli.rb +91 -0
  60. metadata +103 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Workspaces < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ case positional_args
13
+ in ["list"] | []
14
+ list_workspaces
15
+ in ["add"]
16
+ add_workspace
17
+ in ["remove", name]
18
+ remove_workspace(name)
19
+ in ["primary"]
20
+ show_primary
21
+ in ["primary", name]
22
+ set_primary(name)
23
+ else
24
+ error("Unknown action: #{positional_args.first}")
25
+ 1
26
+ end
27
+ end
28
+
29
+ protected
30
+
31
+ def help_text
32
+ help = Support::HelpFormatter.new("slk workspaces <action> [name]")
33
+ help.description("Manage Slack workspaces.")
34
+
35
+ help.section("ACTIONS") do |s|
36
+ s.action("list", "List configured workspaces")
37
+ s.action("add", "Add a new workspace (interactive)")
38
+ s.action("remove <name>", "Remove a workspace")
39
+ s.action("primary", "Show primary workspace")
40
+ s.action("primary <name>", "Set primary workspace")
41
+ end
42
+
43
+ help.section("OPTIONS") do |s|
44
+ s.option("-q, --quiet", "Suppress output")
45
+ end
46
+
47
+ help.render
48
+ end
49
+
50
+ private
51
+
52
+ def list_workspaces
53
+ names = runner.workspace_names
54
+ primary = config.primary_workspace
55
+
56
+ if names.empty?
57
+ puts "No workspaces configured."
58
+ puts "Run 'slack workspaces add' to add one."
59
+ return 0
60
+ end
61
+
62
+ puts "Workspaces:"
63
+ names.each do |name|
64
+ marker = name == primary ? output.green("*") : " "
65
+ puts " #{marker} #{name}"
66
+ end
67
+
68
+ 0
69
+ end
70
+
71
+ def add_workspace
72
+ print "Workspace name: "
73
+ name = $stdin.gets&.chomp
74
+ return error("Name is required") if name.nil? || name.empty?
75
+
76
+ if token_store.exists?(name)
77
+ return error("Workspace '#{name}' already exists")
78
+ end
79
+
80
+ print "Token (xoxb-... or xoxc-...): "
81
+ token = $stdin.gets&.chomp
82
+ return error("Token is required") if token.nil? || token.empty?
83
+
84
+ cookie = nil
85
+ if token.start_with?("xoxc-")
86
+ print "Cookie (d=...): "
87
+ cookie = $stdin.gets&.chomp
88
+ end
89
+
90
+ token_store.add(name, token, cookie)
91
+
92
+ # Set as primary if first workspace
93
+ if runner.workspace_names.size == 1
94
+ config.primary_workspace = name
95
+ success("Added workspace '#{name}' (set as primary)")
96
+ else
97
+ success("Added workspace '#{name}'")
98
+ end
99
+
100
+ 0
101
+ end
102
+
103
+ def remove_workspace(name)
104
+ unless token_store.exists?(name)
105
+ return error("Workspace '#{name}' not found")
106
+ end
107
+
108
+ token_store.remove(name)
109
+
110
+ # Clear primary if removing it
111
+ if config.primary_workspace == name
112
+ remaining = runner.workspace_names
113
+ if remaining.any?
114
+ config.primary_workspace = remaining.first
115
+ success("Removed workspace '#{name}'. Primary changed to '#{remaining.first}'")
116
+ else
117
+ config.primary_workspace = nil
118
+ success("Removed workspace '#{name}'")
119
+ end
120
+ else
121
+ success("Removed workspace '#{name}'")
122
+ end
123
+
124
+ 0
125
+ end
126
+
127
+ def show_primary
128
+ primary = config.primary_workspace
129
+
130
+ if primary
131
+ puts "Primary workspace: #{primary}"
132
+ else
133
+ puts "No primary workspace set."
134
+ end
135
+
136
+ 0
137
+ end
138
+
139
+ def set_primary(name)
140
+ unless token_store.exists?(name)
141
+ return error("Workspace '#{name}' not found")
142
+ end
143
+
144
+ config.primary_workspace = name
145
+ success("Primary workspace set to '#{name}'")
146
+
147
+ 0
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Formatters
5
+ class DurationFormatter
6
+ def format(duration)
7
+ return "" if duration.nil? || duration.zero?
8
+
9
+ duration.to_s
10
+ end
11
+
12
+ def format_remaining(seconds)
13
+ return "" if seconds.nil? || seconds <= 0
14
+
15
+ Models::Duration.new(seconds: seconds).to_s
16
+ end
17
+
18
+ def format_until(timestamp)
19
+ return "" if timestamp.nil? || timestamp <= 0
20
+
21
+ remaining = timestamp - Time.now.to_i
22
+ return "expired" if remaining <= 0
23
+
24
+ format_remaining(remaining)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Formatters
5
+ class EmojiReplacer
6
+ EMOJI_REGEX = /:([a-zA-Z0-9_+-]+):/
7
+ SKIN_TONE_REGEX = /::skin-tone-(\d)/
8
+
9
+ # Common emoji mappings (subset - full list would be much larger)
10
+ EMOJI_MAP = {
11
+ # Faces
12
+ "smile" => "\u{1F604}", "grinning" => "\u{1F600}", "joy" => "\u{1F602}",
13
+ "rofl" => "\u{1F923}", "smiley" => "\u{1F603}", "sweat_smile" => "\u{1F605}",
14
+ "laughing" => "\u{1F606}", "wink" => "\u{1F609}", "blush" => "\u{1F60A}",
15
+ "yum" => "\u{1F60B}", "sunglasses" => "\u{1F60E}", "heart_eyes" => "\u{1F60D}",
16
+ "kissing_heart" => "\u{1F618}", "thinking" => "\u{1F914}", "thinking_face" => "\u{1F914}",
17
+ "raised_eyebrow" => "\u{1F928}", "neutral_face" => "\u{1F610}", "expressionless" => "\u{1F611}",
18
+ "unamused" => "\u{1F612}", "rolling_eyes" => "\u{1F644}", "grimacing" => "\u{1F62C}",
19
+ "relieved" => "\u{1F60C}", "pensive" => "\u{1F614}", "sleepy" => "\u{1F62A}",
20
+ "sleeping" => "\u{1F634}", "sob" => "\u{1F62D}", "cry" => "\u{1F622}",
21
+ "scream" => "\u{1F631}", "angry" => "\u{1F620}", "rage" => "\u{1F621}",
22
+
23
+ # Gestures
24
+ "wave" => "\u{1F44B}", "+1" => "\u{1F44D}", "-1" => "\u{1F44E}",
25
+ "thumbsup" => "\u{1F44D}", "thumbsdown" => "\u{1F44E}",
26
+ "clap" => "\u{1F44F}", "raised_hands" => "\u{1F64C}", "pray" => "\u{1F64F}",
27
+ "point_up" => "\u{261D}", "point_down" => "\u{1F447}", "point_left" => "\u{1F448}",
28
+ "point_right" => "\u{1F449}", "ok_hand" => "\u{1F44C}", "v" => "\u{270C}",
29
+ "muscle" => "\u{1F4AA}", "fist" => "\u{270A}",
30
+
31
+ # Hearts
32
+ "heart" => "\u{2764}", "hearts" => "\u{2665}", "yellow_heart" => "\u{1F49B}",
33
+ "green_heart" => "\u{1F49A}", "blue_heart" => "\u{1F499}", "purple_heart" => "\u{1F49C}",
34
+ "black_heart" => "\u{1F5A4}", "broken_heart" => "\u{1F494}", "sparkling_heart" => "\u{1F496}",
35
+
36
+ # Objects
37
+ "fire" => "\u{1F525}", "star" => "\u{2B50}", "sparkles" => "\u{2728}",
38
+ "boom" => "\u{1F4A5}", "zap" => "\u{26A1}", "sunny" => "\u{2600}",
39
+ "cloud" => "\u{2601}", "umbrella" => "\u{2614}", "snowflake" => "\u{2744}",
40
+ "rocket" => "\u{1F680}", "airplane" => "\u{2708}", "car" => "\u{1F697}",
41
+ "gift" => "\u{1F381}", "trophy" => "\u{1F3C6}", "medal" => "\u{1F3C5}",
42
+ "bell" => "\u{1F514}", "key" => "\u{1F511}", "lock" => "\u{1F512}",
43
+ "bulb" => "\u{1F4A1}", "book" => "\u{1F4D6}", "pencil" => "\u{270F}",
44
+ "memo" => "\u{1F4DD}", "computer" => "\u{1F4BB}", "phone" => "\u{1F4F1}",
45
+ "camera" => "\u{1F4F7}", "headphones" => "\u{1F3A7}", "microphone" => "\u{1F3A4}",
46
+
47
+ # Food
48
+ "coffee" => "\u{2615}", "tea" => "\u{1F375}", "beer" => "\u{1F37A}",
49
+ "wine_glass" => "\u{1F377}", "pizza" => "\u{1F355}", "hamburger" => "\u{1F354}",
50
+ "cake" => "\u{1F370}", "cookie" => "\u{1F36A}", "apple" => "\u{1F34E}",
51
+ "banana" => "\u{1F34C}", "taco" => "\u{1F32E}", "burrito" => "\u{1F32F}",
52
+ "knife_fork_plate" => "\u{1F37D}",
53
+
54
+ # Nature
55
+ "dog" => "\u{1F436}", "cat" => "\u{1F431}", "mouse" => "\u{1F42D}",
56
+ "rabbit" => "\u{1F430}", "bear" => "\u{1F43B}", "panda_face" => "\u{1F43C}",
57
+ "chicken" => "\u{1F414}", "penguin" => "\u{1F427}", "bird" => "\u{1F426}",
58
+ "fish" => "\u{1F41F}", "bug" => "\u{1F41B}", "bee" => "\u{1F41D}",
59
+ "rose" => "\u{1F339}", "sunflower" => "\u{1F33B}", "tree" => "\u{1F333}",
60
+ "cactus" => "\u{1F335}", "palm_tree" => "\u{1F334}",
61
+
62
+ # Symbols
63
+ "white_check_mark" => "\u{2705}", "heavy_check_mark" => "\u{2714}",
64
+ "x" => "\u{274C}", "heavy_multiplication_x" => "\u{2716}",
65
+ "warning" => "\u{26A0}", "no_entry" => "\u{26D4}", "sos" => "\u{1F198}",
66
+ "question" => "\u{2753}", "exclamation" => "\u{2757}", "bangbang" => "\u{203C}",
67
+ "100" => "\u{1F4AF}", "1234" => "\u{1F522}",
68
+
69
+ # Status-related
70
+ "house" => "\u{1F3E0}", "office" => "\u{1F3E2}", "hospital" => "\u{1F3E5}",
71
+ "calendar" => "\u{1F4C5}", "date" => "\u{1F4C5}", "spiral_calendar" => "\u{1F5D3}",
72
+ "clock1" => "\u{1F550}", "hourglass" => "\u{231B}", "stopwatch" => "\u{23F1}",
73
+ "zzz" => "\u{1F4A4}", "speech_balloon" => "\u{1F4AC}", "thought_balloon" => "\u{1F4AD}",
74
+
75
+ # Common Slack custom
76
+ "party-blob" => "\u{1F389}", "blob-wave" => "\u{1F44B}",
77
+ "tada" => "\u{1F389}", "confetti_ball" => "\u{1F38A}",
78
+ "balloon" => "\u{1F388}", "party_popper" => "\u{1F389}",
79
+ "eyes" => "\u{1F440}", "eye" => "\u{1F441}",
80
+ "ear" => "\u{1F442}", "nose" => "\u{1F443}",
81
+ "brb" => "\u{1F6B6}", "away" => "\u{1F6B6}",
82
+ "test_tube" => "\u{1F9EA}"
83
+ }.freeze
84
+
85
+ def initialize(custom_emoji: {}, on_debug: nil)
86
+ @custom_emoji = custom_emoji
87
+ @on_debug = on_debug
88
+ @gemoji_cache = load_gemoji_cache
89
+ end
90
+
91
+ def replace(text, workspace = nil)
92
+ result = text.dup
93
+
94
+ # Remove skin tone modifiers (we don't render them in terminal)
95
+ result.gsub!(SKIN_TONE_REGEX, "")
96
+
97
+ # Replace emoji codes
98
+ result.gsub!(EMOJI_REGEX) do
99
+ name = ::Regexp.last_match(1)
100
+ lookup_emoji(name) || ":#{name}:"
101
+ end
102
+
103
+ result
104
+ end
105
+
106
+ def lookup_emoji(name)
107
+ # Check custom emoji first
108
+ return nil if @custom_emoji[name] # Custom emoji are URLs, skip for now
109
+
110
+ # Check gemoji cache first (from sync-standard)
111
+ if @gemoji_cache&.key?(name)
112
+ return @gemoji_cache[name]
113
+ end
114
+
115
+ # Fall back to built-in map
116
+ EMOJI_MAP[name]
117
+ end
118
+
119
+ def with_custom_emoji(emoji_hash)
120
+ self.class.new(custom_emoji: emoji_hash, on_debug: @on_debug)
121
+ end
122
+
123
+ private
124
+
125
+ def load_gemoji_cache
126
+ cache_path = File.join(
127
+ ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache")),
128
+ "slack-cli",
129
+ "gemoji.json"
130
+ )
131
+
132
+ return nil unless File.exist?(cache_path)
133
+
134
+ JSON.parse(File.read(cache_path))
135
+ rescue JSON::ParserError => e
136
+ @on_debug&.call("Failed to load gemoji cache: #{e.message}")
137
+ nil
138
+ rescue Errno::ENOENT
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Formatters
5
+ class MentionReplacer
6
+ USER_MENTION_REGEX = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>/
7
+ CHANNEL_MENTION_REGEX = /<#([A-Z0-9]+)(?:\|([^>]*))?>/
8
+ SUBTEAM_MENTION_REGEX = /<!subteam\^([A-Z0-9]+)(?:\|@?([^>]+))?>/
9
+ LINK_REGEX = /<(https?:\/\/[^|>]+)(?:\|([^>]+))?>/
10
+ SPECIAL_MENTIONS = {
11
+ "<!here>" => "@here",
12
+ "<!channel>" => "@channel",
13
+ "<!everyone>" => "@everyone"
14
+ }.freeze
15
+
16
+ def initialize(cache_store:, api_client: nil, on_debug: nil)
17
+ @cache = cache_store
18
+ @api = api_client
19
+ @on_debug = on_debug
20
+ end
21
+
22
+ def replace(text, workspace)
23
+ result = text.dup
24
+
25
+ # Replace user mentions
26
+ result.gsub!(USER_MENTION_REGEX) do
27
+ user_id = ::Regexp.last_match(1)
28
+ display_name = ::Regexp.last_match(2)
29
+
30
+ if display_name && !display_name.empty?
31
+ "@#{display_name}"
32
+ else
33
+ name = lookup_user_name(workspace, user_id)
34
+ "@#{name || user_id}"
35
+ end
36
+ end
37
+
38
+ # Replace channel mentions
39
+ result.gsub!(CHANNEL_MENTION_REGEX) do
40
+ channel_id = ::Regexp.last_match(1)
41
+ channel_name = ::Regexp.last_match(2)
42
+
43
+ if channel_name && !channel_name.empty?
44
+ "##{channel_name}"
45
+ else
46
+ name = lookup_channel_name(workspace, channel_id)
47
+ name ? "##{name}" : "##{channel_id}"
48
+ end
49
+ end
50
+
51
+ # Replace subteam (user group) mentions
52
+ result.gsub!(SUBTEAM_MENTION_REGEX) do
53
+ subteam_id = ::Regexp.last_match(1)
54
+ handle = ::Regexp.last_match(2)
55
+
56
+ if handle && !handle.empty?
57
+ "@#{handle}"
58
+ else
59
+ name = lookup_subteam_handle(workspace, subteam_id)
60
+ "@#{name || subteam_id}"
61
+ end
62
+ end
63
+
64
+ # Replace links
65
+ result.gsub!(LINK_REGEX) do
66
+ url = ::Regexp.last_match(1)
67
+ label = ::Regexp.last_match(2)
68
+ label || url
69
+ end
70
+
71
+ # Replace special mentions
72
+ SPECIAL_MENTIONS.each do |pattern, replacement|
73
+ result.gsub!(pattern, replacement)
74
+ end
75
+
76
+ result
77
+ end
78
+
79
+ private
80
+
81
+ def lookup_user_name(workspace, user_id)
82
+ # Try cache first
83
+ cached = @cache.get_user(workspace.name, user_id)
84
+ return cached if cached
85
+
86
+ # Try API lookup
87
+ return nil unless @api
88
+
89
+ begin
90
+ users_api = Api::Users.new(@api, workspace)
91
+ response = users_api.info(user_id)
92
+ if response["ok"] && response["user"]
93
+ profile = response["user"]["profile"] || {}
94
+ name = profile["display_name"]
95
+ name = profile["real_name"] if name.to_s.empty?
96
+ name = response["user"]["name"] if name.to_s.empty?
97
+ # Cache for future lookups
98
+ @cache.set_user(workspace.name, user_id, name, persist: true) if name && !name.empty?
99
+ return name unless name.to_s.empty?
100
+ end
101
+ rescue ApiError => e
102
+ @on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ def lookup_channel_name(workspace, channel_id)
109
+ # Try cache first
110
+ cached = @cache.get_channel_name(workspace.name, channel_id)
111
+ return cached if cached
112
+
113
+ # Try API lookup
114
+ return nil unless @api
115
+
116
+ begin
117
+ conversations_api = Api::Conversations.new(@api, workspace)
118
+ response = conversations_api.info(channel: channel_id)
119
+ if response["ok"] && response["channel"]
120
+ name = response["channel"]["name"]
121
+ # Cache for future lookups
122
+ @cache.set_channel(workspace.name, name, channel_id) if name
123
+ return name
124
+ end
125
+ rescue ApiError => e
126
+ @on_debug&.call("Channel lookup failed for #{channel_id}: #{e.message}")
127
+ end
128
+
129
+ nil
130
+ end
131
+
132
+ def lookup_subteam_handle(workspace, subteam_id)
133
+ # Try cache first
134
+ cached = @cache.get_subteam(workspace.name, subteam_id)
135
+ return cached if cached
136
+
137
+ # Try API lookup
138
+ return nil unless @api
139
+
140
+ begin
141
+ usergroups_api = Api::Usergroups.new(@api, workspace)
142
+ handle = usergroups_api.get_handle(subteam_id)
143
+ # Cache for future lookups
144
+ @cache.set_subteam(workspace.name, subteam_id, handle) if handle
145
+ return handle
146
+ rescue ApiError => e
147
+ @on_debug&.call("Subteam lookup failed for #{subteam_id}: #{e.message}")
148
+ end
149
+
150
+ nil
151
+ end
152
+ end
153
+ end
154
+ end