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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +5 -5
  4. data/bin/slk +3 -3
  5. data/lib/{slack_cli → slk}/api/activity.rb +10 -11
  6. data/lib/{slack_cli → slk}/api/bots.rb +5 -4
  7. data/lib/slk/api/client.rb +51 -0
  8. data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
  9. data/lib/slk/api/dnd.rb +41 -0
  10. data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
  11. data/lib/{slack_cli → slk}/api/threads.rb +13 -12
  12. data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
  13. data/lib/slk/api/users.rb +105 -0
  14. data/lib/slk/cli.rb +157 -0
  15. data/lib/slk/commands/activity.rb +152 -0
  16. data/lib/{slack_cli → slk}/commands/base.rb +67 -41
  17. data/lib/slk/commands/cache.rb +141 -0
  18. data/lib/slk/commands/catchup.rb +411 -0
  19. data/lib/slk/commands/config.rb +114 -0
  20. data/lib/slk/commands/dnd.rb +172 -0
  21. data/lib/slk/commands/emoji.rb +352 -0
  22. data/lib/slk/commands/help.rb +97 -0
  23. data/lib/slk/commands/messages.rb +299 -0
  24. data/lib/slk/commands/presence.rb +109 -0
  25. data/lib/slk/commands/preset.rb +231 -0
  26. data/lib/slk/commands/status.rb +223 -0
  27. data/lib/slk/commands/thread.rb +72 -0
  28. data/lib/slk/commands/unread.rb +305 -0
  29. data/lib/slk/commands/workspaces.rb +168 -0
  30. data/lib/slk/formatters/activity_formatter.rb +148 -0
  31. data/lib/slk/formatters/attachment_formatter.rb +65 -0
  32. data/lib/slk/formatters/block_formatter.rb +57 -0
  33. data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
  34. data/lib/slk/formatters/emoji_replacer.rb +141 -0
  35. data/lib/slk/formatters/json_message_formatter.rb +95 -0
  36. data/lib/slk/formatters/mention_replacer.rb +158 -0
  37. data/lib/slk/formatters/message_formatter.rb +174 -0
  38. data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
  39. data/lib/slk/formatters/reaction_formatter.rb +87 -0
  40. data/lib/{slack_cli → slk}/models/channel.rb +12 -10
  41. data/lib/slk/models/duration.rb +94 -0
  42. data/lib/slk/models/message.rb +242 -0
  43. data/lib/slk/models/preset.rb +78 -0
  44. data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
  45. data/lib/{slack_cli → slk}/models/status.rb +6 -6
  46. data/lib/slk/models/user.rb +55 -0
  47. data/lib/slk/models/workspace.rb +54 -0
  48. data/lib/{slack_cli → slk}/runner.rb +22 -19
  49. data/lib/slk/services/activity_enricher.rb +124 -0
  50. data/lib/slk/services/api_client.rb +145 -0
  51. data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
  52. data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
  53. data/lib/slk/services/emoji_downloader.rb +103 -0
  54. data/lib/slk/services/emoji_searcher.rb +72 -0
  55. data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
  56. data/lib/slk/services/gemoji_sync.rb +97 -0
  57. data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
  58. data/lib/slk/services/reaction_enricher.rb +82 -0
  59. data/lib/slk/services/setup_wizard.rb +131 -0
  60. data/lib/slk/services/target_resolver.rb +108 -0
  61. data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
  62. data/lib/slk/services/unread_marker.rb +101 -0
  63. data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
  64. data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
  65. data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
  66. data/lib/slk/support/interactive_prompt.rb +29 -0
  67. data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
  68. data/lib/slk/support/text_wrapper.rb +57 -0
  69. data/lib/slk/support/user_resolver.rb +141 -0
  70. data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
  71. data/lib/slk/version.rb +5 -0
  72. data/lib/slk.rb +112 -0
  73. metadata +80 -59
  74. data/lib/slack_cli/api/client.rb +0 -49
  75. data/lib/slack_cli/api/dnd.rb +0 -40
  76. data/lib/slack_cli/api/users.rb +0 -101
  77. data/lib/slack_cli/cli.rb +0 -118
  78. data/lib/slack_cli/commands/activity.rb +0 -292
  79. data/lib/slack_cli/commands/cache.rb +0 -116
  80. data/lib/slack_cli/commands/catchup.rb +0 -484
  81. data/lib/slack_cli/commands/config.rb +0 -159
  82. data/lib/slack_cli/commands/dnd.rb +0 -143
  83. data/lib/slack_cli/commands/emoji.rb +0 -412
  84. data/lib/slack_cli/commands/help.rb +0 -76
  85. data/lib/slack_cli/commands/messages.rb +0 -317
  86. data/lib/slack_cli/commands/presence.rb +0 -107
  87. data/lib/slack_cli/commands/preset.rb +0 -239
  88. data/lib/slack_cli/commands/status.rb +0 -194
  89. data/lib/slack_cli/commands/thread.rb +0 -62
  90. data/lib/slack_cli/commands/unread.rb +0 -312
  91. data/lib/slack_cli/commands/workspaces.rb +0 -151
  92. data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
  93. data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
  94. data/lib/slack_cli/formatters/message_formatter.rb +0 -429
  95. data/lib/slack_cli/models/duration.rb +0 -85
  96. data/lib/slack_cli/models/message.rb +0 -217
  97. data/lib/slack_cli/models/preset.rb +0 -73
  98. data/lib/slack_cli/models/user.rb +0 -56
  99. data/lib/slack_cli/models/workspace.rb +0 -52
  100. data/lib/slack_cli/services/api_client.rb +0 -149
  101. data/lib/slack_cli/services/reaction_enricher.rb +0 -87
  102. data/lib/slack_cli/support/user_resolver.rb +0 -114
  103. data/lib/slack_cli/version.rb +0 -5
  104. data/lib/slack_cli.rb +0 -91
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Manages configured Slack workspaces
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Workspaces < Base
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ dispatch_action
15
+ end
16
+
17
+ def dispatch_action
18
+ case positional_args
19
+ in ['list'] | [] then list_workspaces
20
+ in ['add'] then add_workspace
21
+ in ['remove', name] then remove_workspace(name)
22
+ in ['primary'] then show_primary
23
+ in ['primary', name] then set_primary(name)
24
+ else unknown_action
25
+ end
26
+ end
27
+
28
+ def unknown_action
29
+ error("Unknown action: #{positional_args.first}")
30
+ 1
31
+ end
32
+
33
+ protected
34
+
35
+ def help_text
36
+ help = Support::HelpFormatter.new('slk workspaces <action> [name]')
37
+ help.description('Manage Slack workspaces.')
38
+ add_actions_section(help)
39
+ add_options_section(help)
40
+ help.render
41
+ end
42
+
43
+ def add_actions_section(help)
44
+ help.section('ACTIONS') do |s|
45
+ s.action('list', 'List configured workspaces')
46
+ s.action('add', 'Add a new workspace (interactive)')
47
+ s.action('remove <name>', 'Remove a workspace')
48
+ s.action('primary', 'Show primary workspace')
49
+ s.action('primary <name>', 'Set primary workspace')
50
+ end
51
+ end
52
+
53
+ def add_options_section(help)
54
+ help.section('OPTIONS') do |s|
55
+ s.option('-q, --quiet', 'Suppress output')
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def list_workspaces
62
+ names = runner.workspace_names
63
+ return show_no_workspaces if names.empty?
64
+
65
+ print_workspace_list(names, config.primary_workspace)
66
+ 0
67
+ end
68
+
69
+ def show_no_workspaces
70
+ puts 'No workspaces configured.'
71
+ puts "Run 'slack workspaces add' to add one."
72
+ 0
73
+ end
74
+
75
+ def print_workspace_list(names, primary)
76
+ puts 'Workspaces:'
77
+ names.each do |name|
78
+ marker = name == primary ? output.green('*') : ' '
79
+ puts " #{marker} #{name}"
80
+ end
81
+ end
82
+
83
+ def add_workspace
84
+ name = prompt_for_name
85
+ return name if name.is_a?(Integer) # Error code
86
+
87
+ token, cookie = prompt_for_credentials
88
+ return token if token.is_a?(Integer) # Error code
89
+
90
+ save_workspace(name, token, cookie)
91
+ end
92
+
93
+ def prompt_for_name
94
+ print 'Workspace name: '
95
+ name = $stdin.gets&.chomp
96
+ return error('Name is required') if name.nil? || name.empty?
97
+ return error("Workspace '#{name}' already exists") if token_store.exists?(name)
98
+
99
+ name
100
+ end
101
+
102
+ def prompt_for_credentials
103
+ print 'Token (xoxb-... or xoxc-...): '
104
+ token = $stdin.gets&.chomp
105
+ return error('Token is required') if token.nil? || token.empty?
106
+
107
+ cookie = prompt_for_cookie(token)
108
+ [token, cookie]
109
+ end
110
+
111
+ def prompt_for_cookie(token)
112
+ return nil unless token.start_with?('xoxc-')
113
+
114
+ print 'Cookie (d=...): '
115
+ $stdin.gets&.chomp
116
+ end
117
+
118
+ def save_workspace(name, token, cookie)
119
+ token_store.add(name, token, cookie)
120
+
121
+ if runner.workspace_names.size == 1
122
+ config.primary_workspace = name
123
+ success("Added workspace '#{name}' (set as primary)")
124
+ else
125
+ success("Added workspace '#{name}'")
126
+ end
127
+ 0
128
+ end
129
+
130
+ def remove_workspace(name)
131
+ return error("Workspace '#{name}' not found") unless token_store.exists?(name)
132
+
133
+ token_store.remove(name)
134
+ handle_primary_after_removal(name)
135
+ 0
136
+ end
137
+
138
+ def handle_primary_after_removal(name)
139
+ return success("Removed workspace '#{name}'") unless config.primary_workspace == name
140
+
141
+ remaining = runner.workspace_names
142
+ if remaining.any?
143
+ config.primary_workspace = remaining.first
144
+ success("Removed workspace '#{name}'. Primary changed to '#{remaining.first}'")
145
+ else
146
+ config.primary_workspace = nil
147
+ success("Removed workspace '#{name}'")
148
+ end
149
+ end
150
+
151
+ def show_primary
152
+ primary = config.primary_workspace
153
+ puts primary ? "Primary workspace: #{primary}" : 'No primary workspace set.'
154
+ 0
155
+ end
156
+
157
+ def set_primary(name) # rubocop:disable Naming/AccessorMethodName
158
+ return error("Workspace '#{name}' not found") unless token_store.exists?(name)
159
+
160
+ config.primary_workspace = name
161
+ success("Primary workspace set to '#{name}'")
162
+
163
+ 0
164
+ end
165
+ end
166
+ # rubocop:enable Metrics/ClassLength
167
+ end
168
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats activity feed items for terminal display
6
+ # rubocop:disable Metrics/ClassLength
7
+ class ActivityFormatter
8
+ def initialize(output:, enricher:, emoji_replacer:, mention_replacer:, on_debug: nil)
9
+ @output = output
10
+ @enricher = enricher
11
+ @emoji = emoji_replacer
12
+ @mentions = mention_replacer
13
+ @on_debug = on_debug
14
+ end
15
+
16
+ # Display a list of activity items
17
+ def display_all(items, workspace, options: {})
18
+ return puts 'No activity found.' if items.empty?
19
+
20
+ items.each do |item|
21
+ display_item(item, workspace, options)
22
+ end
23
+ end
24
+
25
+ # Display a single activity item
26
+ def display_item(item, workspace, options)
27
+ type = item.dig('item', 'type')
28
+ timestamp = format_time(item['feed_ts'])
29
+ dispatch_activity_display(type, item, workspace, timestamp, options)
30
+ end
31
+
32
+ private
33
+
34
+ def dispatch_activity_display(type, item, workspace, timestamp, options)
35
+ case type
36
+ when 'message_reaction' then display_reaction(item, workspace, timestamp, options)
37
+ when 'at_user', 'at_user_group', 'at_channel', 'at_everyone'
38
+ display_mention(item, workspace, timestamp, options)
39
+ when 'thread_v2' then display_thread(item, workspace, timestamp, options)
40
+ when 'bot_dm_bundle' then display_bot_dm(item, workspace, timestamp, options)
41
+ else @on_debug&.call("Unknown activity type '#{type}' - skipping") if type
42
+ end
43
+ end
44
+
45
+ def display_reaction(item, workspace, timestamp, options)
46
+ reaction_data = item.dig('item', 'reaction')
47
+ message_data = item.dig('item', 'message')
48
+ return debug_missing('reaction') unless reaction_data && message_data
49
+
50
+ username = @enricher.resolve_user(workspace, reaction_data['user'])
51
+ emoji = @emoji.lookup_emoji(reaction_data['name']) || ":#{reaction_data['name']}:"
52
+ channel = @enricher.resolve_channel(workspace, message_data['channel'])
53
+
54
+ puts "#{@output.blue(timestamp)} #{@output.bold(username)} reacted #{emoji} in #{channel}"
55
+ show_message_preview(workspace, message_data, options)
56
+ end
57
+
58
+ def display_mention(item, workspace, timestamp, options)
59
+ message_data = item.dig('item', 'message')
60
+ return debug_missing('mention') unless message_data
61
+
62
+ user_id = message_data['author_user_id'] || message_data['user']
63
+ username = @enricher.resolve_user(workspace, user_id)
64
+ channel = @enricher.resolve_channel(workspace, message_data['channel'])
65
+
66
+ puts "#{@output.blue(timestamp)} #{@output.bold(username)} mentioned you in #{channel}"
67
+ show_message_preview(workspace, message_data, options)
68
+ end
69
+
70
+ def display_thread(item, workspace, timestamp, options)
71
+ thread_entry = item.dig('item', 'bundle_info', 'payload', 'thread_entry')
72
+ return debug_missing('thread') unless thread_entry
73
+
74
+ channel = @enricher.resolve_channel(workspace, thread_entry['channel_id'])
75
+ puts "#{@output.blue(timestamp)} Thread activity in #{channel}"
76
+
77
+ return unless options[:show_messages] && thread_entry['thread_ts']
78
+
79
+ message_data = { 'channel' => thread_entry['channel_id'], 'ts' => thread_entry['thread_ts'] }
80
+ show_message_preview(workspace, message_data, options)
81
+ end
82
+
83
+ def display_bot_dm(item, workspace, timestamp, options)
84
+ message_data = item.dig('item', 'bundle_info', 'payload', 'message')
85
+ return debug_missing('bot DM') unless message_data
86
+
87
+ channel = @enricher.resolve_channel(workspace, message_data['channel'])
88
+ puts "#{@output.blue(timestamp)} Bot message in #{channel}"
89
+ show_message_preview(workspace, message_data, options)
90
+ end
91
+
92
+ def show_message_preview(workspace, message_data, options)
93
+ return unless options[:show_messages]
94
+ return unless options[:fetch_message]
95
+
96
+ message = options[:fetch_message].call(workspace, message_data['channel'], message_data['ts'])
97
+ display_message_content(message, workspace) if message
98
+ end
99
+
100
+ def display_message_content(message, workspace)
101
+ username = resolve_message_author(message, workspace)
102
+ text = prepare_message_text(message, workspace)
103
+
104
+ lines = text.lines
105
+ first_line = truncate(lines.first&.strip || text, 100)
106
+ puts " └─ #{username}: #{first_line}"
107
+
108
+ display_additional_lines(lines) if lines.length > 1
109
+ end
110
+
111
+ def resolve_message_author(message, workspace)
112
+ if message['user']
113
+ @enricher.resolve_user(workspace, message['user'])
114
+ elsif message['bot_id']
115
+ 'Bot'
116
+ else
117
+ 'Unknown'
118
+ end
119
+ end
120
+
121
+ def prepare_message_text(message, workspace)
122
+ text = message['text'] || ''
123
+ return '[No text]' if text.empty?
124
+
125
+ @mentions.replace(text, workspace)
126
+ end
127
+
128
+ def display_additional_lines(lines)
129
+ remaining = lines[1..2].map(&:strip).reject(&:empty?)
130
+ remaining.each { |line| puts " #{truncate(line, 100)}" }
131
+ puts " [#{lines.length - 3} more lines...]" if lines.length > 3
132
+ end
133
+
134
+ def truncate(text, max_length)
135
+ text.length > max_length ? "#{text[0..max_length]}..." : text
136
+ end
137
+
138
+ def format_time(slack_timestamp)
139
+ Time.at(slack_timestamp.to_f).strftime('%b %d %-I:%M %p')
140
+ end
141
+
142
+ def debug_missing(item_type)
143
+ @on_debug&.call("Could not display #{item_type} activity - missing data")
144
+ end
145
+ end
146
+ # rubocop:enable Metrics/ClassLength
147
+ end
148
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats Slack message attachments for terminal display
6
+ class AttachmentFormatter
7
+ def initialize(output:, text_processor:)
8
+ @output = output
9
+ @text_processor = text_processor
10
+ end
11
+
12
+ def format(attachments, lines, options)
13
+ return if attachments.empty?
14
+ return if options[:no_attachments]
15
+
16
+ attachments.each { |att| format_attachment(att, lines, options) }
17
+ end
18
+
19
+ private
20
+
21
+ def format_attachment(attachment, lines, options)
22
+ att_text = attachment['text'] || attachment['fallback']
23
+ image_url = attachment['image_url'] || attachment['thumb_url']
24
+
25
+ return unless att_text || image_url
26
+
27
+ lines << ''
28
+ format_author(attachment, lines)
29
+ format_text(att_text, lines, options) if att_text
30
+ format_image(attachment, image_url, lines) if image_url
31
+ end
32
+
33
+ def format_author(attachment, lines)
34
+ author = attachment['author_name'] || attachment['author_subname']
35
+ lines << "> #{@output.bold(author)}:" if author
36
+ end
37
+
38
+ def format_text(att_text, lines, options)
39
+ processed_text = @text_processor.call(att_text)
40
+ processed_text = wrap_text(processed_text, options[:width])
41
+
42
+ processed_text.each_line do |line|
43
+ lines << "> #{line.chomp}"
44
+ end
45
+ end
46
+
47
+ def wrap_text(text, width)
48
+ return text unless width && width > 2
49
+
50
+ Support::TextWrapper.wrap(text, width - 2, width - 2)
51
+ end
52
+
53
+ def format_image(attachment, image_url, lines)
54
+ filename = attachment['title'] || extract_filename(image_url)
55
+ lines << "> [Image: #{filename}]"
56
+ end
57
+
58
+ def extract_filename(url)
59
+ File.basename(URI.parse(url).path)
60
+ rescue URI::InvalidURIError
61
+ 'image'
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats Slack Block Kit blocks for terminal display
6
+ class BlockFormatter
7
+ def initialize(text_processor:)
8
+ @text_processor = text_processor
9
+ end
10
+
11
+ def format(blocks, main_text, lines, options)
12
+ return unless blocks&.any?
13
+ return if options[:no_blocks]
14
+
15
+ block_texts = extract_texts(blocks)
16
+ block_texts = filter_duplicate_texts(block_texts, main_text)
17
+
18
+ return if block_texts.empty?
19
+
20
+ lines << ''
21
+ block_texts.each { |text| format_block_text(text, lines, options) }
22
+ end
23
+
24
+ private
25
+
26
+ def extract_texts(blocks)
27
+ return [] unless blocks.is_a?(Array)
28
+
29
+ blocks.filter_map do |block|
30
+ block.dig('text', 'text') if block['type'] == 'section'
31
+ end
32
+ end
33
+
34
+ def filter_duplicate_texts(block_texts, main_text)
35
+ normalized_main = normalize(main_text)
36
+ block_texts.reject { |bt| normalize(bt) == normalized_main }
37
+ end
38
+
39
+ def normalize(text)
40
+ text.to_s.gsub(/\s+/, ' ').strip.downcase
41
+ end
42
+
43
+ def format_block_text(block_text, lines, options)
44
+ processed = @text_processor.call(block_text)
45
+ processed = wrap_text(processed, options[:width])
46
+
47
+ processed.each_line { |line| lines << "> #{line.chomp}" }
48
+ end
49
+
50
+ def wrap_text(text, width)
51
+ return text unless width && width > 2
52
+
53
+ Support::TextWrapper.wrap(text, width - 2, width - 2)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,25 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
4
  module Formatters
5
+ # Formats Duration objects for display
5
6
  class DurationFormatter
6
7
  def format(duration)
7
- return "" if duration.nil? || duration.zero?
8
+ return '' if duration.nil? || duration.zero?
8
9
 
9
10
  duration.to_s
10
11
  end
11
12
 
12
13
  def format_remaining(seconds)
13
- return "" if seconds.nil? || seconds <= 0
14
+ return '' if seconds.nil? || seconds <= 0
14
15
 
15
16
  Models::Duration.new(seconds: seconds).to_s
16
17
  end
17
18
 
18
19
  def format_until(timestamp)
19
- return "" if timestamp.nil? || timestamp <= 0
20
+ return '' if timestamp.nil? || timestamp <= 0
20
21
 
21
22
  remaining = timestamp - Time.now.to_i
22
- return "expired" if remaining <= 0
23
+ return 'expired' if remaining <= 0
23
24
 
24
25
  format_remaining(remaining)
25
26
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Replaces :emoji: codes with unicode characters
6
+ class EmojiReplacer
7
+ EMOJI_REGEX = /:([a-zA-Z0-9_+-]+):/
8
+ SKIN_TONE_REGEX = /::skin-tone-(\d)/
9
+
10
+ # Common emoji mappings (subset - full list would be much larger)
11
+ EMOJI_MAP = {
12
+ # Faces
13
+ 'smile' => "\u{1F604}", 'grinning' => "\u{1F600}", 'joy' => "\u{1F602}",
14
+ 'rofl' => "\u{1F923}", 'smiley' => "\u{1F603}", 'sweat_smile' => "\u{1F605}",
15
+ 'laughing' => "\u{1F606}", 'wink' => "\u{1F609}", 'blush' => "\u{1F60A}",
16
+ 'yum' => "\u{1F60B}", 'sunglasses' => "\u{1F60E}", 'heart_eyes' => "\u{1F60D}",
17
+ 'kissing_heart' => "\u{1F618}", 'thinking' => "\u{1F914}", 'thinking_face' => "\u{1F914}",
18
+ 'raised_eyebrow' => "\u{1F928}", 'neutral_face' => "\u{1F610}", 'expressionless' => "\u{1F611}",
19
+ 'unamused' => "\u{1F612}", 'rolling_eyes' => "\u{1F644}", 'grimacing' => "\u{1F62C}",
20
+ 'relieved' => "\u{1F60C}", 'pensive' => "\u{1F614}", 'sleepy' => "\u{1F62A}",
21
+ 'sleeping' => "\u{1F634}", 'sob' => "\u{1F62D}", 'cry' => "\u{1F622}",
22
+ 'scream' => "\u{1F631}", 'angry' => "\u{1F620}", 'rage' => "\u{1F621}",
23
+
24
+ # Gestures
25
+ 'wave' => "\u{1F44B}", '+1' => "\u{1F44D}", '-1' => "\u{1F44E}",
26
+ 'thumbsup' => "\u{1F44D}", 'thumbsdown' => "\u{1F44E}",
27
+ 'clap' => "\u{1F44F}", 'raised_hands' => "\u{1F64C}", 'pray' => "\u{1F64F}",
28
+ 'point_up' => "\u{261D}", 'point_down' => "\u{1F447}", 'point_left' => "\u{1F448}",
29
+ 'point_right' => "\u{1F449}", 'ok_hand' => "\u{1F44C}", 'v' => "\u{270C}",
30
+ 'muscle' => "\u{1F4AA}", 'fist' => "\u{270A}",
31
+
32
+ # Hearts
33
+ 'heart' => "\u{2764}", 'hearts' => "\u{2665}", 'yellow_heart' => "\u{1F49B}",
34
+ 'green_heart' => "\u{1F49A}", 'blue_heart' => "\u{1F499}", 'purple_heart' => "\u{1F49C}",
35
+ 'black_heart' => "\u{1F5A4}", 'broken_heart' => "\u{1F494}", 'sparkling_heart' => "\u{1F496}",
36
+
37
+ # Objects
38
+ 'fire' => "\u{1F525}", 'star' => "\u{2B50}", 'sparkles' => "\u{2728}",
39
+ 'boom' => "\u{1F4A5}", 'zap' => "\u{26A1}", 'sunny' => "\u{2600}",
40
+ 'cloud' => "\u{2601}", 'umbrella' => "\u{2614}", 'snowflake' => "\u{2744}",
41
+ 'rocket' => "\u{1F680}", 'airplane' => "\u{2708}", 'car' => "\u{1F697}",
42
+ 'gift' => "\u{1F381}", 'trophy' => "\u{1F3C6}", 'medal' => "\u{1F3C5}",
43
+ 'bell' => "\u{1F514}", 'key' => "\u{1F511}", 'lock' => "\u{1F512}",
44
+ 'bulb' => "\u{1F4A1}", 'book' => "\u{1F4D6}", 'pencil' => "\u{270F}",
45
+ 'memo' => "\u{1F4DD}", 'computer' => "\u{1F4BB}", 'phone' => "\u{1F4F1}",
46
+ 'camera' => "\u{1F4F7}", 'headphones' => "\u{1F3A7}", 'microphone' => "\u{1F3A4}",
47
+
48
+ # Food
49
+ 'coffee' => "\u{2615}", 'tea' => "\u{1F375}", 'beer' => "\u{1F37A}",
50
+ 'wine_glass' => "\u{1F377}", 'pizza' => "\u{1F355}", 'hamburger' => "\u{1F354}",
51
+ 'cake' => "\u{1F370}", 'cookie' => "\u{1F36A}", 'apple' => "\u{1F34E}",
52
+ 'banana' => "\u{1F34C}", 'taco' => "\u{1F32E}", 'burrito' => "\u{1F32F}",
53
+ 'knife_fork_plate' => "\u{1F37D}",
54
+
55
+ # Nature
56
+ 'dog' => "\u{1F436}", 'cat' => "\u{1F431}", 'mouse' => "\u{1F42D}",
57
+ 'rabbit' => "\u{1F430}", 'bear' => "\u{1F43B}", 'panda_face' => "\u{1F43C}",
58
+ 'chicken' => "\u{1F414}", 'penguin' => "\u{1F427}", 'bird' => "\u{1F426}",
59
+ 'fish' => "\u{1F41F}", 'bug' => "\u{1F41B}", 'bee' => "\u{1F41D}",
60
+ 'rose' => "\u{1F339}", 'sunflower' => "\u{1F33B}", 'tree' => "\u{1F333}",
61
+ 'cactus' => "\u{1F335}", 'palm_tree' => "\u{1F334}",
62
+
63
+ # Symbols
64
+ 'white_check_mark' => "\u{2705}", 'heavy_check_mark' => "\u{2714}",
65
+ 'x' => "\u{274C}", 'heavy_multiplication_x' => "\u{2716}",
66
+ 'warning' => "\u{26A0}", 'no_entry' => "\u{26D4}", 'sos' => "\u{1F198}",
67
+ 'question' => "\u{2753}", 'exclamation' => "\u{2757}", 'bangbang' => "\u{203C}",
68
+ '100' => "\u{1F4AF}", '1234' => "\u{1F522}",
69
+
70
+ # Status-related
71
+ 'house' => "\u{1F3E0}", 'office' => "\u{1F3E2}", 'hospital' => "\u{1F3E5}",
72
+ 'calendar' => "\u{1F4C5}", 'date' => "\u{1F4C5}", 'spiral_calendar' => "\u{1F5D3}",
73
+ 'clock1' => "\u{1F550}", 'hourglass' => "\u{231B}", 'stopwatch' => "\u{23F1}",
74
+ 'zzz' => "\u{1F4A4}", 'speech_balloon' => "\u{1F4AC}", 'thought_balloon' => "\u{1F4AD}",
75
+
76
+ # Common Slack custom
77
+ 'party-blob' => "\u{1F389}", 'blob-wave' => "\u{1F44B}",
78
+ 'tada' => "\u{1F389}", 'confetti_ball' => "\u{1F38A}",
79
+ 'balloon' => "\u{1F388}", 'party_popper' => "\u{1F389}",
80
+ 'eyes' => "\u{1F440}", 'eye' => "\u{1F441}",
81
+ 'ear' => "\u{1F442}", 'nose' => "\u{1F443}",
82
+ 'brb' => "\u{1F6B6}", 'away' => "\u{1F6B6}",
83
+ 'test_tube' => "\u{1F9EA}"
84
+ }.freeze
85
+
86
+ def initialize(custom_emoji: {}, on_debug: nil)
87
+ @custom_emoji = custom_emoji
88
+ @on_debug = on_debug
89
+ @gemoji_cache = load_gemoji_cache
90
+ end
91
+
92
+ def replace(text, _workspace = nil)
93
+ result = text.dup
94
+
95
+ # Remove skin tone modifiers (we don't render them in terminal)
96
+ result.gsub!(SKIN_TONE_REGEX, '')
97
+
98
+ # Replace emoji codes
99
+ result.gsub!(EMOJI_REGEX) do
100
+ name = ::Regexp.last_match(1)
101
+ lookup_emoji(name) || ":#{name}:"
102
+ end
103
+
104
+ result
105
+ end
106
+
107
+ def lookup_emoji(name)
108
+ # Check custom emoji first
109
+ return nil if @custom_emoji[name] # Custom emoji are URLs, skip for now
110
+
111
+ # Check gemoji cache first (from sync-standard)
112
+ return @gemoji_cache[name] if @gemoji_cache&.key?(name)
113
+
114
+ # Fall back to built-in map
115
+ EMOJI_MAP[name]
116
+ end
117
+
118
+ def with_custom_emoji(emoji_hash)
119
+ self.class.new(custom_emoji: emoji_hash, on_debug: @on_debug)
120
+ end
121
+
122
+ private
123
+
124
+ def load_gemoji_cache
125
+ cache_path = gemoji_cache_path
126
+ return nil unless File.exist?(cache_path)
127
+
128
+ JSON.parse(File.read(cache_path))
129
+ rescue JSON::ParserError => e
130
+ @on_debug&.call("Failed to load gemoji cache: #{e.message}")
131
+ nil
132
+ rescue Errno::ENOENT
133
+ nil
134
+ end
135
+
136
+ def gemoji_cache_path
137
+ File.join(ENV.fetch('XDG_CACHE_HOME', File.expand_path('~/.cache')), 'slk', 'gemoji.json')
138
+ end
139
+ end
140
+ end
141
+ end