teems 0.1.0 → 0.3.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.
@@ -15,9 +15,14 @@ module Teems
15
15
 
16
16
  ON OPTIONS:
17
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
18
+ --start TIME Schedule start; enables scheduled mode. Formats:
19
+ YYYY-MM-DD (all-day)
20
+ YYYY-MM-DD HH:MM (timed)
21
+ "today HH:MM", "tomorrow HH:MM", HH:MM
22
+ --end TIME Schedule end, required with --start.
23
+ Must match --start style (date or timed).
24
+ --event Create an OOO calendar event for notify list
25
+ --invite EMAILS Comma-separated invitees; overrides config notify list
21
26
  --no-status Skip setting Teams status/presence
22
27
 
23
28
  CONFIGURATION:
@@ -38,10 +43,43 @@ module Teems
38
43
  teems ooo on # Enable OOO (always on)
39
44
  teems ooo on --message "Vacation" # Custom message
40
45
  teems ooo on --start 2026-04-14 --end 2026-04-18
46
+ teems ooo on --start "today 14:00" --end "today 17:00" \\
47
+ --message "Dr appt"
41
48
  teems ooo off # Disable OOO
42
49
  teems ooo config # Show config
43
50
  HELP
44
51
 
52
+ # A parsed --start or --end value. Produces ISO datetime strings for
53
+ # auto-reply schedules and calendar events. Kind is :timed, :date, or
54
+ # :unscheduled; value is a Time, Date, or nil respectively.
55
+ OooScheduleEntry = Data.define(:kind, :value) do
56
+ def timed? = kind == :timed
57
+ def all_day? = kind == :date
58
+ def unscheduled? = kind == :unscheduled
59
+
60
+ def reply_start_iso
61
+ timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T00:00:00"
62
+ end
63
+
64
+ def reply_end_iso
65
+ timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T23:59:59"
66
+ end
67
+
68
+ def event_start_iso
69
+ base = value || Date.today
70
+ timed? ? base.strftime('%Y-%m-%dT%H:%M:%S') : "#{base}T00:00:00"
71
+ end
72
+
73
+ def event_end_iso
74
+ base = value || Date.today
75
+ return base.strftime('%Y-%m-%dT%H:%M:%S') if timed?
76
+
77
+ "#{base + 1}T00:00:00"
78
+ end
79
+ end
80
+
81
+ UNSCHEDULED_OOO_SCHEDULE = OooScheduleEntry.new(kind: :unscheduled, value: nil)
82
+
45
83
  # Displays current OOO status (auto-reply + presence)
46
84
  module OooDisplay
47
85
  private
@@ -90,13 +128,20 @@ module Teems
90
128
  end
91
129
 
92
130
  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
131
+ start_entry = replies['scheduledStartDateTime']
132
+ return unless start_entry
133
+
134
+ start_str = format_schedule_dt(start_entry)
135
+ end_str = format_schedule_dt(replies['scheduledEndDateTime'])
136
+ puts " Schedule: #{start_str} to #{end_str} #{short_tz_label}"
96
137
  end
97
138
 
98
- def format_dt(raw)
99
- Time.parse(raw).strftime('%Y-%m-%d %H:%M')
139
+ def format_schedule_dt(entry)
140
+ return '' unless entry
141
+
142
+ raw = entry['dateTime']
143
+ time = entry['timeZone'] == 'UTC' ? Time.parse("#{raw} UTC").getlocal : Time.parse(raw)
144
+ time.strftime('%Y-%m-%d %H:%M')
100
145
  rescue ArgumentError
101
146
  raw.to_s
102
147
  end
@@ -118,6 +163,60 @@ module Teems
118
163
  end
119
164
  end
120
165
 
166
+ # Parses and validates --start/--end into @schedule_start and @schedule_end.
167
+ # Each parsed value is a tuple: [:date, Date] for all-day or [:timed, Time] for timed.
168
+ module OooSchedule
169
+ private
170
+
171
+ def validate_create_options
172
+ start_raw = @options[:start]
173
+ end_raw = @options[:end]
174
+ return unless start_raw
175
+ return missing_end_error unless end_raw
176
+
177
+ parse_schedule_pair(start_raw, end_raw)
178
+ end
179
+
180
+ def missing_end_error
181
+ error('--end is required when using --start')
182
+ 1
183
+ end
184
+
185
+ def parse_schedule_pair(start_raw, end_raw)
186
+ @schedule_start = parse_schedule_value(start_raw)
187
+ return invalid_schedule_error(:start, start_raw) unless @schedule_start
188
+
189
+ @schedule_end = parse_schedule_value(end_raw)
190
+ return invalid_schedule_error(:end, end_raw) unless @schedule_end
191
+
192
+ return mixed_schedule_error if schedule_kind(@schedule_start) != schedule_kind(@schedule_end)
193
+
194
+ nil
195
+ end
196
+
197
+ def parse_schedule_value(raw)
198
+ time = parse_time_input(raw)
199
+ return OooScheduleEntry.new(kind: :timed, value: time) if time
200
+
201
+ OooScheduleEntry.new(kind: :date, value: Date.parse(raw))
202
+ rescue Date::Error
203
+ nil
204
+ end
205
+
206
+ def invalid_schedule_error(key, raw)
207
+ error("Invalid value for --#{key}: #{raw}. " \
208
+ "Use YYYY-MM-DD, HH:MM, 'today HH:MM', 'tomorrow HH:MM', or 'YYYY-MM-DD HH:MM'")
209
+ 1
210
+ end
211
+
212
+ def mixed_schedule_error
213
+ error('--start and --end must both be dates or both include a time')
214
+ 1
215
+ end
216
+
217
+ def schedule_kind(parsed) = parsed.kind
218
+ end
219
+
121
220
  # Enables/disables OOO: auto-reply, status message, presence, calendar event
122
221
  module OooActions
123
222
  private
@@ -139,28 +238,6 @@ module Teems
139
238
  results.values.all? ? 0 : 1
140
239
  end
141
240
 
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
241
  def execute_ooo_steps
165
242
  results = {}
166
243
  results[:auto_reply] = set_auto_reply
@@ -261,37 +338,29 @@ module Teems
261
338
  end
262
339
 
263
340
  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 }
341
+ timezone = detect_timezone
342
+ settings[:scheduledStartDateTime] = { dateTime: @schedule_start.reply_start_iso, timeZone: timezone }
343
+ settings[:scheduledEndDateTime] = { dateTime: @schedule_end.reply_end_iso, timeZone: timezone }
267
344
  end
268
345
 
269
346
  def build_ooo_event
347
+ timezone = detect_timezone
270
348
  { 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,
349
+ start: { dateTime: @schedule_start.event_start_iso, timeZone: timezone },
350
+ end: { dateTime: @schedule_end.event_end_iso, timeZone: timezone },
351
+ isAllDay: !@schedule_start.timed?, showAs: 'free',
352
+ isReminderOn: false, isOnlineMeeting: false,
277
353
  transactionId: SecureRandom.uuid,
278
354
  attendees: notify_list.map { |email| ooo_attendee(email) },
279
355
  responseRequested: false }
280
356
  end
281
357
 
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
358
  def ooo_attendee(email)
290
359
  { emailAddress: { address: email }, type: 'required' }
291
360
  end
292
361
 
293
362
  def notify_list
294
- @notify_list ||= ooo_config('notify') || []
363
+ @notify_list ||= @options[:invite] || ooo_config('notify') || []
295
364
  end
296
365
 
297
366
  def ooo_config(key)
@@ -302,20 +371,27 @@ module Teems
302
371
  # Manage out-of-office: auto-reply, status, presence, and calendar event
303
372
  class Ooo < Base
304
373
  include Support::Timezone
374
+ include Support::TimeParsing
305
375
  include OooDisplay
376
+ include OooSchedule
306
377
  include OooActions
307
378
  include OooBuildHelpers
308
379
 
309
380
  def initialize(args, runner:)
310
381
  @options = {}
382
+ @schedule_start = UNSCHEDULED_OOO_SCHEDULE
383
+ @schedule_end = UNSCHEDULED_OOO_SCHEDULE
311
384
  super
312
385
  end
313
386
 
387
+ SPLIT_OOO_EMAILS = ->(raw) { raw ? raw.split(',').map(&:strip).reject(&:empty?) : [] }
388
+
314
389
  OOO_OPTIONS = {
315
390
  '--message' => ->(opts, args) { opts[:message] = args.shift },
316
391
  '--start' => ->(opts, args) { opts[:start] = args.shift },
317
392
  '--end' => ->(opts, args) { opts[:end] = args.shift },
318
393
  '--event' => ->(opts, _args) { opts[:event] = true },
394
+ '--invite' => ->(opts, args) { opts[:invite] = SPLIT_OOO_EMAILS.call(args.shift) },
319
395
  '--no-status' => ->(opts, _args) { opts[:no_status] = true }
320
396
  }.freeze
321
397
 
data/lib/teems/runner.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Teems
4
4
  # API factory methods for Runner, extracted to keep method count manageable
5
5
  module ApiFactories
6
+ def meetings_api = Api::Meetings.new(api_client, account)
6
7
  def channels_api = Api::Channels.new(api_client, account)
7
8
  def chats_api = Api::Chats.new(api_client, account)
8
9
  def messages_api = Api::Messages.new(api_client, account)
@@ -56,6 +57,11 @@ module Teems
56
57
  Formatters::MessageFormatter.new(output: @output, cache_store: cache_store)
57
58
  end
58
59
 
60
+ # Safari JS runner for SharePoint automation
61
+ def safari_js_runner
62
+ Services::SafariJsRunner.new(output: @output)
63
+ end
64
+
59
65
  # Token extractor for Safari automation
60
66
  def token_extractor(auth_mode: :default)
61
67
  Services::TokenExtractor.new(output: @output, auth_mode: auth_mode)
@@ -162,9 +162,9 @@ module Teems
162
162
  run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
163
163
  end
164
164
 
165
- def delete(url, endpoint_key:, account:)
166
- uri = URI(url)
167
- run_request(uri.path, get_http_for_endpoint(endpoint_key)) do |http|
165
+ def delete(endpoint_key, path, account:)
166
+ uri = URI("#{resolve_endpoint(endpoint_key)}#{path}")
167
+ run_request(path, get_http_for_endpoint(endpoint_key)) do |http|
168
168
  req = Net::HTTP::Delete.new(uri)
169
169
  apply_auth(req, account, endpoint_key)
170
170
  http.request(req)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Teems
6
+ module Services
7
+ # Runs JavaScript in Safari's current tab via AppleScript
8
+ class SafariJsRunner
9
+ def initialize(output: nil)
10
+ @output = output
11
+ end
12
+
13
+ def available? = system('which', 'osascript', out: File::NULL, err: File::NULL)
14
+
15
+ def execute_js(js_code)
16
+ escaped = escape_js(js_code)
17
+ run_applescript(safari_js_script(escaped))
18
+ end
19
+
20
+ def navigate(url)
21
+ run_applescript(navigate_script(url))
22
+ end
23
+
24
+ def wait_for_load(timeout: 15)
25
+ timeout.times do
26
+ sleep 1
27
+ return if execute_js('document.readyState') == 'complete'
28
+ end
29
+ raise Teems::Error, 'Timed out waiting for page to load'
30
+ end
31
+
32
+ def page_url
33
+ run_applescript(page_url_script)
34
+ end
35
+
36
+ private
37
+
38
+ def escape_js(js_code)
39
+ js_code.gsub('\\', '\\\\\\\\').gsub('"', '\\"').gsub("\n", '\\n')
40
+ end
41
+
42
+ def safari_js_script(escaped_js)
43
+ <<~APPLESCRIPT
44
+ tell application "Safari"
45
+ if (count of windows) > 0 then
46
+ return do JavaScript "#{escaped_js}" in current tab of front window
47
+ end if
48
+ return ""
49
+ end tell
50
+ APPLESCRIPT
51
+ end
52
+
53
+ def navigate_script(url)
54
+ safe = escape_for_applescript(url)
55
+ "tell application \"Safari\"\nactivate\n" \
56
+ "if (count of windows) = 0 then\nmake new document with properties {URL:\"#{safe}\"}\n" \
57
+ "else\nset URL of current tab of front window to \"#{safe}\"\n" \
58
+ "end if\nend tell\n"
59
+ end
60
+
61
+ def escape_for_applescript(str)
62
+ str.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
63
+ end
64
+
65
+ def page_url_script
66
+ <<~APPLESCRIPT
67
+ tell application "Safari"
68
+ if (count of windows) > 0 then
69
+ return URL of current tab of front window
70
+ end if
71
+ return ""
72
+ end tell
73
+ APPLESCRIPT
74
+ end
75
+
76
+ def run_applescript(script)
77
+ out, _err, status = Open3.capture3('osascript', '-e', script)
78
+ return nil unless status.success?
79
+
80
+ out.strip
81
+ rescue IOError, SystemCallError => e
82
+ log("AppleScript execution failed: #{e.message}")
83
+ nil
84
+ end
85
+
86
+ def log(message) = @output&.debug(message)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Shared time-input parsing for commands that accept schedule inputs
6
+ # like "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
7
+ module TimeParsing
8
+ private
9
+
10
+ def parse_time_input(raw)
11
+ date, time_str = split_time_input(raw)
12
+ return unless time_str
13
+
14
+ date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
15
+ end
16
+
17
+ def split_time_input(raw)
18
+ base = Date.today
19
+ split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
20
+ end
21
+
22
+ def split_tomorrow_time(raw, base)
23
+ return unless raw.start_with?('tomorrow ')
24
+
25
+ [base + 1, raw.delete_prefix('tomorrow ')]
26
+ end
27
+
28
+ def split_today_time(raw, base)
29
+ return unless raw.match?(/\A(?:today\s+)?\d{1,2}:\d{2}\z/)
30
+
31
+ [base, raw.delete_prefix('today ')]
32
+ end
33
+
34
+ def split_absolute_time(raw)
35
+ return unless raw.match?(/\A\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\z/)
36
+
37
+ raw.split(/\s+/, 2)
38
+ end
39
+
40
+ def parse_relative_time(date, time_str)
41
+ hour, min = time_str.split(':').map(&:to_i)
42
+ Time.new(date.year, date.month, date.day, hour, min, 0)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def parse_absolute_time(date_str, time_str)
48
+ date = Date.parse(date_str)
49
+ parse_relative_time(date, time_str)
50
+ rescue Date::Error
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/teems/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teems
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/teems.rb CHANGED
@@ -67,6 +67,7 @@ module Teems
67
67
  autoload :TeamsUrlParser, 'teems/services/teams_url_parser'
68
68
  autoload :SyncStore, 'teems/services/sync_store'
69
69
  autoload :SyncDirNaming, 'teems/services/sync_dir_naming'
70
+ autoload :SafariJsRunner, 'teems/services/safari_js_runner'
70
71
  autoload :SyncEngine, 'teems/services/sync_engine'
71
72
  end
72
73
 
@@ -93,6 +94,7 @@ module Teems
93
94
  autoload :Who, 'teems/commands/who'
94
95
  autoload :Ooo, 'teems/commands/ooo'
95
96
  autoload :Org, 'teems/commands/org'
97
+ autoload :Meeting, 'teems/commands/meeting'
96
98
  autoload :Status, 'teems/commands/status'
97
99
  end
98
100
 
@@ -103,6 +105,7 @@ module Teems
103
105
  autoload :Channels, 'teems/api/channels'
104
106
  autoload :Chats, 'teems/api/chats'
105
107
  autoload :Files, 'teems/api/files'
108
+ autoload :Meetings, 'teems/api/meetings'
106
109
  autoload :Messages, 'teems/api/messages'
107
110
  autoload :Users, 'teems/api/users'
108
111
  end
@@ -113,5 +116,6 @@ module Teems
113
116
  autoload :HelpFormatter, 'teems/support/help_formatter'
114
117
  autoload :ErrorLogger, 'teems/support/error_logger'
115
118
  autoload :Timezone, 'teems/support/timezone'
119
+ autoload :TimeParsing, 'teems/support/time_parsing'
116
120
  end
117
121
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: teems
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -28,6 +28,7 @@ files:
28
28
  - lib/teems/api/chats.rb
29
29
  - lib/teems/api/client.rb
30
30
  - lib/teems/api/files.rb
31
+ - lib/teems/api/meetings.rb
31
32
  - lib/teems/api/messages.rb
32
33
  - lib/teems/api/users.rb
33
34
  - lib/teems/api/users_mailbox.rb
@@ -40,6 +41,9 @@ files:
40
41
  - lib/teems/commands/channels.rb
41
42
  - lib/teems/commands/chats.rb
42
43
  - lib/teems/commands/help.rb
44
+ - lib/teems/commands/meeting.rb
45
+ - lib/teems/commands/meeting_recording.rb
46
+ - lib/teems/commands/meeting_transcript.rb
43
47
  - lib/teems/commands/messages.rb
44
48
  - lib/teems/commands/ooo.rb
45
49
  - lib/teems/commands/org.rb
@@ -66,6 +70,7 @@ files:
66
70
  - lib/teems/services/configuration.rb
67
71
  - lib/teems/services/file_downloader.rb
68
72
  - lib/teems/services/headless_extract.rb
73
+ - lib/teems/services/safari_js_runner.rb
69
74
  - lib/teems/services/safari_oauth.rb
70
75
  - lib/teems/services/sync_dir_naming.rb
71
76
  - lib/teems/services/sync_engine.rb
@@ -78,6 +83,7 @@ files:
78
83
  - lib/teems/services/token_store.rb
79
84
  - lib/teems/support/error_logger.rb
80
85
  - lib/teems/support/help_formatter.rb
86
+ - lib/teems/support/time_parsing.rb
81
87
  - lib/teems/support/timezone.rb
82
88
  - lib/teems/support/xdg_paths.rb
83
89
  - lib/teems/version.rb
@@ -104,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
110
  - !ruby/object:Gem::Version
105
111
  version: '0'
106
112
  requirements: []
107
- rubygems_version: 3.6.9
113
+ rubygems_version: 4.0.10
108
114
  specification_version: 4
109
115
  summary: A command-line interface for Microsoft Teams
110
116
  test_files: []