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,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Messages < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ target = positional_args.first
13
+ unless target
14
+ error("Usage: slk messages <channel|@user|url>")
15
+ return 1
16
+ end
17
+
18
+ workspace, channel_id, thread_ts, msg_ts = resolve_target(target)
19
+
20
+ # Apply default limits based on target type
21
+ apply_default_limit(msg_ts)
22
+
23
+ messages = fetch_messages(workspace, channel_id, thread_ts, oldest: msg_ts)
24
+
25
+ # Enrich with reaction timestamps if requested
26
+ if @options[:reaction_timestamps]
27
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
28
+ messages = enricher.enrich_messages(messages, channel_id)
29
+ end
30
+
31
+ if @options[:json]
32
+ format_options = {
33
+ no_names: @options[:no_names],
34
+ reaction_timestamps: @options[:reaction_timestamps]
35
+ }
36
+ output_json(messages.map { |m| runner.message_formatter.format_json(m, workspace: workspace, options: format_options) })
37
+ else
38
+ display_messages(messages, workspace, channel_id)
39
+ end
40
+
41
+ 0
42
+ rescue ApiError => e
43
+ error("Failed to fetch messages: #{e.message}")
44
+ 1
45
+ end
46
+
47
+ protected
48
+
49
+ def default_options
50
+ super.merge(
51
+ limit: 500,
52
+ limit_set: false,
53
+ threads: false,
54
+ no_emoji: false,
55
+ no_reactions: false,
56
+ no_names: false,
57
+ workspace_emoji: true, # Default to showing workspace emoji as images
58
+ reaction_names: false,
59
+ reaction_timestamps: false
60
+ )
61
+ end
62
+
63
+ def handle_option(arg, args, remaining)
64
+ case arg
65
+ when "-n", "--limit"
66
+ @options[:limit] = args.shift.to_i
67
+ @options[:limit_set] = true
68
+ when "--threads"
69
+ @options[:threads] = true
70
+ when "--no-emoji"
71
+ @options[:no_emoji] = true
72
+ when "--no-reactions"
73
+ @options[:no_reactions] = true
74
+ when "--no-names"
75
+ @options[:no_names] = true
76
+ when "--no-workspace-emoji"
77
+ @options[:workspace_emoji] = false
78
+ when "--reaction-names"
79
+ @options[:reaction_names] = true
80
+ when "--reaction-timestamps"
81
+ @options[:reaction_timestamps] = true
82
+ else
83
+ super
84
+ end
85
+ end
86
+
87
+ def help_text
88
+ help = Support::HelpFormatter.new("slk messages <target> [options]")
89
+ help.description("Read messages from a channel, DM, or thread.")
90
+
91
+ help.section("TARGET") do |s|
92
+ s.item("#channel", "Channel by name")
93
+ s.item("channel", "Channel by name (without #)")
94
+ s.item("@user", "Direct message with user")
95
+ s.item("C123ABC", "Channel by ID")
96
+ s.item("<slack_url>", "Slack message URL (returns message + subsequent)")
97
+ end
98
+
99
+ help.section("OPTIONS") do |s|
100
+ s.option("-n, --limit N", "Number of messages (default: 500, or 50 for message URLs)")
101
+ s.option("--threads", "Show thread replies inline")
102
+ s.option("--no-emoji", "Show :emoji: codes instead of unicode")
103
+ s.option("--no-reactions", "Hide reactions")
104
+ s.option("--no-names", "Skip user name lookups (faster)")
105
+ s.option("--no-workspace-emoji", "Disable workspace emoji images")
106
+ s.option("--reaction-names", "Show reactions with user names")
107
+ s.option("--reaction-timestamps", "Show when each person reacted")
108
+ s.option("--width N", "Wrap text at N columns (default: 72 on TTY, no wrap otherwise)")
109
+ s.option("--no-wrap", "Disable text wrapping")
110
+ s.option("--json", "Output as JSON")
111
+ s.option("-w, --workspace", "Specify workspace")
112
+ s.option("-v, --verbose", "Show debug information")
113
+ s.option("-q, --quiet", "Suppress output")
114
+ end
115
+
116
+ help.render
117
+ end
118
+
119
+ private
120
+
121
+ def resolve_target(target)
122
+ url_parser = Support::SlackUrlParser.new
123
+
124
+ # Check if it's a Slack URL
125
+ if url_parser.slack_url?(target)
126
+ result = url_parser.parse(target)
127
+ if result
128
+ ws = runner.workspace(result.workspace)
129
+ # thread_ts means it's a thread, msg_ts means start from that message
130
+ if result.thread?
131
+ return [ws, result.channel_id, result.thread_ts, nil]
132
+ else
133
+ return [ws, result.channel_id, nil, result.msg_ts]
134
+ end
135
+ end
136
+ end
137
+
138
+ workspace = target_workspaces.first
139
+
140
+ # Direct channel ID
141
+ if target.match?(/^[CDG][A-Z0-9]+$/)
142
+ return [workspace, target, nil, nil]
143
+ end
144
+
145
+ # Channel by name
146
+ if target.start_with?("#") || !target.start_with?("@")
147
+ channel_name = target.delete_prefix("#")
148
+ channel_id = resolve_channel(workspace, channel_name)
149
+ return [workspace, channel_id, nil, nil]
150
+ end
151
+
152
+ # DM by username
153
+ if target.start_with?("@")
154
+ username = target.delete_prefix("@")
155
+ channel_id = resolve_dm(workspace, username)
156
+ return [workspace, channel_id, nil, nil]
157
+ end
158
+
159
+ raise ConfigError, "Could not resolve target: #{target}"
160
+ end
161
+
162
+ def resolve_channel(workspace, name)
163
+ # Check cache first
164
+ cached = cache_store.get_channel_id(workspace.name, name)
165
+ return cached if cached
166
+
167
+ # Search via API
168
+ api = runner.conversations_api(workspace.name)
169
+ response = api.list
170
+
171
+ channels = response["channels"] || []
172
+ channel = channels.find { |c| c["name"] == name }
173
+
174
+ if channel
175
+ cache_store.set_channel(workspace.name, name, channel["id"])
176
+ return channel["id"]
177
+ end
178
+
179
+ raise ConfigError, "Channel not found: ##{name}"
180
+ end
181
+
182
+ def resolve_dm(workspace, username)
183
+ # Find user ID
184
+ user_id = find_user_id(workspace, username)
185
+ raise ConfigError, "User not found: @#{username}" unless user_id
186
+
187
+ # Open DM
188
+ api = runner.conversations_api(workspace.name)
189
+ response = api.open(users: user_id)
190
+ response.dig("channel", "id")
191
+ end
192
+
193
+ def find_user_id(workspace, username)
194
+ # Check cache
195
+ # Note: We need reverse lookup, which cache_store doesn't support directly
196
+ # For now, fetch user list and search
197
+
198
+ api = runner.users_api(workspace.name)
199
+ response = api.list
200
+
201
+ users = response["members"] || []
202
+ user = users.find do |u|
203
+ u["name"] == username ||
204
+ u.dig("profile", "display_name") == username ||
205
+ u.dig("profile", "real_name") == username
206
+ end
207
+
208
+ user&.dig("id")
209
+ end
210
+
211
+ # Apply default limit based on target type (50 for message URLs, 500 otherwise)
212
+ def apply_default_limit(msg_ts)
213
+ return if @options[:limit_set]
214
+
215
+ @options[:limit] = msg_ts ? 50 : 500
216
+ end
217
+
218
+ def fetch_messages(workspace, channel_id, thread_ts = nil, oldest: nil)
219
+ api = runner.conversations_api(workspace.name)
220
+
221
+ if thread_ts
222
+ # For threads, paginate to fetch all replies
223
+ messages = fetch_all_thread_replies(api, channel_id, thread_ts)
224
+
225
+ # Apply limit (keep parent + last N-1 replies)
226
+ if @options[:limit] > 0 && messages.length > @options[:limit]
227
+ messages = [messages.first] + messages.last(@options[:limit] - 1)
228
+ end
229
+ else
230
+ # For channel history, use oldest parameter if provided
231
+ # Slack API oldest is exclusive - decrement slightly to include the target message
232
+ oldest_adjusted = oldest ? adjust_timestamp(oldest, -0.000001) : nil
233
+ response = api.history(channel: channel_id, limit: @options[:limit], oldest: oldest_adjusted)
234
+ messages = response["messages"] || []
235
+ end
236
+
237
+ # Convert to model objects
238
+ messages = messages.map { |m| Models::Message.from_api(m, channel_id: channel_id) }
239
+
240
+ # Reverse to show oldest first
241
+ messages.reverse
242
+ end
243
+
244
+ # Adjust a Slack timestamp by a small amount while preserving precision
245
+ def adjust_timestamp(ts, delta)
246
+ require 'bigdecimal'
247
+ (BigDecimal(ts) + BigDecimal(delta.to_s)).to_s('F')
248
+ end
249
+
250
+ def fetch_all_thread_replies(api, channel_id, thread_ts)
251
+ all_messages = []
252
+ cursor = nil
253
+
254
+ loop do
255
+ response = api.replies(channel: channel_id, ts: thread_ts, limit: 200, cursor: cursor)
256
+ page_messages = response["messages"] || []
257
+ all_messages.concat(page_messages)
258
+
259
+ debug("Fetched #{page_messages.length} messages, total: #{all_messages.length}")
260
+
261
+ cursor = response.dig("response_metadata", "next_cursor")
262
+ break if cursor.nil? || cursor.empty? || !response["has_more"]
263
+ end
264
+
265
+ # Deduplicate and sort by timestamp
266
+ all_messages
267
+ .uniq { |m| m["ts"] }
268
+ .sort_by { |m| m["ts"].to_f }
269
+ end
270
+
271
+ def display_messages(messages, workspace, channel_id)
272
+ formatter = runner.message_formatter
273
+ format_options = {
274
+ no_emoji: @options[:no_emoji],
275
+ no_reactions: @options[:no_reactions],
276
+ no_names: @options[:no_names],
277
+ workspace_emoji: @options[:workspace_emoji],
278
+ reaction_names: @options[:reaction_names],
279
+ width: @options[:width]
280
+ }
281
+
282
+ messages.each_with_index do |message, index|
283
+ formatted = formatter.format(message, workspace: workspace, options: format_options)
284
+ puts formatted
285
+ puts if index < messages.length - 1
286
+
287
+ # Show thread replies if requested
288
+ if @options[:threads] && message.has_thread? && !message.is_reply?
289
+ show_thread_replies(workspace, channel_id, message, format_options)
290
+ end
291
+ end
292
+ end
293
+
294
+ def show_thread_replies(workspace, channel_id, parent_message, format_options)
295
+ api = runner.conversations_api(workspace.name)
296
+ formatter = runner.message_formatter
297
+
298
+ # Fetch all replies with pagination
299
+ replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
300
+
301
+ # Skip the parent message (first one) and show replies
302
+ replies[1..].each do |reply_data|
303
+ reply = Models::Message.from_api(reply_data, channel_id: channel_id)
304
+ formatted = formatter.format(reply, workspace: workspace, options: format_options)
305
+
306
+ # Indent multiline messages so continuation lines align with the first line
307
+ lines = formatted.lines
308
+ first_line = " └ #{lines.first}"
309
+ continuation_lines = lines[1..].map { |line| " #{line}" }
310
+
311
+ puts first_line
312
+ continuation_lines.each { |line| puts line }
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Presence < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ case positional_args
13
+ in ["away"]
14
+ set_presence("away")
15
+ in ["auto" | "active"]
16
+ set_presence("auto")
17
+ in []
18
+ get_presence
19
+ else
20
+ error("Unknown presence: #{positional_args.first}")
21
+ error("Valid options: away, auto, active")
22
+ 1
23
+ end
24
+ rescue ApiError => e
25
+ error("Failed: #{e.message}")
26
+ 1
27
+ end
28
+
29
+ protected
30
+
31
+ def help_text
32
+ help = Support::HelpFormatter.new("slk presence [away|auto|active]")
33
+ help.description("Get or set your presence status.")
34
+ help.note("GET shows all workspaces by default. SET applies to primary only.")
35
+
36
+ help.section("ACTIONS") do |s|
37
+ s.action("(none)", "Show current presence (all workspaces)")
38
+ s.action("away", "Set presence to away")
39
+ s.action("auto", "Set presence to auto (active)")
40
+ s.action("active", "Alias for auto")
41
+ end
42
+
43
+ help.section("OPTIONS") do |s|
44
+ s.option("-w, --workspace", "Limit to specific workspace")
45
+ s.option("--all", "Set across all workspaces")
46
+ s.option("-q, --quiet", "Suppress output")
47
+ end
48
+
49
+ help.render
50
+ end
51
+
52
+ private
53
+
54
+ def get_presence
55
+ # GET defaults to all workspaces unless -w specified
56
+ workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
57
+
58
+ workspaces.each do |workspace|
59
+ data = runner.users_api(workspace.name).get_presence
60
+
61
+ if workspaces.size > 1
62
+ puts output.bold(workspace.name)
63
+ end
64
+
65
+ presence = data[:presence]
66
+ manual = data[:manual_away]
67
+
68
+ status = case [presence, manual]
69
+ in ["away", true]
70
+ output.yellow("away (manual)")
71
+ in ["away", _]
72
+ output.yellow("away")
73
+ in ["active", _]
74
+ output.green("active")
75
+ else
76
+ presence
77
+ end
78
+
79
+ puts " Presence: #{status}"
80
+ end
81
+
82
+ 0
83
+ end
84
+
85
+ def set_presence(presence)
86
+ target_workspaces.each do |workspace|
87
+ runner.users_api(workspace.name).set_presence(presence)
88
+
89
+ status_text = presence == "away" ? output.yellow("away") : output.green("active")
90
+ success("Presence set to #{status_text} on #{workspace.name}")
91
+ end
92
+
93
+ show_all_workspaces_hint
94
+
95
+ 0
96
+ end
97
+
98
+ def show_all_workspaces_hint
99
+ # Show hint if user has multiple workspaces and didn't use --all or -w
100
+ return if @options[:all] || @options[:workspace]
101
+ return if runner.all_workspaces.size <= 1
102
+
103
+ info("Tip: Use --all to set across all workspaces")
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/inline_images"
4
+ require_relative "../support/help_formatter"
5
+
6
+ module SlackCli
7
+ module Commands
8
+ class Preset < Base
9
+ include Support::InlineImages
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ case positional_args
15
+ in ["list" | "ls"] | []
16
+ list_presets
17
+ in ["add"]
18
+ add_preset
19
+ in ["edit", name]
20
+ edit_preset(name)
21
+ in ["delete" | "rm", name]
22
+ delete_preset(name)
23
+ in [name, *]
24
+ apply_preset(name)
25
+ end
26
+ end
27
+
28
+ protected
29
+
30
+ def help_text
31
+ help = Support::HelpFormatter.new("slk preset <action|name> [options]")
32
+ help.description("Manage and apply status presets.")
33
+
34
+ help.section("ACTIONS") do |s|
35
+ s.action("list", "List all presets")
36
+ s.action("add", "Add a new preset (interactive)")
37
+ s.action("edit <name>", "Edit an existing preset")
38
+ s.action("delete <name>", "Delete a preset")
39
+ s.action("<name>", "Apply a preset")
40
+ end
41
+
42
+ help.section("EXAMPLES") do |s|
43
+ s.example("slk preset list")
44
+ s.example("slk preset meeting")
45
+ s.example("slk preset add")
46
+ end
47
+
48
+ help.section("OPTIONS") do |s|
49
+ s.option("-w, --workspace", "Specify workspace")
50
+ s.option("--all", "Apply to all workspaces")
51
+ s.option("-q, --quiet", "Suppress output")
52
+ end
53
+
54
+ help.render
55
+ end
56
+
57
+ private
58
+
59
+ def list_presets
60
+ presets = preset_store.all
61
+
62
+ if presets.empty?
63
+ puts "No presets configured."
64
+ return 0
65
+ end
66
+
67
+ puts "Presets:"
68
+ presets.each do |preset|
69
+ puts " #{output.bold(preset.name)}"
70
+ display_preset_status(preset) unless preset.text.empty? && preset.emoji.empty?
71
+ puts " Duration: #{preset.duration}" unless preset.duration == "0"
72
+ puts " Presence: #{preset.presence}" if preset.sets_presence?
73
+ puts " DND: #{preset.dnd}" if preset.sets_dnd?
74
+ end
75
+
76
+ 0
77
+ end
78
+
79
+ def display_preset_status(preset)
80
+ emoji_name = preset.emoji.delete_prefix(":").delete_suffix(":")
81
+ emoji_path = find_workspace_emoji_any(emoji_name)
82
+
83
+ if emoji_path && inline_images_supported?
84
+ text = " #{preset.text}"
85
+ print_inline_image_with_text(emoji_path, text)
86
+ else
87
+ puts " #{preset.emoji} #{preset.text}"
88
+ end
89
+ end
90
+
91
+ def find_workspace_emoji_any(emoji_name)
92
+ return nil if emoji_name.empty?
93
+
94
+ paths = Support::XdgPaths.new
95
+ emoji_dir = config.emoji_dir || paths.cache_dir
96
+
97
+ # Search across all workspaces
98
+ runner.all_workspaces.each do |workspace|
99
+ workspace_dir = File.join(emoji_dir, workspace.name)
100
+ next unless Dir.exist?(workspace_dir)
101
+
102
+ path = Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
103
+ return path if path
104
+ end
105
+
106
+ nil
107
+ end
108
+
109
+ def add_preset
110
+ print "Preset name: "
111
+ name = $stdin.gets&.chomp
112
+ return error("Name is required") if name.nil? || name.empty?
113
+
114
+ print "Status text: "
115
+ text = $stdin.gets&.chomp || ""
116
+
117
+ print "Emoji (e.g., :calendar:): "
118
+ emoji = $stdin.gets&.chomp || ""
119
+
120
+ print "Duration (e.g., 1h, 30m, or 0 for none): "
121
+ duration = $stdin.gets&.chomp || "0"
122
+
123
+ print "Presence (away/auto or blank): "
124
+ presence = $stdin.gets&.chomp || ""
125
+
126
+ print "DND (e.g., 1h, off, or blank): "
127
+ dnd = $stdin.gets&.chomp || ""
128
+
129
+ preset = Models::Preset.new(
130
+ name: name,
131
+ text: text,
132
+ emoji: emoji,
133
+ duration: duration,
134
+ presence: presence,
135
+ dnd: dnd
136
+ )
137
+
138
+ preset_store.add(preset)
139
+ success("Preset '#{name}' created")
140
+
141
+ 0
142
+ end
143
+
144
+ def edit_preset(name)
145
+ preset = preset_store.get(name)
146
+ return error("Preset '#{name}' not found") unless preset
147
+
148
+ puts "Editing preset '#{name}' (press Enter to keep current value)"
149
+
150
+ print "Status text [#{preset.text}]: "
151
+ text = $stdin.gets&.chomp
152
+ text = preset.text if text.empty?
153
+
154
+ print "Emoji [#{preset.emoji}]: "
155
+ emoji = $stdin.gets&.chomp
156
+ emoji = preset.emoji if emoji.empty?
157
+
158
+ print "Duration [#{preset.duration}]: "
159
+ duration = $stdin.gets&.chomp
160
+ duration = preset.duration if duration.empty?
161
+
162
+ print "Presence [#{preset.presence}]: "
163
+ presence = $stdin.gets&.chomp
164
+ presence = preset.presence if presence.empty?
165
+
166
+ print "DND [#{preset.dnd}]: "
167
+ dnd = $stdin.gets&.chomp
168
+ dnd = preset.dnd if dnd.empty?
169
+
170
+ updated = Models::Preset.new(
171
+ name: name,
172
+ text: text,
173
+ emoji: emoji,
174
+ duration: duration,
175
+ presence: presence,
176
+ dnd: dnd
177
+ )
178
+
179
+ preset_store.add(updated)
180
+ success("Preset '#{name}' updated")
181
+
182
+ 0
183
+ end
184
+
185
+ def delete_preset(name)
186
+ unless preset_store.exists?(name)
187
+ return error("Preset '#{name}' not found")
188
+ end
189
+
190
+ preset_store.remove(name)
191
+ success("Preset '#{name}' deleted")
192
+
193
+ 0
194
+ end
195
+
196
+ def apply_preset(name)
197
+ preset = preset_store.get(name)
198
+ return error("Preset '#{name}' not found") unless preset
199
+
200
+ target_workspaces.each do |workspace|
201
+ # Set status
202
+ unless preset.clears_status?
203
+ duration = preset.duration_value
204
+ runner.users_api(workspace.name).set_status(
205
+ text: preset.text,
206
+ emoji: preset.emoji,
207
+ duration: duration
208
+ )
209
+ else
210
+ runner.users_api(workspace.name).clear_status
211
+ end
212
+
213
+ # Set presence
214
+ if preset.sets_presence?
215
+ runner.users_api(workspace.name).set_presence(preset.presence)
216
+ end
217
+
218
+ # Set DND
219
+ if preset.sets_dnd?
220
+ dnd_api = runner.dnd_api(workspace.name)
221
+ if preset.dnd == "off"
222
+ dnd_api.end_snooze
223
+ else
224
+ duration = Models::Duration.parse(preset.dnd)
225
+ dnd_api.set_snooze(duration)
226
+ end
227
+ end
228
+
229
+ success("Applied preset '#{name}' on #{workspace.name}")
230
+ end
231
+
232
+ 0
233
+ rescue ApiError => e
234
+ error("Failed to apply preset: #{e.message}")
235
+ 1
236
+ end
237
+ end
238
+ end
239
+ end