teems 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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. metadata +110 -0
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Teems
6
+ module Commands
7
+ OOO_HELP = <<~HELP
8
+ teems ooo - Manage out-of-office status
9
+
10
+ USAGE:
11
+ teems ooo Show current OOO status
12
+ teems ooo on [options] Enable out-of-office
13
+ teems ooo off Disable out-of-office
14
+ teems ooo config Show OOO configuration
15
+
16
+ ON OPTIONS:
17
+ --message TEXT Auto-reply and status message
18
+ --start DATE Schedule start (YYYY-MM-DD), enables scheduled mode
19
+ --end DATE Schedule end (YYYY-MM-DD), required with --start
20
+ --event Create an all-day OOO calendar event for notify list
21
+ --no-status Skip setting Teams status/presence
22
+
23
+ CONFIGURATION:
24
+ Edit ~/.config/teems/config.json to set defaults:
25
+
26
+ {
27
+ "ooo": {
28
+ "internal_message": "I'm currently out of office.",
29
+ "external_message": "Thank you for your message. I'm out of office.",
30
+ "external_audience": "all",
31
+ "status_message": "Out of Office",
32
+ "notify": ["manager@example.com", "team@example.com"]
33
+ }
34
+ }
35
+
36
+ EXAMPLES:
37
+ teems ooo # Check OOO status
38
+ teems ooo on # Enable OOO (always on)
39
+ teems ooo on --message "Vacation" # Custom message
40
+ teems ooo on --start 2026-04-14 --end 2026-04-18
41
+ teems ooo off # Disable OOO
42
+ teems ooo config # Show config
43
+ HELP
44
+
45
+ # Displays current OOO status (auto-reply + presence)
46
+ module OooDisplay
47
+ private
48
+
49
+ def show_status
50
+ replies = fetch_auto_replies
51
+ presence = fetch_presence
52
+ render_ooo_status(replies, presence)
53
+ 0
54
+ end
55
+
56
+ def fetch_auto_replies
57
+ with_token_refresh { runner.users_api.auto_replies }
58
+ rescue ApiError => e
59
+ debug("Auto-reply fetch failed: #{e.message}")
60
+ nil
61
+ end
62
+
63
+ def fetch_presence
64
+ with_token_refresh { runner.users_api.my_presence }
65
+ rescue ApiError => e
66
+ debug("Presence fetch failed: #{e.message}")
67
+ nil
68
+ end
69
+
70
+ def render_ooo_status(replies, presence)
71
+ if @options[:json]
72
+ output_json({ auto_replies: replies, presence: presence })
73
+ else
74
+ render_ooo_text(replies, presence)
75
+ end
76
+ end
77
+
78
+ def render_ooo_text(replies, presence)
79
+ render_auto_reply_status(replies)
80
+ render_presence_status(presence)
81
+ end
82
+
83
+ def render_auto_reply_status(replies)
84
+ return puts('Auto-replies: unknown (permission denied)') unless replies
85
+
86
+ status = replies['status'] || 'disabled'
87
+ puts "Auto-replies: #{status}"
88
+ render_schedule(replies) if status == 'scheduled'
89
+ render_reply_message(replies)
90
+ end
91
+
92
+ def render_schedule(replies)
93
+ start_dt = replies.dig('scheduledStartDateTime', 'dateTime')
94
+ end_dt = replies.dig('scheduledEndDateTime', 'dateTime')
95
+ puts " Schedule: #{format_dt(start_dt)} to #{format_dt(end_dt)}" if start_dt
96
+ end
97
+
98
+ def format_dt(raw)
99
+ Time.parse(raw).strftime('%Y-%m-%d %H:%M')
100
+ rescue ArgumentError
101
+ raw.to_s
102
+ end
103
+
104
+ def render_reply_message(replies)
105
+ plain = strip_reply_html(replies['internalReplyMessage'])
106
+ puts " Message: #{plain[0..80]}" unless plain.empty?
107
+ end
108
+
109
+ def strip_reply_html(raw) = raw.to_s.gsub(/<[^>]+>/, '').strip
110
+
111
+ def render_presence_status(presence)
112
+ return unless presence
113
+
114
+ availability = presence['availability'] || 'Unknown'
115
+ puts "Presence: #{availability}"
116
+ msg = presence.dig('statusMessage', 'message', 'content')
117
+ puts " Status: #{msg}" if msg && !msg.empty?
118
+ end
119
+ end
120
+
121
+ # Enables/disables OOO: auto-reply, status message, presence, calendar event
122
+ module OooActions
123
+ private
124
+
125
+ def enable_ooo
126
+ result = validate_create_options
127
+ return result if result
128
+
129
+ results = execute_ooo_steps
130
+ summarize_results(results)
131
+ results.values.all? ? 0 : 1
132
+ end
133
+
134
+ def disable_ooo
135
+ results = {}
136
+ results[:auto_reply] = disable_auto_reply
137
+ results[:status] = clear_ooo_status unless @options[:no_status]
138
+ summarize_disable_results(results)
139
+ results.values.all? ? 0 : 1
140
+ end
141
+
142
+ def validate_create_options
143
+ validate_schedule_dates || validate_date_format(:start) || validate_date_format(:end)
144
+ end
145
+
146
+ def validate_schedule_dates
147
+ return unless @options[:start] && !@options[:end]
148
+
149
+ error('--end is required when using --start')
150
+ 1
151
+ end
152
+
153
+ def validate_date_format(key)
154
+ value = @options[key]
155
+ return unless value
156
+
157
+ Date.parse(value)
158
+ nil
159
+ rescue ArgumentError
160
+ error("Invalid date for --#{key}: #{value}. Expected format: YYYY-MM-DD")
161
+ 1
162
+ end
163
+
164
+ def execute_ooo_steps
165
+ results = {}
166
+ results[:auto_reply] = set_auto_reply
167
+ results[:status] = set_ooo_status unless @options[:no_status]
168
+ results[:event] = create_ooo_event if @options[:event] && !notify_list.empty?
169
+ results
170
+ end
171
+
172
+ def set_auto_reply
173
+ settings = build_auto_reply_settings
174
+ with_token_refresh { runner.users_api.update_auto_replies(settings) }
175
+ success('Auto-reply enabled')
176
+ true
177
+ rescue ApiError => e
178
+ warn_step('auto-reply', e)
179
+ false
180
+ end
181
+
182
+ def disable_auto_reply
183
+ with_token_refresh { runner.users_api.update_auto_replies(status: 'disabled') }
184
+ success('Auto-reply disabled')
185
+ true
186
+ rescue ApiError => e
187
+ warn_step('auto-reply', e)
188
+ false
189
+ end
190
+
191
+ def set_ooo_status
192
+ message = status_message_text
193
+ apply_ooo_presence(message)
194
+ success("Status set: #{message}")
195
+ true
196
+ rescue ApiError => e
197
+ warn_step('status/presence', e)
198
+ false
199
+ end
200
+
201
+ def apply_ooo_presence(message)
202
+ users = runner.users_api
203
+ with_token_refresh { users.set_status_message(message: message, expiry: nil) }
204
+ with_token_refresh { users.set_presence(availability: 'Offline', activity: 'OffWork', duration: 'PT8H') }
205
+ end
206
+
207
+ def clear_ooo_status
208
+ users = runner.users_api
209
+ with_token_refresh { users.clear_status_message }
210
+ with_token_refresh { users.clear_presence }
211
+ success('Status and presence cleared')
212
+ true
213
+ rescue ApiError => e
214
+ warn_step('status/presence', e)
215
+ false
216
+ end
217
+
218
+ def create_ooo_event
219
+ body = build_ooo_event
220
+ with_token_refresh { runner.calendar_api.create_event(body) }
221
+ success("Calendar event created for #{notify_list.length} recipient(s)")
222
+ true
223
+ rescue ApiError => e
224
+ warn_step('calendar event', e)
225
+ false
226
+ end
227
+
228
+ def warn_step(step, err)
229
+ output.warn("#{step}: #{err.message}")
230
+ end
231
+ end
232
+
233
+ # Builds API request bodies for OOO operations
234
+ module OooBuildHelpers
235
+ private
236
+
237
+ def build_auto_reply_settings
238
+ msg = auto_reply_message
239
+ settings = { status: auto_reply_status,
240
+ internalReplyMessage: msg,
241
+ externalReplyMessage: external_message(msg),
242
+ externalAudience: ooo_config('external_audience') || 'all' }
243
+ add_schedule(settings) if @options[:start]
244
+ settings
245
+ end
246
+
247
+ def auto_reply_status
248
+ @options[:start] ? 'scheduled' : 'alwaysEnabled'
249
+ end
250
+
251
+ def auto_reply_message
252
+ @options[:message] || ooo_config('internal_message') || 'I am currently out of office.'
253
+ end
254
+
255
+ def external_message(internal_fallback)
256
+ ooo_config('external_message') || internal_fallback
257
+ end
258
+
259
+ def status_message_text
260
+ @options[:message] || ooo_config('status_message') || 'Out of Office'
261
+ end
262
+
263
+ def add_schedule(settings)
264
+ tz = detect_timezone
265
+ settings[:scheduledStartDateTime] = { dateTime: "#{@options[:start]}T00:00:00", timeZone: tz }
266
+ settings[:scheduledEndDateTime] = { dateTime: "#{@options[:end]}T23:59:59", timeZone: tz }
267
+ end
268
+
269
+ def build_ooo_event
270
+ { subject: status_message_text,
271
+ start: ooo_event_time(:start),
272
+ end: ooo_event_time(:end),
273
+ isAllDay: true,
274
+ showAs: 'free',
275
+ isReminderOn: false,
276
+ isOnlineMeeting: false,
277
+ transactionId: SecureRandom.uuid,
278
+ attendees: notify_list.map { |email| ooo_attendee(email) },
279
+ responseRequested: false }
280
+ end
281
+
282
+ def ooo_event_time(field)
283
+ date = @options[field] || Date.today.to_s
284
+ end_date = field == :end ? (Date.parse(date) + 1).to_s : date
285
+ tz = detect_timezone
286
+ { dateTime: "#{end_date}T00:00:00", timeZone: tz }
287
+ end
288
+
289
+ def ooo_attendee(email)
290
+ { emailAddress: { address: email }, type: 'required' }
291
+ end
292
+
293
+ def notify_list
294
+ @notify_list ||= ooo_config('notify') || []
295
+ end
296
+
297
+ def ooo_config(key)
298
+ config['ooo']&.dig(key)
299
+ end
300
+ end
301
+
302
+ # Manage out-of-office: auto-reply, status, presence, and calendar event
303
+ class Ooo < Base
304
+ include Support::Timezone
305
+ include OooDisplay
306
+ include OooActions
307
+ include OooBuildHelpers
308
+
309
+ def initialize(args, runner:)
310
+ @options = {}
311
+ super
312
+ end
313
+
314
+ OOO_OPTIONS = {
315
+ '--message' => ->(opts, args) { opts[:message] = args.shift },
316
+ '--start' => ->(opts, args) { opts[:start] = args.shift },
317
+ '--end' => ->(opts, args) { opts[:end] = args.shift },
318
+ '--event' => ->(opts, _args) { opts[:event] = true },
319
+ '--no-status' => ->(opts, _args) { opts[:no_status] = true }
320
+ }.freeze
321
+
322
+ OOO_ACTIONS = {
323
+ 'on' => :enable_ooo, 'off' => :disable_ooo,
324
+ 'config' => :show_config
325
+ }.freeze
326
+
327
+ def execute
328
+ result = validate_options
329
+ return result if result
330
+
331
+ auth_result = require_auth
332
+ return auth_result if auth_result
333
+
334
+ dispatch_action
335
+ end
336
+
337
+ protected
338
+
339
+ def handle_option(arg, pending)
340
+ handler = OOO_OPTIONS[arg]
341
+ return super unless handler
342
+
343
+ handler.call(@options, pending)
344
+ end
345
+
346
+ def help_text = OOO_HELP
347
+
348
+ private
349
+
350
+ def dispatch_action
351
+ action = positional_args.first
352
+ method_name = OOO_ACTIONS[action]
353
+ return send(method_name) if method_name
354
+ return show_status unless action
355
+
356
+ error("Unknown action: #{action}. Use: on, off, config")
357
+ 1
358
+ end
359
+
360
+ def show_config
361
+ ooo = config['ooo'] || {}
362
+ if @options[:json]
363
+ output_json(ooo)
364
+ else
365
+ puts ooo.empty? ? 'No OOO config set. See: teems ooo --help' : JSON.pretty_generate(ooo)
366
+ end
367
+ 0
368
+ end
369
+
370
+ def summarize_results(results)
371
+ return if results.values.all?
372
+
373
+ output.warn("OOO partially enabled: #{count_successes(results)}/#{results.size} steps succeeded")
374
+ end
375
+
376
+ def summarize_disable_results(results)
377
+ return if results.values.all?
378
+
379
+ output.warn("OOO partially disabled: #{count_successes(results)}/#{results.size} steps succeeded")
380
+ end
381
+
382
+ def count_successes(results) = results.values.count(true)
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Walks manager chain and direct reports tree
6
+ module OrgTreeWalker
7
+ private
8
+
9
+ def walk_manager_chain(target_id)
10
+ managers = []
11
+ current_id = target_id
12
+ fetch = method(target_is_me? ? :fetch_my_manager : :fetch_user_manager)
13
+ collect_managers(managers, current_id, fetch)
14
+ end
15
+
16
+ def collect_managers(managers, current_id, fetch)
17
+ while (mgr = fetch.call(current_id))
18
+ managers.unshift(mgr)
19
+ current_id = mgr.id
20
+ fetch = method(:fetch_user_manager)
21
+ end
22
+ managers
23
+ end
24
+
25
+ def fetch_my_manager(_user_id)
26
+ with_token_refresh { runner.users_api.manager_me }
27
+ rescue ApiError => e
28
+ raise unless e.not_found?
29
+
30
+ nil
31
+ end
32
+
33
+ def fetch_user_manager(user_id)
34
+ with_token_refresh { runner.users_api.manager(user_id) }
35
+ rescue ApiError => e
36
+ raise unless e.not_found?
37
+
38
+ nil
39
+ end
40
+
41
+ def walk_reports_tree(user, remaining_depth, fetch)
42
+ return { user: user, reports: [] } if remaining_depth <= 0
43
+
44
+ reports = fetch.call(user.id)
45
+ children = reports.map { |report| walk_reports_tree(report, remaining_depth - 1, method(:fetch_user_reports)) }
46
+ { user: user, reports: children }
47
+ end
48
+
49
+ def fetch_my_reports(_user_id)
50
+ with_token_refresh { runner.users_api.direct_reports_me }
51
+ rescue ApiError => e
52
+ handle_reports_error(e, 'direct reports')
53
+ end
54
+
55
+ def fetch_user_reports(user_id)
56
+ with_token_refresh { runner.users_api.direct_reports(user_id) }
57
+ rescue ApiError => e
58
+ handle_reports_error(e, "direct reports for #{user_id}")
59
+ end
60
+
61
+ def handle_reports_error(err, context)
62
+ debug("Could not fetch #{context}: #{err.message}")
63
+ raise unless recoverable_reports_error?(err)
64
+
65
+ []
66
+ end
67
+
68
+ def recoverable_reports_error?(err) = err.not_found? || err.forbidden?
69
+
70
+ def target_is_me?
71
+ positional_args.empty?
72
+ end
73
+ end
74
+
75
+ # Renders org chart as text tree or JSON
76
+ module OrgRenderer
77
+ # Groups org chart data: managers chain, target user, and reports tree
78
+ OrgData = Struct.new(:managers, :target, :reports) do
79
+ def to_json_hash
80
+ { managers: managers.map(&:to_h), target: target.to_h,
81
+ direct_reports: self.class.json_reports(reports[:reports]) }
82
+ end
83
+
84
+ def self.json_reports(reports)
85
+ reports.map { |report| json_report(*report.values_at(:user, :reports)) }
86
+ end
87
+
88
+ def self.json_report(user, sub_reports)
89
+ user.to_h.merge(direct_reports: json_reports(sub_reports))
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def render_org(org_data)
96
+ if @options[:json]
97
+ output_json(org_data.to_json_hash)
98
+ else
99
+ render_tree(org_data)
100
+ end
101
+ end
102
+
103
+ def render_tree(org_data)
104
+ managers = org_data.managers
105
+ depth = managers.length
106
+ managers.each_with_index { |mgr, index| puts "#{' ' * index}#{format_person(mgr)}" }
107
+ puts "#{' ' * depth}--> #{format_person(org_data.target)}"
108
+ render_reports(org_data.reports[:reports], depth + 1)
109
+ end
110
+
111
+ def render_reports(reports, level)
112
+ reports.each do |node|
113
+ puts "#{' ' * level}#{format_person(node[:user])}"
114
+ render_reports(node[:reports], level + 1)
115
+ end
116
+ end
117
+
118
+ def format_person(profile)
119
+ title = profile.job_title
120
+ name = profile.best_name
121
+ title && !title.empty? ? "#{name} (#{title})" : name.to_s
122
+ end
123
+ end
124
+
125
+ # Show org chart for a user
126
+ class Org < Base
127
+ include OrgTreeWalker
128
+ include OrgRenderer
129
+
130
+ ORG_OPTIONS = {
131
+ '--depth' => ->(opts, args) { opts[:depth] = parse_depth(args.shift) }
132
+ }.freeze
133
+
134
+ def self.parse_depth(value)
135
+ return 1 unless value
136
+
137
+ result = value.to_i
138
+ result >= 0 ? result : 1
139
+ end
140
+
141
+ def initialize(args, runner:)
142
+ @options = {}
143
+ super
144
+ end
145
+
146
+ def execute
147
+ result = validate_options
148
+ return result if result
149
+
150
+ auth_result = require_auth
151
+ return auth_result if auth_result
152
+
153
+ show_org_chart
154
+ end
155
+
156
+ protected
157
+
158
+ def handle_option(arg, pending)
159
+ handler = ORG_OPTIONS[arg]
160
+ return super unless handler
161
+
162
+ handler.call(@options, pending)
163
+ end
164
+
165
+ def help_text
166
+ <<~HELP
167
+ #{output.bold('teems org')} - Show org chart for a user
168
+
169
+ #{output.bold('USAGE:')}
170
+ teems org [options] Org chart for current user
171
+ teems org <query> [options] Org chart for a searched user
172
+
173
+ #{output.bold('OPTIONS:')}
174
+ --depth N Limit direct report depth (default: 1)
175
+ -v, --verbose Show debug output
176
+ -q, --quiet Suppress output
177
+ --json Output as JSON
178
+ -h, --help Show this help
179
+
180
+ #{output.bold('EXAMPLES:')}
181
+ teems org # Your org chart
182
+ teems org john # Org chart for "john"
183
+ teems org --depth 1 # Only immediate reports
184
+ teems org --json # JSON output
185
+ HELP
186
+ end
187
+
188
+ private
189
+
190
+ def show_org_chart
191
+ target = resolve_target
192
+ target ? display_org(target) : 1
193
+ rescue ApiError => e
194
+ org_fetch_error(e)
195
+ end
196
+
197
+ def org_fetch_error(err)
198
+ error("Failed to fetch org chart: #{err.message}")
199
+ 1
200
+ end
201
+
202
+ def display_org(target)
203
+ render_org(fetch_org_data(target))
204
+ 0
205
+ end
206
+
207
+ def fetch_org_data(target)
208
+ managers = walk_manager_chain(target.id)
209
+ report_fetch = method(target_is_me? ? :fetch_my_reports : :fetch_user_reports)
210
+ reports = walk_reports_tree(target, depth, report_fetch)
211
+ OrgRenderer::OrgData.new(managers: managers, target: target, reports: reports)
212
+ end
213
+
214
+ def depth
215
+ @options[:depth] || 1
216
+ end
217
+
218
+ def resolve_target
219
+ query = positional_args.join(' ')
220
+ query.empty? ? with_token_refresh { runner.users_api.me } : resolve_by_search(query)
221
+ end
222
+
223
+ def resolve_by_search(query)
224
+ results = with_token_refresh { runner.users_api.search(query) }
225
+ return results.first unless results.empty?
226
+
227
+ error("No users found matching '#{query}'")
228
+ nil
229
+ end
230
+ end
231
+ end
232
+ end